diff --git a/.claude/reference/feature-flags.md b/.claude/reference/feature-flags.md index 797049e70a..12eff39b4d 100644 --- a/.claude/reference/feature-flags.md +++ b/.claude/reference/feature-flags.md @@ -35,6 +35,7 @@ if (features.platform) { | `datacenter` | Datacenter-related UI. | | `danger-zone` | Destructive settings actions (`features.dangerZone`). | | `byoc` | Bring Your Own Cloud (`features.byoc`): the BYOC option in the create-project flow, BYOC clusters in the project lists, and the `/orgs/$org/clusters/$cluster` page. Requires `platform`. | +| `services` | Managed services (`features.services`): the Services section of the onboarding product picker, the Durable Streams onboarding path, and the Services tab on the namespace settings drawer. Independent of `platform` (cloud hands out a managed service URL, self-host runs the worker container). | Deployment flavors map to flag sets roughly as: **cloud** = all on; **OSS** = `auth`/`platform`/`acl` off; **enterprise** = `acl` on, `auth`/`platform` off (engine enforces auth without a login UI). Do not treat `platform`/`auth` as "engine requires credentials" — that is `acl`. **`compute` is opt-in even on cloud** — each Railway service adds it to `VITE_FEATURE_FLAGS` per-environment (e.g. staging on, prod off) rather than inheriting the cloud default-on set. @@ -69,7 +70,7 @@ localStorage.setItem("FEATURE_FLAGS", ""); location.reload(); // Full cloud: all flags on (see the commented canonical list in frontend/.env.local) localStorage.setItem( "FEATURE_FLAGS", - "compute,platform,acl,auth,captcha,branding,support,billing,datacenter,danger-zone,multitenancy,byoc", + "compute,platform,acl,auth,captcha,branding,support,billing,datacenter,danger-zone,multitenancy,byoc,services", ); location.reload(); diff --git a/frontend/public/images/brand/durable-streams-mark.svg b/frontend/public/images/brand/durable-streams-mark.svg new file mode 100644 index 0000000000..5655143b95 --- /dev/null +++ b/frontend/public/images/brand/durable-streams-mark.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/app/actors-grid.tsx b/frontend/src/app/actors-grid.tsx index a0ab9a8d3d..4a8fd2a4dd 100644 --- a/frontend/src/app/actors-grid.tsx +++ b/frontend/src/app/actors-grid.tsx @@ -206,7 +206,7 @@ export function ActorsGrid({ namespaceLabel }: { namespaceLabel?: string }) {

- Actors + Components

@@ -223,11 +223,12 @@ export function ActorsGrid({ namespaceLabel }: { namespaceLabel?: string }) { ) : (

- No actors yet + No components yet

- Deploy code that registers an actor to - see it here. + {features.services + ? "Deploy code that registers an actor or add a service to see it here." + : "Deploy code that registers an actor to see it here."}
diff --git a/frontend/src/app/compute-deploy.tsx b/frontend/src/app/compute-deploy.tsx index fd356b74d0..1871f996ab 100644 --- a/frontend/src/app/compute-deploy.tsx +++ b/frontend/src/app/compute-deploy.tsx @@ -23,6 +23,7 @@ import { type HostedTarget, hostedUrl } from "@/components/mcp/scope"; import { getAgentInstructionsPrompt, getComputeAddendum, + getDurableStreamsServiceUrl, type McpSetup, type OnboardingTarget, } from "@/content/agent-prompts"; @@ -82,6 +83,7 @@ export function useAgentInstructionsCode({ const secretToken = useRivetDsn({ kind: "secret", endpoint }); const mcp = useMcpSetup(); const namespace = useEngineCompatDataProvider().engineNamespace; + const durableStreamsServiceUrl = useDurableStreamsServiceUrl(); return getAgentInstructionsPrompt({ providerStr, @@ -96,9 +98,19 @@ export function useAgentInstructionsCode({ cliDeploy: provider === "rivet", target, mcp, + durableStreamsServiceUrl, }); } +// Where the managed Durable Streams service for this namespace is served on +// Rivet Cloud. Self-hosted flavors run the worker themselves, so there is no +// fixed URL to hand out. +export function useDurableStreamsServiceUrl(): string | undefined { + const namespace = useEngineCompatDataProvider().engineNamespace; + if (!features.compute || !features.services) return undefined; + return getDurableStreamsServiceUrl(getRivetRunUrl(namespace)); +} + // The MCP setup the copy-prompt should instruct the agent to perform. The hosted // connection needs the user to approve an OAuth window, so the agent has to hand // that step back; the local stdio server it can wire up itself. diff --git a/frontend/src/app/engine-namespace-landing.tsx b/frontend/src/app/engine-namespace-landing.tsx index 1054419f7a..298cba845f 100644 --- a/frontend/src/app/engine-namespace-landing.tsx +++ b/frontend/src/app/engine-namespace-landing.tsx @@ -5,8 +5,9 @@ import { Button, H1, ScrollArea, SmallText, WithTooltip } from "@/components"; import { useEngineNamespaceDataProvider } from "@/components/actors"; import { NoProvidersAlert } from "@/components/actors/no-providers-alert"; import { VisibilitySensor } from "@/components/visibility-sensor"; -import { AddComponentButton, AddComponentCard } from "./add-component-card"; +import { features } from "@/lib/features"; import { ActorBuildCard, ActorGridCardSkeleton } from "./actors-grid"; +import { AddComponentButton, AddComponentCard } from "./add-component-card"; // Engine (OSS / enterprise) namespace landing shown when no Actor name is // selected. This is the engine counterpart to the cloud `ActorsGrid`; keep the @@ -79,7 +80,7 @@ export function EngineNamespaceLanding() {

- Actors + Components

@@ -96,11 +97,12 @@ export function EngineNamespaceLanding() { ) : (

- No actors yet + No components yet

- Deploy code that registers an actor to - see it here. + {features.services + ? "Deploy code that registers an actor or add a service to see it here." + : "Deploy code that registers an actor to see it here."}
diff --git a/frontend/src/app/getting-started.tsx b/frontend/src/app/getting-started.tsx index 2327f4a39a..baa66e515d 100644 --- a/frontend/src/app/getting-started.tsx +++ b/frontend/src/app/getting-started.tsx @@ -26,20 +26,38 @@ import { CodeGroup, CodeGroupSyncProvider, CodePreview, + DiscreteCopyButton, FormField, } from "@/components"; import { useCloudNamespaceDataProvider, useEngineCompatDataProvider, } from "@/components/actors"; +import { AgentSelectStep } from "@/components/onboarding/agent-os/agent-select-step"; +import { buildAgentOsSetup } from "@/components/onboarding/agent-os/build-agent-os-setup"; +import { + DEFAULT_AGENT, + DEFAULT_PACKAGES, + DEFAULT_SANDBOX_PROVIDER, +} from "@/components/onboarding/agent-os/catalog"; +import { ProductPicker } from "@/components/products/product-picker"; import { defineStepper } from "@/components/ui/stepper"; import { + DURABLE_STREAMS_CLIENT_PACKAGE, + DURABLE_STREAMS_DEV_COMMAND, + DURABLE_STREAMS_DOCS_URL, + DURABLE_STREAMS_LOCAL_URL, + getDurableStreamsClientSnippet, + getDurableStreamUrl, getOnboardingTargetCopy, type OnboardingTarget, } from "@/content/agent-prompts"; +import { + MANAGED_SERVICES_POOL_CONFIG, + useEnableManagedServicesMutation, +} from "@/app/managed-services"; import { deriveProviderFromMetadata } from "@/lib/data"; import { engineEnv } from "@/lib/env"; -import { ProductPicker } from "@/components/products/product-picker"; import { features } from "@/lib/features"; import { queryClient } from "@/queries/global"; import { cn } from "../components/lib/utils"; @@ -52,6 +70,14 @@ import { DropdownMenuTrigger, } from "../components/ui/dropdown-menu"; import { TEST_IDS } from "../utils/test-ids"; +import { + AgentPromptBanner, + CommandBox, + defaultRuntimeModeForProvider, + useAgentInstructionsCode, + useComputeInstructionsCode, + useDurableStreamsServiceUrl, +} from "./compute-deploy"; import { DeploymentCheck } from "./deployment-check"; import { useEndpoint } from "./dialogs/connect-manual-serverful-frame"; import { @@ -59,7 +85,7 @@ import { Configuration, ConfigurationAccordion, } from "./dialogs/connect-manual-serverless-frame"; -import { EnvVariables } from "./env-variables"; +import { EnvVariables, useRivetDsn } from "./env-variables"; import { StepperForm, StepVisibilityContext, @@ -67,20 +93,6 @@ import { } from "./forms/stepper-form"; import { Content } from "./layout"; import { StepContentSkeleton } from "./onboarding-skeleton"; -import { AgentSelectStep } from "@/components/onboarding/agent-os/agent-select-step"; -import { buildAgentOsSetup } from "@/components/onboarding/agent-os/build-agent-os-setup"; -import { - DEFAULT_AGENT, - DEFAULT_PACKAGES, - DEFAULT_SANDBOX_PROVIDER, -} from "@/components/onboarding/agent-os/catalog"; -import { - AgentPromptBanner, - CommandBox, - defaultRuntimeModeForProvider, - useAgentInstructionsCode, - useComputeInstructionsCode, -} from "./compute-deploy"; function platformTitle(provider: unknown): string { return ( @@ -100,7 +112,13 @@ const stepper = defineStepper( // it must survive navigation past this step. schema: z.object({ template: z - .enum(["actor", "agent-os", "workflows", "dynamic-apps"]) + .enum([ + "actor", + "agent-os", + "workflows", + "dynamic-apps", + "durable-streams", + ]) .optional(), }), group: "local", @@ -140,6 +158,18 @@ const stepper = defineStepper( isVisible: (values: Record) => values.template === "agent-os", }, + // Services are managed by Rivet, so there is no runner or image to deploy. + // Durable Streams gets this step in place of the platform deploy below. + { + id: "services", + title: "Connect to Durable Streams", + next: "Done", + previous: "Back", + schema: z.object({}), + group: "deploy", + isVisible: (values: Record) => + features.services && values.template === "durable-streams", + }, { id: "deploy", title: "Deploy", @@ -149,6 +179,8 @@ const stepper = defineStepper( previous: "Back", assist: true, group: "deploy", + isVisible: (values: Record) => + values.template !== "durable-streams", schema: (values: Record) => { const provider = (values.provider as string) || "rivet"; if (provider === "rivet") { @@ -362,6 +394,11 @@ export function GettingStarted({ ), + services: () => ( + + + + ), deploy: () => ( @@ -382,7 +419,18 @@ export function GettingStarted({ >; const provider = (accumulated.provider ?? live.provider) as string | undefined; - if (provider && provider !== "rivet") { + const template = (accumulated.template ?? + live.template) as + | OnboardingTarget + | undefined; + // Services skip the platform deploy step, so + // the provider fields hold untouched defaults; + // writing them would register a bogus runner. + if ( + provider && + provider !== "rivet" && + template !== "durable-streams" + ) { await saveProviderConfig({ ...accumulated, provider, @@ -734,7 +782,11 @@ function AgentOsKeyNotice() { // Product selector shown atop the first step. Selecting a product is the whole // step, so the choice advances the wizard instead of parking the user in front // of a Continue button. -function BuildTargetSelector() { +function BuildTargetSelector({ + onSelectTarget, +}: { + onSelectTarget?: (target: OnboardingTarget) => void; +}) { const { control, setValue } = useFormContext(); const submitForm = useStepperFormSubmit(); return ( @@ -744,6 +796,7 @@ function BuildTargetSelector() { render={() => ( { + onSelectTarget?.(template); setValue("template", template, { shouldDirty: true, shouldTouch: true, @@ -757,6 +810,22 @@ function BuildTargetSelector() { ); } +// Picking a service provisions the managed services pool up front so it is +// warming while the user reads the connection steps. +function CloudBuildTargetSelector() { + const { mutate } = useEnableManagedServicesMutation(); + return ( + { + if (target !== "durable-streams") { + return; + } + mutate(MANAGED_SERVICES_POOL_CONFIG); + }} + /> + ); +} + function useOnboardingTarget(): OnboardingTarget { return ( (useWatch({ name: "template" }) as OnboardingTarget | undefined) ?? @@ -765,12 +834,19 @@ function useOnboardingTarget(): OnboardingTarget { } function SelectProductStep() { - return ; + return features.compute ? ( + + ) : ( + + ); } function RunLocallyStep() { const target = useOnboardingTarget(); const copy = getOnboardingTargetCopy(target); + if (target === "durable-streams") { + return ; + } return (
{features.compute ? ( @@ -779,31 +855,241 @@ function RunLocallyStep() { )} -
-
-

- Follow the quickstart guide + +

+ ); +} + +function QuickstartLink({ + description, + url, + label = "Quickstart guide", +}: { + description: string; + url: string; + label?: string; +}) { + return ( +
+
+

Follow the quickstart guide

+

{description}

+
+ +
+ ); +} + +// Durable Streams is a service, so "run locally" means starting the bundled dev +// server and pointing the client SDK at it. There is no project to scaffold, so +// the prompt is the generic one (no compute addendum) on every flavor. +function DurableStreamsRunLocally() { + const code = useAgentInstructionsCode({ target: "durable-streams" }); + const copy = getOnboardingTargetCopy("durable-streams"); + return ( +
+ + +
+ +
+

Start the dev server

+

+ Runs a local Rivet control plane and the Durable Streams + worker. Streams are served at{" "} + + {getDurableStreamUrl( + DURABLE_STREAMS_LOCAL_URL, + "", + )} + + .

-

- {copy.quickstartDescription} + +

+
+
+ +
+

Connect a client

+

+ Install the official client and append to your first + stream.

+
+ + +
-
+ ); +} + +function DurableStreamsClientSnippet({ serviceUrl }: { serviceUrl: string }) { + const code = getDurableStreamsClientSnippet(serviceUrl); + return ( + + {[ + code} + className="m-0" > - + , + ]} + + ); +} + +// Final step on the Durable Streams path. On Rivet Cloud the service is +// managed per namespace, so this hands out the service URL. Self-hosted +// flavors run the worker container against their own control plane instead. +// Also rendered by the "Add Durable Streams" sheet from namespace settings. +function DurableStreamsConnect() { + const code = useAgentInstructionsCode({ target: "durable-streams" }); + const serviceUrl = useDurableStreamsServiceUrl(); + return ( +
+ + + {serviceUrl ? ( + + ) : ( + + )} +
+ ); +} + +function DurableStreamsManagedService({ serviceUrl }: { serviceUrl: string }) { + return ( + <> +
-
+
+ +
+

Point your client at it

+

+ Swap the local URL for the service URL. Streams live + under{" "} + + v1/stream/<path> + + . +

+ +
+
+ + ); +} + +function DurableStreamsSelfHosted() { + const endpoint = useRivetDsn({ kind: "secret" }); + const command = `docker run -p 8642:8642 \\ + -e RIVET_ENDPOINT="${endpoint}" \\ + -e HOST=0.0.0.0 \\ + rivetdev/services`; + return ( + <> +
+ +
+

Run the worker

+

+ The worker runs your streams and connects to this + namespace.{" "} + + RIVET_ENDPOINT + {" "} + contains an admin credential, so keep it out of source + control and browser code. +

+ +
+
+
+ +
+

Point your client at it

+

+ Streams are served at{" "} + + http://<host>:8642/durable-streams/v1/stream/<path> + + . See the{" "} + + integration guide + {" "} + for the control plane flags Durable Streams needs. +

+ :8642/durable-streams/"} + /> +
+
+ ); } diff --git a/frontend/src/app/managed-services.ts b/frontend/src/app/managed-services.ts new file mode 100644 index 0000000000..3cf1075462 --- /dev/null +++ b/frontend/src/app/managed-services.ts @@ -0,0 +1,51 @@ +import { useMutation } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { useCloudNamespaceDataProvider } from "@/components/actors"; +import { isRivetApiError } from "@/lib/errors"; +import { queryClient } from "@/queries/global"; + +// Rivet runs every managed service for a namespace out of one dedicated compute +// pool. The pool existing is what "services are enabled" means for a namespace, +// so both the enable button and the service URL key off it. +export const MANAGED_SERVICES_POOL = "x-rivet-managed-services"; + +export const MANAGED_SERVICES_POOL_CONFIG = { + pool: MANAGED_SERVICES_POOL, + displayName: "Services", + image: { preset: { managedServices: {} } }, +}; + +export function useManagedServicesPoolQueryOptions() { + const dataProvider = useCloudNamespaceDataProvider(); + return dataProvider.currentNamespaceManagedPoolQueryOptions({ + pool: MANAGED_SERVICES_POOL, + safe: true, + }); +} + +export function useEnableManagedServicesMutation() { + const dataProvider = useCloudNamespaceDataProvider(); + return useMutation({ + ...dataProvider.upsertCurrentNamespaceManagedPoolMutationOptions(), + onSuccess: async () => { + await queryClient.invalidateQueries( + dataProvider.currentNamespaceManagedPoolQueryOptions({ + pool: MANAGED_SERVICES_POOL, + safe: true, + }), + ); + }, + // Replaces the generic mutation-cache toast rather than stacking on it. + meta: { hideErrorToast: true }, + onError: (error) => { + const reason = isRivetApiError(error) + ? error.body?.message + : error.message; + toast.error("Failed to enable services", { + description: + reason || + "The services pool could not be provisioned. Try again in a moment.", + }); + }, + }); +} diff --git a/frontend/src/app/settings-drawer.tsx b/frontend/src/app/settings-drawer.tsx index 380dee2d8a..a3a6395c38 100644 --- a/frontend/src/app/settings-drawer.tsx +++ b/frontend/src/app/settings-drawer.tsx @@ -5,6 +5,7 @@ import { faClose, faCreditCard, faGear, + faPuzzlePiece, faRivet, faSliders, faSparkles, @@ -30,6 +31,7 @@ import { BillingUsageGauge } from "./billing/billing-usage-gauge"; import { PoolSwitcher, poolHeaderText, resolvePoolName } from "./pool-switcher"; import { BillingPanel } from "./settings-pages/billing-panel"; import { NamespaceComputeContent } from "./settings-pages/namespace-compute"; +import { NamespaceServicesContent } from "./settings-pages/namespace-services"; import { NamespaceAdvancedContent, NamespaceSettingsContent, @@ -42,6 +44,7 @@ import { WhatsNewPanel } from "./settings-pages/whats-new-panel"; export type SettingsTab = | "profile" | "settings" + | "services" | "compute" | "advanced" | "billing" @@ -64,6 +67,7 @@ const NAV_SECTIONS: Array<{ label: "Namespace", items: [ { key: "settings", label: "Settings", icon: faGear }, + { key: "services", label: "Services", icon: faPuzzlePiece }, { key: "compute", label: "Compute", icon: faRivet }, { key: "advanced", label: "Advanced", icon: faSliders }, ], @@ -96,6 +100,11 @@ const TAB_META: Record = { ? "Connect your RivetKit application to Rivet Cloud. Use your cloud of choice to run Rivet Actors." : "Connect providers and runners to this namespace. Use your cloud of choice to run Rivet Actors.", }, + services: { + title: "Services", + description: + "Add Rivet-managed services to this namespace and point your app at them.", + }, compute: { title: "Compute", description: "Manage the Rivet Compute deployment for this namespace.", @@ -160,9 +169,16 @@ export function SettingsDrawer({ // Engine flavors only expose namespace-scoped settings (no account, billing, // or organization), so the nav collapses to the Namespace section. - const navSections = features.platform - ? NAV_SECTIONS - : NAV_SECTIONS.filter((section) => section.label === "Namespace"); + const navSections = ( + features.platform + ? NAV_SECTIONS + : NAV_SECTIONS.filter((section) => section.label === "Namespace") + ).map((section) => ({ + ...section, + items: section.items.filter( + (item) => item.key !== "services" || features.services, + ), + })); const meta = TAB_META[activeTab]; const titleNode: ReactNode = @@ -366,6 +382,8 @@ function TabContent({ tab }: { tab: SettingsTab }) { return ; case "settings": return ; + case "services": + return ; case "compute": return ; case "advanced": @@ -660,6 +678,43 @@ function CloudAdvancedTabBody() { return ; } +function ServicesTabBody() { + if (!features.platform) { + return ; + } + return ; +} + +function EngineNamespaceServices() { + return useEngineNamespaceReady() ? ( + + ) : ( + + ); +} + +function CloudServicesTabBody() { + const namespaceMatch = useMatch({ + from: "/_context/orgs/$organization/projects/$project/ns/$namespace", + shouldThrow: false, + }); + + if (!namespaceMatch) { + return ( + + ); + } + if (!namespaceMatch.loaderData) { + return ; + } + return ; +} + export function settingsParamToTab( param: string | undefined, ): SettingsTab | null { @@ -675,6 +730,8 @@ export function settingsParamToTab( // the regular Settings tab on flavors without it. case "compute": return features.compute ? "compute" : "settings"; + case "services": + return features.services ? "services" : "settings"; // Legacy: members lived in its own tab before being merged into // Organization. Keep the deep link working. case "members": diff --git a/frontend/src/app/settings-pages/namespace-services.tsx b/frontend/src/app/settings-pages/namespace-services.tsx new file mode 100644 index 0000000000..7049f2a7b5 --- /dev/null +++ b/frontend/src/app/settings-pages/namespace-services.tsx @@ -0,0 +1,234 @@ +import { faExternalLink, faPlus, Icon } from "@rivet-gg/icons"; +import { useQueries, useQuery } from "@tanstack/react-query"; +import { + Button, + cn, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Ping, + Skeleton, +} from "@/components"; +import { useEngineCompatDataProvider } from "@/components/actors"; +import { + getProduct, + getProductDocsUrl, + ProductMark, +} from "@/components/products/product-picker"; +import { features } from "@/lib/features"; +import { + MANAGED_SERVICES_POOL_CONFIG, + useEnableManagedServicesMutation, + useManagedServicesPoolQueryOptions, +} from "../managed-services"; +import { SettingsCard } from "./settings-card"; + +// Services are Rivet-run workers the user connects to a namespace rather than +// code they deploy. Each one registers well-known actor names, so a namespace +// "has" the service once an actor under any of those names exists. Setup +// instructions live in the docs so they are not duplicated here. +const SERVICES = [ + { + product: getProduct("durable-streams"), + actorNames: ["durableStream"], + }, +]; + +function openDocs(target: (typeof SERVICES)[number]["product"]["target"]) { + window.open(getProductDocsUrl(target), "_blank", "noopener,noreferrer"); +} + +export function NamespaceServicesContent() { + return features.compute ? : ; +} + +// On Rivet Cloud every managed service runs in one dedicated compute pool, so +// the namespace has to provision it before any service is reachable. Other +// flavors run the service worker themselves and have nothing to enable. +function CloudServices() { + const { data: pool, isLoading } = useQuery( + useManagedServicesPoolQueryOptions(), + ); + + if (isLoading) { + return ; + } + if (!pool) { + return ; + } + return ; +} + +// Mirrors one row of ServicesList. The card chrome is static copy, so only the +// per-service parts shimmer. +function ServicesSkeleton() { + return ( +
+ } + > +
+
+ +
+ + +
+
+
+ + +
+
+
+
+ ); +} + +function EnableServices() { + const { mutate, isPending } = useEnableManagedServicesMutation(); + + return ( +
+
+ Services are not enabled +
+

+ Enable services to provision the managed pool that runs them in + this namespace. +

+ +
+ ); +} + +function ServicesList() { + const dataProvider = useEngineCompatDataProvider(); + // Probed per service rather than read off the namespace build list: that + // list is paginated, so a service registered past the first page would read + // as not connected. + const connections = useQueries({ + queries: SERVICES.map((service) => ({ + ...dataProvider.actorsListPage1PollQueryOptions({ + n: service.actorNames, + filters: { showDestroyed: { value: ["true"] } }, + }), + select: (data: { actors: unknown[] }) => data.actors.length > 0, + })), + }); + + return ( +
+ + + + + + {SERVICES.map((service) => ( + + } + onSelect={() => + openDocs(service.product.target) + } + > + {service.product.label} + + ))} + + + } + > + {SERVICES.map((service, idx) => { + const connected = connections[idx]?.data ?? false; + return ( +
+
+ +
+
+ {service.product.label} +
+
+ {service.product.description} +
+
+
+
+ {connected ? ( + + + Connected + + ) : ( + + Not connected + + )} + +
+
+ ); + })} +
+
+ ); +} diff --git a/frontend/src/components/products/product-picker.tsx b/frontend/src/components/products/product-picker.tsx index e360ade462..1cfd88d779 100644 --- a/frontend/src/components/products/product-picker.tsx +++ b/frontend/src/components/products/product-picker.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from "react"; +import { cn } from "@/components/lib/utils"; import { Badge } from "@/components/ui/badge"; import { getOnboardingTargetCopy, @@ -7,8 +8,14 @@ import { import { features } from "@/lib/features"; import { publicUrl } from "@/lib/utils"; +// Products are SDKs the user builds on and deploys themselves. Services are +// managed by Rivet: the user only points a client at them, so they onboard +// through a different path and are listed under their own heading. +export type ProductSection = "products" | "services"; + type Product = { target: OnboardingTarget; + section: ProductSection; label: string; description: string; markFileName: string; @@ -19,6 +26,7 @@ type Product = { const PRODUCTS: Product[] = [ { target: "actor", + section: "products", label: "Actors", description: "The primitive for realtime, stateful workloads", markFileName: "actors-mark.svg", @@ -26,6 +34,7 @@ const PRODUCTS: Product[] = [ }, { target: "agent-os", + section: "products", label: "agentOS", description: "Hand every agent a computer of its own", markFileName: "agentos-mark.svg", @@ -33,6 +42,7 @@ const PRODUCTS: Product[] = [ }, { target: "workflows", + section: "products", label: "Workflows", description: "Write multi-step operations that survive restarts", markFileName: "workflows-mark.svg", @@ -40,32 +50,92 @@ const PRODUCTS: Product[] = [ }, { target: "dynamic-apps", + section: "products", label: "Dynamic Apps", description: "Deploy AI-generated apps for your users", markFileName: "dynamic-apps-mark.svg", badge: "Preview", isAvailable: () => true, }, + { + target: "durable-streams", + section: "services", + label: "Durable Streams", + description: "Real-time streams with durable, replayable history", + markFileName: "durable-streams-mark.svg", + isAvailable: () => features.services, + }, +]; + +const SECTIONS: { id: ProductSection; label: string }[] = [ + { id: "products", label: "Products" }, + { id: "services", label: "Services" }, ]; export function getAvailableProducts() { return PRODUCTS.filter((p) => p.isAvailable()); } +export function getProductSections() { + const available = getAvailableProducts(); + return SECTIONS.map((section) => ({ + ...section, + products: available.filter((p) => p.section === section.id), + })).filter((section) => section.products.length > 0); +} + +export function getProduct(target: OnboardingTarget) { + const product = PRODUCTS.find((p) => p.target === target); + if (!product) { + throw new Error(`Unknown product: ${target}`); + } + return product; +} + export function getProductDocsUrl(target: OnboardingTarget) { return getOnboardingTargetCopy(target).quickstartUrl; } -export function ProductMark({ fileName }: { fileName: string }) { - return ( +// Product marks ship as tiles (logo inside a colored rounded square with an +// inner ring). Service marks are the bare third-party logo, so give them a +// neutral tile here to match the products' visual weight. +export function ProductMark({ + fileName, + section, + size = "md", +}: { + fileName: string; + section: ProductSection; + /** `md` is the picker card size; `sm` fits inline in menus and text. */ + size?: "sm" | "md"; +}) { + const isService = section === "services"; + const img = ( ); + if (!isService) { + return img; + } + return ( + + {img} + + ); } export function ProductCard({ @@ -122,17 +192,34 @@ export function ProductPicker({
- {getAvailableProducts().map((product) => ( - } - label={product.label} - description={product.description} - badge={product.badge} - onSelect={() => onSelect(product.target)} - /> + {getProductSections().map((section) => ( +
+ + {section.label} + +
+ {section.products.map((product) => ( + + } + label={product.label} + description={product.description} + badge={product.badge} + onSelect={() => onSelect(product.target)} + /> + ))} +
+
))}

diff --git a/frontend/src/content/agent-prompts.test.ts b/frontend/src/content/agent-prompts.test.ts index 70f4ddc9d4..a3e432df75 100644 --- a/frontend/src/content/agent-prompts.test.ts +++ b/frontend/src/content/agent-prompts.test.ts @@ -83,6 +83,55 @@ describe("onboarding product prompts", () => { expect(prompt).not.toContain("Drive actors via the inspector HTTP API"); }); + it("treats Durable Streams as a client-only service", () => { + const prompt = getAgentInstructionsPrompt({ + ...agentPromptOptions, + target: "durable-streams", + }); + + expect(prompt).toContain("# Durable Streams Setup"); + expect(prompt).toContain("npx @rivet-dev/services dev"); + expect(prompt).toContain("npm install @durable-streams/client"); + expect(prompt).toContain( + "http://127.0.0.1:8642/durable-streams/v1/stream/demo", + ); + expect(prompt).toContain( + "https://rivet.dev/actors/integrations/durable-streams", + ); + expect(prompt).not.toContain("npm install rivetkit"); + expect(prompt).not.toContain("registry.listen"); + expect(prompt).not.toContain("RIVET_PUBLIC_ENDPOINT"); + }); + + it("hands out the managed service URL for Durable Streams on Rivet Cloud", () => { + const prompt = getAgentInstructionsPrompt({ + ...agentPromptOptions, + target: "durable-streams", + durableStreamsServiceUrl: + "https://onboarding-test.staging.rivet.run/durable-streams/", + }); + + expect(prompt).toContain( + "https://onboarding-test.staging.rivet.run/durable-streams/v1/stream/", + ); + expect(prompt).toContain( + "managed service in the `onboarding-test` namespace", + ); + expect(prompt).not.toContain("docker run"); + expect(prompt).not.toContain("secret_test"); + }); + + it("runs the Durable Streams worker against the control plane when self-hosting", () => { + const prompt = getAgentInstructionsPrompt({ + ...agentPromptOptions, + target: "durable-streams", + }); + + expect(prompt).toContain("rivetdev/services"); + expect(prompt).toContain('RIVET_ENDPOINT="secret_test"'); + expect(prompt).not.toContain("rivet.run/durable-streams/"); + }); + it("replaces Actor verification with an app URL for Dynamic Apps on Compute", () => { const prompt = getComputeAddendum({ ...computePromptOptions, diff --git a/frontend/src/content/agent-prompts.ts b/frontend/src/content/agent-prompts.ts index 3be81ebe07..dca44ee16a 100644 --- a/frontend/src/content/agent-prompts.ts +++ b/frontend/src/content/agent-prompts.ts @@ -2,7 +2,47 @@ export type OnboardingTarget = | "actor" | "agent-os" | "workflows" - | "dynamic-apps"; + | "dynamic-apps" + | "durable-streams"; + +// Durable Streams is a managed service rather than user code, so the local +// dev server and the client SDK are fixed and the prompts can name them. +export const DURABLE_STREAMS_DOCS_URL = + "https://rivet.dev/actors/integrations/durable-streams"; +export const DURABLE_STREAMS_DEV_COMMAND = "npx @rivet-dev/services dev"; +export const DURABLE_STREAMS_LOCAL_URL = + "http://127.0.0.1:8642/durable-streams/"; +export const DURABLE_STREAMS_CLIENT_PACKAGE = "@durable-streams/client"; + +// Path under a Rivet Run origin where the managed Durable Streams service is +// served, e.g. `https://.rivet.run/durable-streams/`. +export function getDurableStreamsServiceUrl(rivetRunUrl: string) { + return `${rivetRunUrl.replace(/\/?$/, "/")}durable-streams/`; +} + +// The Durable Streams protocol mounts streams at `v1/stream/` under the +// service origin. +export function getDurableStreamUrl(serviceUrl: string, streamPath: string) { + return `${serviceUrl.replace(/\/?$/, "/")}v1/stream/${streamPath}`; +} + +export function getDurableStreamsClientSnippet(serviceUrl: string) { + return `import { DurableStream } from "${DURABLE_STREAMS_CLIENT_PACKAGE}"; + +const stream = await DurableStream.create({ + url: "${getDurableStreamUrl(serviceUrl, "demo")}", + contentType: "application/json", +}); + +await stream.append(JSON.stringify({ message: "hello" })); + +const res = await stream.stream<{ message: string }>(); +res.subscribeJson(async (batch) => { + for (const item of batch.items) { + console.log(item.message); + } +});`; +} const onboardingTargetCopy: Record< OnboardingTarget, @@ -35,6 +75,12 @@ const onboardingTargetCopy: Record< "Build a Dynamic Apps host and deploy a sample app by hand.", quickstartUrl: "https://rivet.dev/dynamic-apps/docs/quickstart/", }, + "durable-streams": { + promptObject: "a Durable Streams client", + quickstartDescription: + "Run Durable Streams locally and connect a client by hand.", + quickstartUrl: DURABLE_STREAMS_DOCS_URL, + }, }; export function getOnboardingTargetCopy(target: OnboardingTarget) { @@ -172,6 +218,100 @@ ${mcpSection}## Step 4: Verify the workflow end-to-end Report the workflow host URL, command used, action or queue invoked, observed result, and any remaining setup the user must complete.`; } +// Durable Streams ships as a service, so there is no user code to deploy. The +// prompt walks the agent through the local dev server, the client SDK, and then +// either the managed service URL (Rivet Cloud) or running the worker container +// against the user's own control plane (self-hosted). +function getDurableStreamsPrompt({ + secretToken, + namespace, + durableStreamsServiceUrl, + mcpSection, +}: { + secretToken: string; + namespace?: string; + durableStreamsServiceUrl?: string; + mcpSection: string; +}) { + const localStreamUrl = getDurableStreamUrl( + DURABLE_STREAMS_LOCAL_URL, + "", + ); + const deploySteps = durableStreamsServiceUrl + ? `Durable Streams runs as a managed service in the \`${namespace ?? "selected"}\` namespace on Rivet Cloud. There is nothing to build or deploy. + +1. Point the client at the managed service instead of the local server. Streams live at \`${getDurableStreamUrl(durableStreamsServiceUrl, "")}\`. +2. Read the service URL from an environment variable (for example \`DURABLE_STREAMS_URL\`) so local development keeps using \`${DURABLE_STREAMS_LOCAL_URL}\` and production uses \`${durableStreamsServiceUrl}\`. +3. Verify against the managed service: append one record and read it back with \`curl '${getDurableStreamUrl(durableStreamsServiceUrl, "demo")}?offset=-1'\`.` + : `Durable Streams runs as a single worker container that connects to the user's Rivet control plane. + +1. Run the worker next to the application and point it at the control plane. \`RIVET_ENDPOINT\` contains a secret admin credential, so write it to the platform's secret store or a local \`.env\` that is listed in \`.gitignore\`. Never commit it and never expose it to browser code: + \`\`\`bash + docker run -p 8642:8642 \\ + -e RIVET_ENDPOINT="${secretToken}" \\ + -e HOST=0.0.0.0 \\ + rivetdev/services + \`\`\` +2. Point the client at the worker. Streams live at \`http://:8642/durable-streams/v1/stream/\`. Read the URL from an environment variable (for example \`DURABLE_STREAMS_URL\`) so local development keeps using \`${DURABLE_STREAMS_LOCAL_URL}\`. +3. Verify against the deployed worker: append one record and read it back with \`curl 'http://:8642/durable-streams/v1/stream/demo?offset=-1'\`.`; + + return `# Durable Streams Setup + +Read the Durable Streams integration guide before changing the project: ${DURABLE_STREAMS_DOCS_URL} + +Durable Streams is an open standard for real-time streams with durable, replayable history. Rivet runs it as a service backed by Rivet Actors, so the project only needs a client. Do not scaffold Rivet Actors, a RivetKit registry, or a Dockerfile for this. + +## Step 1: Understand the project + +Determine whether the user wants a new project or wants to add Durable Streams to the existing application. Inspect the package manager, runtime, and any existing streaming or realtime code first. Good fits are agent session transcripts, CRDT sync, and change feeds that clients resume from an offset. + +## Step 2: Run Durable Streams locally + +Start the local Rivet control plane and the Durable Streams worker in a separate terminal: + +\`\`\`bash +${DURABLE_STREAMS_DEV_COMMAND} +\`\`\` + +Streams are then available at \`${localStreamUrl}\`. If the project already runs Rivet Actors with the RivetKit TypeScript SDK (2.3.12 or newer), Durable Streams is already served by that process at \`http://127.0.0.1:6420/durable-streams/v1/stream/\` and this command is not needed. + +## Step 3: Connect a client + +- Install the official client with the project's package manager: \`npm install ${DURABLE_STREAMS_CLIENT_PACKAGE}\`. +- Create a stream and append to it. Keep the base URL in one place so it can point at production later: + +\`\`\`ts +${getDurableStreamsClientSnippet(DURABLE_STREAMS_LOCAL_URL)} +\`\`\` + +- Readers can catch up from offset \`-1\` (the beginning), resume from a saved offset, or tail live. Persist the offset from the \`Stream-Next-Offset\` header when the application needs to resume. +- For structured data, JSON mode, Yjs, TanStack AI, or the Vercel AI SDK, follow https://durablestreams.com instead of inventing a wire format. + +## Step 4: Verify locally + +Create a stream with one record, append a second, and read both back over HTTP, then run the application's own read path: + +\`\`\`bash +curl -i -X PUT -H 'content-type: application/json' --data '[{"message":"hello"}]' ${getDurableStreamUrl(DURABLE_STREAMS_LOCAL_URL, "demo")} +curl -i -X POST -H 'content-type: application/json' --data '{"message":"world"}' ${getDurableStreamUrl(DURABLE_STREAMS_LOCAL_URL, "demo")} +curl '${getDurableStreamUrl(DURABLE_STREAMS_LOCAL_URL, "demo")}?offset=-1' +\`\`\` + +Report the commands run and the observed result. + +## Step 5: Deploy + +${deploySteps} + +${mcpSection}## If you get stuck + +Check ${DURABLE_STREAMS_DOCS_URL} and https://durablestreams.com. If that doesn't help, point the user at: +- Discord: https://rivet.dev/discord +- GitHub issues: https://github.com/rivet-dev/rivet-durable-streams + +Include in the report: symptoms, what was tried, the client version, and the stream URL in use.`; +} + // The hosted connection authorizes against the user's Rivet account through a // browser window, so the agent has to hand that step back. The local server is // plain stdio and the agent can run it itself. @@ -390,6 +530,7 @@ export function getAgentInstructionsPrompt({ cliDeploy, target = "actor", mcp, + durableStreamsServiceUrl, }: { providerStr: string; publishableToken: string; @@ -403,7 +544,19 @@ export function getAgentInstructionsPrompt({ cliDeploy?: boolean; target?: OnboardingTarget; mcp?: McpSetup; + // Managed Durable Streams service URL for this namespace on Rivet Cloud. + // Omitted when self-hosting, where the agent runs the worker itself. + durableStreamsServiceUrl?: string; }) { + if (target === "durable-streams") { + return getDurableStreamsPrompt({ + secretToken, + namespace, + durableStreamsServiceUrl, + mcpSection: mcp ? getMcpSection(mcp) : "", + }); + } + const poolLine = runnerName !== "default" ? `\n RIVET_POOL=${runnerName}` : ""; // Compute appends its own addendum with the same section; emitting it twice diff --git a/frontend/src/lib/features.ts b/frontend/src/lib/features.ts index 7c73361498..ffe87985ec 100644 --- a/frontend/src/lib/features.ts +++ b/frontend/src/lib/features.ts @@ -48,6 +48,10 @@ export const features = { // `agentOs` gates the agentOS (coding-agent VM) onboarding template. Beta. agentOs: isEnabled("agent-os"), byoc: isEnabled("byoc") && platform, + // `services` gates managed services (Durable Streams): the Services + // section of the product picker, its onboarding path, and the Services + // namespace settings tab. + services: isEnabled("services"), // `mcp` gates the MCP connection settings. The snippet differs per flavor: // platform points at the hosted endpoint, OSS at the local stdio server. mcp: isEnabled("mcp"),