Skip to content
Merged
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
25 changes: 15 additions & 10 deletions kiloclaw/src/durable-objects/kiloclaw-instance/kilo-cli-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
KiloCliRunStatusResponseSchema,
GatewayCommandResponseSchema,
} from '../gateway-controller-types';
import { callGatewayController } from './gateway';
import { callGatewayController, isErrorUnknownRoute } from './gateway';
import type { InstanceMutableState } from './types';

type KiloCliRunStartResponse = {
Expand All @@ -29,19 +29,24 @@ export async function startKiloCliRun(
state: InstanceMutableState,
env: KiloClawEnv,
prompt: string
): Promise<KiloCliRunStartResponse> {
): Promise<KiloCliRunStartResponse | null> {
if (state.status !== 'running' || !state.flyMachineId) {
throw Object.assign(new Error('Instance is not running'), { status: 409 });
}

return callGatewayController(
state,
env,
'/_kilo/cli-run/start',
'POST',
KiloCliRunStartResponseSchema,
{ prompt }
);
try {
return await callGatewayController(
state,
env,
'/_kilo/cli-run/start',
'POST',
KiloCliRunStartResponseSchema,
{ prompt }
);
} catch (error) {
if (isErrorUnknownRoute(error)) return null;
throw error;
}
}

/**
Expand Down
11 changes: 9 additions & 2 deletions kiloclaw/src/routes/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1097,10 +1097,17 @@ platform.post('/kilo-cli-run/start', async c => {
stub => stub.startKiloCliRun(result.data.prompt),
'startKiloCliRun'
);
if (!response) {
return jsonError(
'Kilo CLI agent not available (controller too old)',
404,
'controller_route_unavailable'
);
}
return c.json(response, 200);
} catch (err) {
const { message, status } = sanitizeError(err, 'kilo-cli-run start');
return jsonError(message, status);
const { message, status, code } = sanitizeOpenclawConfigError(err, 'kilo-cli-run start');
return jsonError(message, status, code);
}
});

Expand Down
6 changes: 5 additions & 1 deletion src/app/(app)/claw/components/InstanceControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,11 @@ export function InstanceControls({
onOpenChange={setDoctorOpen}
mutation={mutations.runDoctor}
/>
<StartKiloCliRunDialog open={kiloRunOpen} onOpenChange={setKiloRunOpen} />
<StartKiloCliRunDialog
open={kiloRunOpen}
onOpenChange={setKiloRunOpen}
machineStatus={status.status}
/>
</div>
);
}
189 changes: 142 additions & 47 deletions src/app/(app)/claw/components/StartKiloCliRunDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
'use client';

import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Loader2, Terminal } from 'lucide-react';
import { AlertTriangle, Loader2, RotateCw, Terminal } from 'lucide-react';
import { toast } from 'sonner';
import {
Dialog,
DialogContent,
Expand All @@ -14,18 +15,47 @@ import {
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { useKiloClawMutations } from '@/hooks/useKiloClaw';
import type { PlatformStatusResponse } from '@/lib/kiloclaw/types';
import { AnimatedDots } from './AnimatedDots';

function isNeedsRedeployError(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'data' in error &&
typeof (error as { data?: unknown }).data === 'object' &&
(error as { data?: { upstreamCode?: unknown } }).data !== null &&
(error as { data: { upstreamCode?: unknown } }).data.upstreamCode ===
'controller_route_unavailable'
);
}

export function StartKiloCliRunDialog({
open,
onOpenChange,
machineStatus,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
machineStatus: PlatformStatusResponse['status'];
}) {
const router = useRouter();
const [prompt, setPrompt] = useState('');
const mutations = useKiloClawMutations();
const startMutation = mutations.startKiloCliRun;
const redeployMutation = mutations.restartMachine;

const needsRedeploy = startMutation.isError && isNeedsRedeployError(startMutation.error);
const machineReady = machineStatus === 'running';

// Clear stale "needs redeploy" error when the machine status changes away
// from running (e.g. restarting after a redeploy was dispatched). This
// ensures reopening the dialog shows the prompt form, not the old error.
useEffect(() => {
if (needsRedeploy && machineStatus !== 'running') {
startMutation.reset();
}
}, [needsRedeploy, machineStatus, startMutation]);

const handleStart = () => {
const trimmed = prompt.trim();
Expand All @@ -41,7 +71,20 @@ export function StartKiloCliRunDialog({
);
};

const handleRedeploy = () => {
redeployMutation.mutate(
{ imageTag: 'latest' },
{
onSuccess: () => {
startMutation.reset();
},
onError: err => toast.error(err.message, { duration: 10000 }),
}
);
};

const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen && redeployMutation.isPending) return;
if (!nextOpen) {
setPrompt('');
startMutation.reset();
Expand All @@ -58,55 +101,107 @@ export function StartKiloCliRunDialog({
Recover with Kilo CLI Agent
</DialogTitle>
<DialogDescription>
If your KiloClaw instance is stuck or failing, the Kilo CLI agent can help diagnose and
fix the problem. Describe the issue below and the agent will work autonomously to
resolve it.
{needsRedeploy
? 'Your instance needs to be redeployed before the recovery agent can run.'
: !machineReady
? 'Waiting for your instance to come back online before the recovery agent can run.'
: 'If your KiloClaw instance is stuck or failing, the Kilo CLI agent can help diagnose and fix the problem. Describe the issue below and the agent will work autonomously to resolve it.'}
</DialogDescription>
</DialogHeader>

<div className="space-y-2">
<Textarea
placeholder="Describe the problem you're trying to solve (e.g. &quot;I can't connect to the gateway&quot; or &quot;The bot's cron jobs aren't checking in&quot;)"
value={prompt}
onChange={e => setPrompt(e.target.value)}
className="min-h-30 resize-none"
maxLength={10_000}
autoFocus
onKeyDown={e => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleStart();
}
}}
/>
<p className="text-muted-foreground text-xs">
Press Cmd+Enter to start. The agent will attempt to fix the issue using{' '}
<code className="text-[11px]">kilo run --auto</code>.
</p>
</div>

<DialogFooter>
<Button variant="outline" onClick={() => handleOpenChange(false)}>
Cancel
</Button>
<Button
onClick={handleStart}
disabled={!prompt.trim() || startMutation.isPending}
className="bg-emerald-600 text-white hover:bg-emerald-700"
>
{startMutation.isPending ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Starting...
</>
) : (
<>
<Terminal className="h-4 w-4" />
Run Recovery
</>
{needsRedeploy ? (
<>
<div className="flex items-start gap-3 rounded-md border border-amber-500/30 bg-amber-500/10 p-3">
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-amber-400" />
<p className="text-sm text-amber-200">
Your KiloClaw instance is running an older version that doesn&apos;t support the
recovery agent. Upgrade to the latest version to use this feature.
</p>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={redeployMutation.isPending}
>
Cancel
</Button>
<Button
className="border-amber-500/30 bg-amber-500/10 text-amber-400 hover:bg-amber-500/20 hover:text-amber-300"
onClick={handleRedeploy}
disabled={redeployMutation.isPending}
>
{redeployMutation.isPending ? (
<>
Upgrading
<AnimatedDots />
</>
) : (
<>
<RotateCw className="h-4 w-4" />
Upgrade &amp; Redeploy
</>
)}
</Button>
</DialogFooter>
</>
) : (
<>
{!machineReady && (
<div className="flex items-start gap-3 rounded-md border border-blue-500/30 bg-blue-500/10 p-3">
<Loader2 className="mt-0.5 h-5 w-5 shrink-0 animate-spin text-blue-400" />
<p className="text-sm text-blue-200">
Your instance is restarting. The recovery agent will be available once it&apos;s
back online.
</p>
</div>
)}
</Button>
</DialogFooter>
<div className="space-y-2">
<Textarea
placeholder="Describe the problem you're trying to solve (e.g. &quot;I can't connect to the gateway&quot; or &quot;The bot's cron jobs aren't checking in&quot;)"
value={prompt}
onChange={e => setPrompt(e.target.value)}
className="min-h-30 resize-none"
maxLength={10_000}
autoFocus
disabled={!machineReady}
onKeyDown={e => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleStart();
}
}}
/>
<p className="text-muted-foreground text-xs">
Press Cmd+Enter to start. The agent will attempt to fix the issue using{' '}
<code className="text-[11px]">kilo run --auto</code>.
</p>
</div>

<DialogFooter>
<Button variant="outline" onClick={() => handleOpenChange(false)}>
Cancel
</Button>
<Button
onClick={handleStart}
disabled={!machineReady || !prompt.trim() || startMutation.isPending}
className="bg-emerald-600 text-white hover:bg-emerald-700"
>
{startMutation.isPending ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Starting...
</>
) : (
<>
<Terminal className="h-4 w-4" />
Run Recovery
</>
)}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);
Expand Down
26 changes: 21 additions & 5 deletions src/routers/kiloclaw-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1138,11 +1138,27 @@ export const kiloclawRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => {
const instance = await getActiveInstance(ctx.user.id);
const client = new KiloClawInternalClient();
const result = await client.startKiloCliRun(
ctx.user.id,
input.prompt,
workerInstanceId(instance)
);

let result;
try {
result = await client.startKiloCliRun(
ctx.user.id,
input.prompt,
workerInstanceId(instance)
);
} catch (err) {
if (err instanceof KiloClawApiError) {
const { code } = getKiloClawApiErrorPayload(err);
if (code === 'controller_route_unavailable') {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Instance needs redeploy to support recovery',
cause: new UpstreamApiError('controller_route_unavailable'),
});
}
}
throw err;
}

// Persist the run in the database and return its ID
const [row] = await db
Expand Down
Loading