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: 25 additions & 0 deletions kiloclaw/src/durable-objects/kiloclaw-instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -681,6 +681,31 @@ export class KiloClawInstance extends DurableObject<KiloClawEnv> {
};
}

/**
* Run `openclaw doctor --fix --non-interactive` on the machine and return the output.
* Requires the machine to be running.
*/
async runDoctor(): Promise<{ success: boolean; output: string }> {
await this.loadState();

const { flyMachineId } = this;
if (this.status !== 'running' || !flyMachineId) {
return { success: false, output: 'Instance is not running' };
}

const flyConfig = this.getFlyConfig();

const result = await fly.execCommand(
flyConfig,
flyMachineId,
['/usr/bin/env', 'HOME=/root', 'openclaw', 'doctor', '--fix', '--non-interactive'],
60
);

const output = result.stdout + (result.stderr ? '\n' + result.stderr : '');
return { success: result.exit_code === 0, output };
}

/**
* Start the Fly Machine.
*/
Expand Down
19 changes: 19 additions & 0 deletions kiloclaw/src/routes/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,25 @@ platform.post('/pairing/approve', async c => {
}
});

// POST /api/platform/doctor
platform.post('/doctor', async c => {
const result = await parseBody(c, UserIdRequestSchema);
if ('error' in result) return result.error;

try {
const doctor = await withDORetry(
instanceStubFactory(c.env, result.data.userId),
stub => stub.runDoctor(),
'runDoctor'
);
return c.json(doctor, 200);
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
console.error('[platform] doctor failed:', message);
return c.json({ error: message }, 500);
}
});

// POST /api/platform/start
platform.post('/start', async c => {
const result = await parseBody(c, UserIdRequestSchema);
Expand Down
41 changes: 36 additions & 5 deletions src/app/(app)/claw/components/ChangelogCard.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
'use client';

import { useState } from 'react';
import { format, parseISO } from 'date-fns';
import { Bug, History, Sparkles } from 'lucide-react';
import { Bug, ChevronDown, ChevronUp, History, Sparkles } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { CHANGELOG_ENTRIES, type ChangelogEntry } from './changelog-data';

const COLLAPSED_COUNT = 4;

const CATEGORY_STYLES = {
feature: 'border-emerald-500/30 bg-emerald-500/15 text-emerald-400',
bugfix: 'border-amber-500/30 bg-amber-500/15 text-amber-400',
Expand Down Expand Up @@ -55,8 +59,13 @@ function ChangelogRow({ entry }: { entry: ChangelogEntry }) {
}

export function ChangelogCard() {
const [expanded, setExpanded] = useState(false);

if (CHANGELOG_ENTRIES.length === 0) return null;

const hasMore = CHANGELOG_ENTRIES.length > COLLAPSED_COUNT;
const visibleEntries = expanded ? CHANGELOG_ENTRIES : CHANGELOG_ENTRIES.slice(0, COLLAPSED_COUNT);

return (
<Card>
<CardHeader>
Expand All @@ -66,10 +75,32 @@ export function ChangelogCard() {
</CardTitle>
<CardDescription>Recent changes and updates to the KiloClaw platform.</CardDescription>
</CardHeader>
<CardContent className="divide-y">
{CHANGELOG_ENTRIES.map((entry, i) => (
<ChangelogRow key={`${entry.date}-${i}`} entry={entry} />
))}
<CardContent>
<div className="divide-y">
{visibleEntries.map((entry, i) => (
<ChangelogRow key={`${entry.date}-${i}`} entry={entry} />
))}
</div>
{hasMore && (
<Button
variant="ghost"
size="sm"
className="text-muted-foreground mt-2 w-full"
onClick={() => setExpanded(!expanded)}
>
{expanded ? (
<>
<ChevronUp className="mr-1 h-4 w-4" />
Show less
</>
) : (
<>
<ChevronDown className="mr-1 h-4 w-4" />
See more ({CHANGELOG_ENTRIES.length - COLLAPSED_COUNT} older)
</>
)}
</Button>
)}
</CardContent>
</Card>
);
Expand Down
25 changes: 23 additions & 2 deletions src/app/(app)/claw/components/InstanceControls.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
'use client';

import { Play, RotateCw, Square } from 'lucide-react';
import { useState } from 'react';
import { Play, RotateCw, Square, Stethoscope } from 'lucide-react';
import { usePostHog } from 'posthog-js/react';
import { toast } from 'sonner';
import type { KiloClawDashboardStatus } from '@/lib/kiloclaw/types';
import { Button } from '@/components/ui/button';
import type { useKiloClawMutations } from '@/hooks/useKiloClaw';
import { RunDoctorDialog } from './RunDoctorDialog';

type ClawMutations = ReturnType<typeof useKiloClawMutations>;

Expand All @@ -20,6 +22,7 @@ export function InstanceControls({
const isRunning = status.status === 'running';
const isStopped = status.status === 'stopped' || status.status === 'provisioned';
const isDestroying = status.status === 'destroying';
const [doctorOpen, setDoctorOpen] = useState(false);

return (
<div>
Expand Down Expand Up @@ -71,9 +74,27 @@ export function InstanceControls({
}}
>
<RotateCw className="h-4 w-4" />
{mutations.restartGateway.isPending ? 'Restarting...' : 'Restart Gateway'}
{mutations.restartGateway.isPending ? 'Redeploying...' : 'Redeploy'}
</Button>
<Button
size="sm"
variant="outline"
className="border-cyan-500/30 text-cyan-400 hover:bg-cyan-500/10 hover:text-cyan-300"
disabled={!isRunning || mutations.runDoctor.isPending || isDestroying}
onClick={() => {
posthog?.capture('claw_doctor_clicked', { instance_status: status.status });
setDoctorOpen(true);
}}
>
<Stethoscope className="h-4 w-4" />
OpenClaw Doctor
</Button>
</div>
<RunDoctorDialog
open={doctorOpen}
onOpenChange={setDoctorOpen}
mutation={mutations.runDoctor}
/>
</div>
);
}
102 changes: 102 additions & 0 deletions src/app/(app)/claw/components/RunDoctorDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
'use client';

import { useEffect, useRef } from 'react';
import { CheckCircle2, Loader2, XCircle } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import type { useKiloClawMutations } from '@/hooks/useKiloClaw';

type DoctorMutation = ReturnType<typeof useKiloClawMutations>['runDoctor'];

export function RunDoctorDialog({
open,
onOpenChange,
mutation,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
mutation: DoctorMutation;
}) {
const hasFired = useRef(false);
const mutationRef = useRef(mutation);
mutationRef.current = mutation;

useEffect(() => {
if (open && !hasFired.current) {
hasFired.current = true;
mutationRef.current.mutate(undefined);
}
if (!open) {
hasFired.current = false;
mutationRef.current.reset();
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Stale result flashes on reopen

When the dialog is closed and reopened, mutation.data from the previous run persists. The result && !isPending check on line 72 will briefly render the old output before the new mutation sets isPending = true.

Call mutation.reset() when the dialog closes to clear stale state:

Suggested change
}
hasFired.current = false;
mutation.reset();

}, [open]);

const result = mutation.data;
const isPending = mutation.isPending;
const isError = mutation.isError;

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>OpenClaw Doctor</DialogTitle>
<DialogDescription>
Running diagnostics and applying fixes on your instance.
</DialogDescription>
</DialogHeader>

{isPending && (
<div className="flex flex-col items-center justify-center gap-3 py-12">
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
<p className="text-muted-foreground text-sm">Running diagnostics...</p>
</div>
)}

{isError && (
<div className="flex flex-col items-center justify-center gap-3 py-12">
<XCircle className="h-8 w-8 text-red-400" />
<p className="text-sm text-red-400">
{mutation.error?.message || 'Failed to run doctor'}
</p>
</div>
)}

{result && !isPending && (
<div className="space-y-3">
<div className="flex items-center gap-2">
{result.success ? (
<CheckCircle2 className="h-4 w-4 text-emerald-400" />
) : (
<XCircle className="h-4 w-4 text-red-400" />
)}
<span className="text-sm font-medium">
{result.success ? 'Executed successfully' : 'Issues detected'}
</span>
</div>
<Textarea
readOnly
value={result.output}
className="min-h-[300px] font-mono text-xs"
rows={20}
/>
</div>
)}

<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
7 changes: 7 additions & 0 deletions src/app/(app)/claw/components/changelog-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ export type ChangelogEntry = {

// Newest entries first. Developers add new entries to the top of this array.
export const CHANGELOG_ENTRIES: ChangelogEntry[] = [
{
date: '2026-02-20',
description:
'Added OpenClaw Doctor: run diagnostics and auto-fix from the dashboard. Renamed "Restart Gateway" to "Redeploy" to reflect actual behavior.',
category: 'feature',
deployHint: null,
},
{
date: '2026-02-19',
description: 'Added Discord and Slack channel configuration',
Expand Down
3 changes: 3 additions & 0 deletions src/hooks/useKiloClaw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,5 +83,8 @@ export function useKiloClawMutations() {
},
})
),
runDoctor: useMutation(
trpc.kiloclaw.runDoctor.mutationOptions({ onSuccess: invalidateStatus })
),
};
}
8 changes: 8 additions & 0 deletions src/lib/kiloclaw/kiloclaw-internal-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
ChannelsPatchResponse,
PairingListResponse,
PairingApproveResponse,
DoctorResponse,
} from './types';

/**
Expand Down Expand Up @@ -118,4 +119,11 @@ export class KiloClawInternalClient {
body: JSON.stringify({ userId, channel, code }),
});
}

async runDoctor(userId: string): Promise<DoctorResponse> {
return this.request('/api/platform/doctor', {
method: 'POST',
body: JSON.stringify({ userId }),
});
}
}
6 changes: 6 additions & 0 deletions src/lib/kiloclaw/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,12 @@ export type UserConfigResponse = {
};
};

/** Response from POST /api/platform/doctor */
export type DoctorResponse = {
success: boolean;
output: string;
};

/** Response from POST /api/admin/gateway/restart */
export type RestartGatewayResponse = {
success: boolean;
Expand Down
5 changes: 5 additions & 0 deletions src/routers/kiloclaw-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,4 +272,9 @@ export const kiloclawRouter = createTRPCRouter({
const client = new KiloClawInternalClient();
return client.approvePairingRequest(ctx.user.id, input.channel, input.code);
}),

runDoctor: kiloclawProcedure.mutation(async ({ ctx }) => {
const client = new KiloClawInternalClient();
return client.runDoctor(ctx.user.id);
}),
});