diff --git a/kiloclaw/controller/src/routes/gateway.ts b/kiloclaw/controller/src/routes/gateway.ts index 7bb2f2e7d..c0e8954f8 100644 --- a/kiloclaw/controller/src/routes/gateway.ts +++ b/kiloclaw/controller/src/routes/gateway.ts @@ -1,7 +1,19 @@ +import os from 'node:os'; import type { Hono } from 'hono'; import { timingSafeTokenEqual } from '../auth'; import type { Supervisor } from '../supervisor'; +// shared-cpu-2x gives ~6% of 2 physical cores, so even modest load +// averages represent heavy pressure. After boot completes, an idle +// system sits near 0. A threshold of 0.1 ensures boot CPU work has +// fully subsided before we tell the frontend it's safe to proceed. +const LOAD_SETTLED_THRESHOLD = 0.1; + +function loadFields(): { loadAverage: number[]; settled: boolean } { + const loadAverage = os.loadavg(); + return { loadAverage, settled: loadAverage[0] < LOAD_SETTLED_THRESHOLD }; +} + export function getBearerToken(header: string | undefined): string | null { if (!header) return null; const [scheme, token] = header.split(/\s+/, 2); @@ -58,6 +70,30 @@ export function registerGatewayRoutes( } }); + app.get('/_kilo/gateway/ready', async c => { + if (supervisor.getState() !== 'running') { + return c.json({ ready: false, error: 'Gateway not running', ...loadFields() }, 503); + } + try { + const res = await fetch('http://127.0.0.1:3001/ready'); + const body = await res.text(); + let json: unknown; + try { + json = JSON.parse(body); + } catch { + json = { raw: body }; + } + const envelope = + typeof json === 'object' && json !== null + ? { ...json, ...loadFields() } + : { raw: json, ...loadFields() }; + return c.json(envelope, res.ok ? 200 : 503); + } catch (error) { + console.error('[controller] /_kilo/gateway/ready failed:', error); + return c.json({ ready: false, error: 'Failed to reach gateway', ...loadFields() }, 502); + } + }); + app.post('/_kilo/gateway/restart', async c => { try { const restarted = await supervisor.restart(); diff --git a/kiloclaw/src/durable-objects/gateway-controller-types.ts b/kiloclaw/src/durable-objects/gateway-controller-types.ts index 24a8f5526..ac4c6d927 100644 --- a/kiloclaw/src/durable-objects/gateway-controller-types.ts +++ b/kiloclaw/src/durable-objects/gateway-controller-types.ts @@ -45,6 +45,22 @@ export const ControllerVersionResponseSchema = z.object({ openclawCommit: z.string().nullable().optional(), }); +export type ControllerHealthResponse = { + status: 'ok'; + state: 'bootstrapping' | 'starting' | 'ready' | 'degraded'; + phase?: string; + error?: string; +}; + +export const ControllerHealthResponseSchema: ZodType = z.object({ + status: z.literal('ok'), + state: z.enum(['bootstrapping', 'starting', 'ready', 'degraded']), + phase: z.string().optional(), + error: z.string().optional(), +}); + +export const GatewayReadyResponseSchema = z.record(z.string(), z.unknown()); + export const EnvPatchResponseSchema = z.object({ ok: z.boolean(), signaled: z.boolean(), diff --git a/kiloclaw/src/durable-objects/kiloclaw-instance/gateway.ts b/kiloclaw/src/durable-objects/kiloclaw-instance/gateway.ts index 010890485..9a4208ae7 100644 --- a/kiloclaw/src/durable-objects/kiloclaw-instance/gateway.ts +++ b/kiloclaw/src/durable-objects/kiloclaw-instance/gateway.ts @@ -7,6 +7,7 @@ import { GatewayCommandResponseSchema, ConfigRestoreResponseSchema, ControllerVersionResponseSchema, + GatewayReadyResponseSchema, EnvPatchResponseSchema, OpenclawConfigResponseSchema, GatewayControllerError, @@ -239,6 +240,32 @@ export async function getControllerVersion( } } +export async function getGatewayReady( + state: InstanceMutableState, + env: KiloClawEnv +): Promise | null> { + try { + return await callGatewayController( + state, + env, + '/_kilo/gateway/ready', + 'GET', + GatewayReadyResponseSchema + ); + } catch (error) { + if (isErrorUnknownRoute(error)) { + return null; + } + // During startup the gateway process may not be running yet, producing + // a 503 from the controller. Return a descriptive object instead of + // throwing so the frontend poll doesn't see a wall of 500s. + if (error instanceof GatewayControllerError) { + return { ready: false, error: error.message, status: error.status }; + } + throw error; + } +} + /** Returns null if the controller is too old to have the /_kilo/config/read endpoint. */ export async function getOpenclawConfig( state: InstanceMutableState, diff --git a/kiloclaw/src/durable-objects/kiloclaw-instance/index.ts b/kiloclaw/src/durable-objects/kiloclaw-instance/index.ts index 43a257922..403531897 100644 --- a/kiloclaw/src/durable-objects/kiloclaw-instance/index.ts +++ b/kiloclaw/src/durable-objects/kiloclaw-instance/index.ts @@ -1399,6 +1399,11 @@ export class KiloClawInstance extends DurableObject { return gateway.getControllerVersion(this.s, this.env); } + async getGatewayReady(): Promise | null> { + await this.loadState(); + return gateway.getGatewayReady(this.s, this.env); + } + async patchConfigOnMachine(patch: Record): Promise { await this.loadState(); return gateway.patchConfigOnMachine(this.s, this.env, patch); diff --git a/kiloclaw/src/routes/platform.ts b/kiloclaw/src/routes/platform.ts index 9e2e18471..18e3b151e 100644 --- a/kiloclaw/src/routes/platform.ts +++ b/kiloclaw/src/routes/platform.ts @@ -621,6 +621,28 @@ platform.get('/gateway/status', async c => { } }); +// GET /api/platform/gateway/ready?userId=... +// Non-fatal polling endpoint — always returns 200 so the frontend poll +// doesn't generate a wall of errors during startup. +platform.get('/gateway/ready', async c => { + const userId = setValidatedQueryUserId(c); + if (!userId) { + return c.json({ error: 'userId query parameter is required' }, 400); + } + + try { + const result = await withDORetry( + instanceStubFactory(c.env, userId), + stub => stub.getGatewayReady(), + 'getGatewayReady' + ); + return c.json(result ?? { ready: false, error: 'controller too old' }, 200); + } catch (err) { + const { message } = sanitizeError(err, 'gateway ready'); + return c.json({ ready: false, error: message }, 200); + } +}); + // GET /api/platform/controller-version?userId=... platform.get('/controller-version', async c => { const userId = setValidatedQueryUserId(c); diff --git a/src/app/(app)/claw/components/ChannelPairingStep.tsx b/src/app/(app)/claw/components/ChannelPairingStep.tsx new file mode 100644 index 000000000..686ca13aa --- /dev/null +++ b/src/app/(app)/claw/components/ChannelPairingStep.tsx @@ -0,0 +1,245 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { CheckCircle2, Loader2, Send, XCircle } from 'lucide-react'; +import { toast } from 'sonner'; +import { useKiloClawPairing, useRefreshPairing } from '@/hooks/useKiloClaw'; +import { Button } from '@/components/ui/button'; +import type { ClawMutations } from './claw.types'; +import { OnboardingStepView } from './OnboardingStepView'; + +type PairingChannelId = 'telegram' | 'discord'; + +const CHANNEL_META: Record = { + telegram: { + label: 'Telegram', + instruction: + 'Open Telegram and send any message to your bot. The bot will reply with a pairing request \u2014 we\u2019ll pick it up automatically.', + }, + discord: { + label: 'Discord', + instruction: + 'Open Discord and send a DM to your bot. The bot will reply with a pairing request \u2014 we\u2019ll pick it up automatically.', + }, +}; + +export function ChannelPairingStep({ + channelId, + mutations, + onComplete, + onSkip, +}: { + channelId: PairingChannelId; + mutations: ClawMutations; + onComplete: () => void; + onSkip: () => void; +}) { + const { data: pairingData } = useKiloClawPairing(true); + + const refreshPairing = useRefreshPairing(); + const refreshRef = useRef(refreshPairing); + refreshRef.current = refreshPairing; + + useEffect(() => { + let cancelled = false; + async function poll() { + while (!cancelled) { + await refreshRef.current().catch(() => {}); + if (cancelled) break; + await new Promise(r => setTimeout(r, 1_000)); + } + } + void poll(); + return () => { + cancelled = true; + }; + }, []); + + const matchingRequest = pairingData?.requests?.find( + (r: { channel: string; code: string; id: string }) => r.channel === channelId + ); + + // Preserve the request being approved so the approval card stays visible + // while the mutation's onSuccess invalidates the pairing query. Without + // this, matchingRequest goes null from the refetch before onComplete fires, + // causing a brief flash of the "waiting" state. + const [pendingApproval, setPendingApproval] = useState<{ + code: string; + channel: string; + id: string; + } | null>(null); + + const displayedRequest = matchingRequest ?? pendingApproval; + const isApproving = mutations.approvePairingRequest.isPending || pendingApproval !== null; + + function handleApprove(channel: string, code: string) { + if (matchingRequest) setPendingApproval(matchingRequest); + mutations.approvePairingRequest.mutate( + { channel, code }, + { + onSuccess: result => { + if (result.success) { + toast.success('Pairing approved'); + onComplete(); + } else { + setPendingApproval(null); + toast.error(result.message || 'Approval failed'); + } + }, + onError: err => { + setPendingApproval(null); + toast.error(`Failed to approve: ${err.message}`); + }, + } + ); + } + + return ( + + ); +} + +type ChannelPairingStepViewProps = { + channelId: PairingChannelId; + matchingRequest: { code: string; channel: string; id: string } | null; + isApproving?: boolean; + onApprove?: (channel: string, code: string) => void; + onSkip?: () => void; +}; + +export function ChannelPairingStepView({ + channelId, + matchingRequest, + isApproving = false, + onApprove, + onSkip, +}: ChannelPairingStepViewProps) { + const meta = CHANNEL_META[channelId]; + + if (matchingRequest) { + return ( + +
+
+ + + Pairing request received + +
+ +
+ {meta.label} user ID + {matchingRequest.id} +
+ +
+ + Pairing code + + + {matchingRequest.code} + +
+
+ +

+ A user on {meta.label} is requesting access to your bot. Only approve if you initiated + this. +

+ + + + +
+ ); + } + + return ( + +
+
+ + + + + +
+ +
+

+ Waiting for you to message the bot... +

+

This page will update automatically.

+
+ +
+
+ ); +} diff --git a/src/app/(app)/claw/components/ClawDashboard.tsx b/src/app/(app)/claw/components/ClawDashboard.tsx index 011f93013..079d347e3 100644 --- a/src/app/(app)/claw/components/ClawDashboard.tsx +++ b/src/app/(app)/claw/components/ClawDashboard.tsx @@ -21,6 +21,7 @@ import { InstanceTab } from './InstanceTab'; import { OpenClawButton } from './OpenClawButton'; import { SettingsTab } from './SettingsTab'; import { ChangelogTab } from './ChangelogTab'; +import { ChannelPairingStep } from './ChannelPairingStep'; import { ChannelSelectionStepView } from './ChannelSelectionStep'; import { PermissionStep } from './PermissionStep'; import { ProvisioningStep } from './ProvisioningStep'; @@ -66,10 +67,12 @@ export function ClawDashboard({ const configServiceNudgeVisible = !instanceStatus || instanceYoung; const [onboardingStep, setOnboardingStep] = useState< - 'permissions' | 'channels' | 'provisioning' | 'done' + 'permissions' | 'channels' | 'provisioning' | 'pairing' | 'done' >('permissions'); const [selectedPreset, setSelectedPreset] = useState(null); const [channelTokens, setChannelTokens] = useState | null>(null); + const [selectedChannelId, setSelectedChannelId] = useState(null); + const hasPairingStep = selectedChannelId === 'telegram' || selectedChannelId === 'discord'; const [dirtySecrets, setDirtySecrets] = useState>(new Set()); @@ -188,11 +191,13 @@ export function ClawDashboard({ ) : isNewSetup && onboardingStep === 'channels' ? ( { + onSelect={(channelId, tokens) => { + setSelectedChannelId(channelId); setChannelTokens(tokens); setOnboardingStep('provisioning'); }} onSkip={() => { + setSelectedChannelId(null); setChannelTokens(null); setOnboardingStep('provisioning'); }} @@ -203,7 +208,17 @@ export function ClawDashboard({ channelTokens={channelTokens} instanceRunning={isRunning && gatewayStatus?.state === 'running'} mutations={mutations} + totalSteps={hasPairingStep ? 5 : 4} + onComplete={() => setOnboardingStep(hasPairingStep ? 'pairing' : 'done')} + /> + ) : isNewSetup && + onboardingStep === 'pairing' && + (selectedChannelId === 'telegram' || selectedChannelId === 'discord') ? ( + setOnboardingStep('done')} + onSkip={() => setOnboardingStep('done')} /> ) : isNewSetup ? ( diff --git a/src/app/(app)/claw/components/ProvisioningStep.tsx b/src/app/(app)/claw/components/ProvisioningStep.tsx index da3afaf39..47155bce5 100644 --- a/src/app/(app)/claw/components/ProvisioningStep.tsx +++ b/src/app/(app)/claw/components/ProvisioningStep.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from 'react'; import { Volume2 } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; +import { useGatewayReady } from '@/hooks/useKiloClaw'; import { type ExecPreset, type ClawMutations, @@ -35,15 +36,18 @@ export function ProvisioningStep({ channelTokens, instanceRunning, mutations, + totalSteps = 4, onComplete, }: { preset: ExecPreset; channelTokens: Record | null; instanceRunning: boolean; mutations: ClawMutations; + totalSteps?: number; onComplete: () => void; }) { const completedRef = useRef(false); + const [configReady, setConfigReady] = useState(false); // Keep stable references to callbacks so the effect only re-runs // when data values change, not when the parent re-renders or mutation @@ -90,18 +94,14 @@ export function ProvisioningStep({ } if (Object.keys(configPatch).length === 0) { - playChime(); - onCompleteRef.current(); + setConfigReady(true); return; } patchOpenclawConfigRef.current( { patch: configPatch }, { - onSuccess: () => { - playChime(); - onCompleteRef.current(); - }, + onSuccess: () => setConfigReady(true), onError: (err: { message: string }) => { completedRef.current = false; toast.error(err.message); @@ -110,7 +110,21 @@ export function ProvisioningStep({ ); }, [instanceRunning, preset]); - return ; + // Poll the gateway /ready endpoint to know when channels are fully set up + // and the system's CPU load has settled after boot. + const { data: gatewayReady } = useGatewayReady(instanceRunning); + const isGatewaySettled = gatewayReady?.ready === true && gatewayReady?.settled === true; + + // Advance to the next step when config is applied, gateway reports ready, + // and boot CPU pressure has subsided (settled === true). + useEffect(() => { + if (configReady && isGatewaySettled) { + playChime(); + onCompleteRef.current(); + } + }, [configReady, isGatewaySettled]); + + return ; } const PROVISIONING_PHRASES = [ @@ -151,7 +165,7 @@ const PROVISIONING_PHRASES = [ ]; /** Pure visual shell — extracted so Storybook can render it without wiring up mutations. */ -export function ProvisioningStepView() { +export function ProvisioningStepView({ totalSteps = 4 }: { totalSteps?: number }) { const [phraseIndex, setPhraseIndex] = useState(() => Math.floor(Math.random() * PROVISIONING_PHRASES.length) ); @@ -177,7 +191,7 @@ export function ProvisioningStepView() { return ( diff --git a/src/hooks/useKiloClaw.ts b/src/hooks/useKiloClaw.ts index 5bc76e3a0..e1d2d2ce5 100644 --- a/src/hooks/useKiloClaw.ts +++ b/src/hooks/useKiloClaw.ts @@ -34,7 +34,7 @@ export function useRefreshPairing() { // Fetch with refresh=true to bust KV cache, then write the result // into the normal (no-input) query so the component sees it immediately. const fresh = await queryClient.fetchQuery( - trpc.kiloclaw.listPairingRequests.queryOptions({ refresh: true }) + trpc.kiloclaw.listPairingRequests.queryOptions({ refresh: true }, { staleTime: 0 }) ); queryClient.setQueryData(trpc.kiloclaw.listPairingRequests.queryKey(), fresh); }; @@ -71,6 +71,16 @@ export function useKiloClawGatewayStatus(enabled: boolean) { ); } +export function useGatewayReady(enabled: boolean) { + const trpc = useTRPC(); + return useQuery( + trpc.kiloclaw.gatewayReady.queryOptions(undefined, { + enabled, + refetchInterval: enabled ? 5_000 : false, + }) + ); +} + export function useControllerVersion(enabled: boolean) { const trpc = useTRPC(); return useQuery( diff --git a/src/lib/kiloclaw/kiloclaw-internal-client.ts b/src/lib/kiloclaw/kiloclaw-internal-client.ts index ac78dedae..04345880d 100644 --- a/src/lib/kiloclaw/kiloclaw-internal-client.ts +++ b/src/lib/kiloclaw/kiloclaw-internal-client.ts @@ -21,6 +21,7 @@ import type { GatewayProcessStatusResponse, GatewayProcessActionResponse, ConfigRestoreResponse, + GatewayReadyResponse, ControllerVersionResponse, OpenclawConfigResponse, GoogleCredentialsInput, @@ -297,6 +298,14 @@ export class KiloClawInternalClient { ); } + async getGatewayReady(userId: string): Promise { + return this.request( + `/api/platform/gateway/ready?userId=${encodeURIComponent(userId)}`, + undefined, + { userId } + ); + } + async getControllerVersion(userId: string): Promise { return this.request( `/api/platform/controller-version?userId=${encodeURIComponent(userId)}`, diff --git a/src/lib/kiloclaw/types.ts b/src/lib/kiloclaw/types.ts index 99b44612f..f176fe219 100644 --- a/src/lib/kiloclaw/types.ts +++ b/src/lib/kiloclaw/types.ts @@ -219,6 +219,9 @@ export type ConfigRestoreResponse = { signaled: boolean; }; +/** Response from GET /api/platform/gateway/ready (opaque — shape depends on OpenClaw version) */ +export type GatewayReadyResponse = Record; + /** Response from GET /api/platform/controller-version. Null fields = old controller. */ export type ControllerVersionResponse = { version: string | null; diff --git a/src/routers/kiloclaw-router.ts b/src/routers/kiloclaw-router.ts index d08516075..5157bca10 100644 --- a/src/routers/kiloclaw-router.ts +++ b/src/routers/kiloclaw-router.ts @@ -659,6 +659,25 @@ export const kiloclawRouter = createTRPCRouter({ } }), + gatewayReady: baseProcedure.query(async ({ ctx }) => { + try { + const client = new KiloClawInternalClient(); + return await client.getGatewayReady(ctx.user.id); + } catch (err) { + console.error('[gatewayReady] error for user:', ctx.user.id, err); + if (err instanceof KiloClawApiError && (err.statusCode === 404 || err.statusCode === 409)) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Gateway ready check unavailable', + }); + } + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to fetch gateway ready state', + }); + } + }), + controllerVersion: baseProcedure.query(async ({ ctx }) => { const client = new KiloClawInternalClient(); return client.getControllerVersion(ctx.user.id); diff --git a/storybook/stories/claw/ChannelPairingStep.stories.tsx b/storybook/stories/claw/ChannelPairingStep.stories.tsx new file mode 100644 index 000000000..3bff9eebd --- /dev/null +++ b/storybook/stories/claw/ChannelPairingStep.stories.tsx @@ -0,0 +1,56 @@ +import type { Meta, StoryObj } from '@storybook/nextjs'; +import { ChannelPairingStepView } from '@/app/(app)/claw/components/ChannelPairingStep'; + +const meta: Meta = { + title: 'Claw/ChannelPairingStep', + component: ChannelPairingStepView, + parameters: { + layout: 'fullscreen', + }, + decorators: [ + Story => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const TelegramWaiting: Story = { + args: { + channelId: 'telegram', + matchingRequest: null, + }, +}; + +export const DiscordWaiting: Story = { + args: { + channelId: 'discord', + matchingRequest: null, + }, +}; + +export const TelegramWithRequest: Story = { + args: { + channelId: 'telegram', + matchingRequest: { code: '3YPKLDPP', channel: 'telegram', id: '829104561' }, + }, +}; + +export const DiscordWithRequest: Story = { + args: { + channelId: 'discord', + matchingRequest: { code: 'K7WMRX2Q', channel: 'discord', id: '491028374165' }, + }, +}; + +export const Approving: Story = { + args: { + channelId: 'telegram', + matchingRequest: { code: '3YPKLDPP', channel: 'telegram', id: '829104561' }, + isApproving: true, + }, +};