Skip to content

Commit d168221

Browse files
authored
feat(admin): add machine start/stop controls to kiloclaw instance detail (#1066)
## Summary Adds machine-level Start/Stop buttons to the KiloClaw admin instance detail page (`/admin/kiloclaw/[id]`). These call the kiloclaw worker's existing `POST /api/platform/start` and `POST /api/platform/stop` endpoints via two new admin tRPC mutations (`machineStart`, `machineStop`). The buttons are always clickable regardless of the DO's reported machine state. This is intentional — the primary use case is admin recovery when the Durable Object state and the actual Fly machine state have drifted out of sync, so we need to be able to force a start or stop even if the DO thinks the machine is already in that state. The existing Gateway Process card controls the openclaw process *inside* the machine; this new Machine Controls card operates on the Fly machine itself. ## Verification - [x] `pnpm typecheck` — passed (only pre-existing errors in `email-mailgun.ts` unrelated to this change) - [ ] Manual verification of UI and machine start/stop behavior ## Visual Changes | Before | After | | ------ | ----- | | No machine controls on instance detail page | New "Machine Controls" card with Start/Stop buttons and status badge, between Live Worker Status and Gateway Process cards | ## Reviewer Notes - The two new tRPC mutations follow the exact same pattern as the existing `gatewayStart`/`gatewayStop` mutations (same input schema, error handling, logging). - Buttons are only disabled during in-flight requests — no state-based gating — per the requirement to always allow forcing start/stop for recovery scenarios. - The card only renders when the instance is active and has a `flyMachineId` (same guard as the gateway controls card).
2 parents 830107f + b87dc38 commit d168221

2 files changed

Lines changed: 98 additions & 0 deletions

File tree

src/app/admin/components/KiloclawInstances/KiloclawInstanceDetail.tsx

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,36 @@ export function KiloclawInstanceDetail({ instanceId }: { instanceId: string }) {
394394
void queryClient.invalidateQueries({ queryKey: trpc.admin.kiloclawInstances.get.queryKey() });
395395
};
396396

397+
const machineControlsEnabled = data?.destroyed_at === null && !!data?.workerStatus?.flyMachineId;
398+
399+
const invalidateMachineQueries = () => {
400+
void queryClient.invalidateQueries({ queryKey: trpc.admin.kiloclawInstances.get.queryKey() });
401+
};
402+
403+
const { mutateAsync: machineStart, isPending: isMachineStarting } = useMutation(
404+
trpc.admin.kiloclawInstances.machineStart.mutationOptions({
405+
onSuccess: () => {
406+
toast.success('Machine start requested');
407+
invalidateMachineQueries();
408+
},
409+
onError: err => {
410+
toast.error(`Failed to start machine: ${err.message}`);
411+
},
412+
})
413+
);
414+
415+
const { mutateAsync: machineStop, isPending: isMachineStopping } = useMutation(
416+
trpc.admin.kiloclawInstances.machineStop.mutationOptions({
417+
onSuccess: () => {
418+
toast.success('Machine stop requested');
419+
invalidateMachineQueries();
420+
},
421+
onError: err => {
422+
toast.error(`Failed to stop machine: ${err.message}`);
423+
},
424+
})
425+
);
426+
397427
const { mutateAsync: gatewayStart, isPending: isGatewayStarting } = useMutation(
398428
trpc.admin.kiloclawInstances.gatewayStart.mutationOptions({
399429
onSuccess: () => {
@@ -494,6 +524,7 @@ export function KiloclawInstanceDetail({ instanceId }: { instanceId: string }) {
494524
}
495525

496526
const isActive = data.destroyed_at === null;
527+
const machineActionPending = isMachineStarting || isMachineStopping;
497528
const gatewayActionPending =
498529
isGatewayStarting ||
499530
isGatewayStopping ||
@@ -790,6 +821,51 @@ export function KiloclawInstanceDetail({ instanceId }: { instanceId: string }) {
790821
</CardContent>
791822
</Card>
792823

824+
{/* Machine Controls */}
825+
{isActive && machineControlsEnabled && (
826+
<Card>
827+
<CardHeader>
828+
<div className="flex items-center justify-between gap-3">
829+
<div>
830+
<CardTitle>Machine Controls</CardTitle>
831+
<CardDescription>Start or stop the Fly machine</CardDescription>
832+
</div>
833+
<StatusBadge status={data.workerStatus?.status ?? null} />
834+
</div>
835+
</CardHeader>
836+
<CardContent>
837+
<div className="flex flex-wrap gap-2">
838+
<Button
839+
size="sm"
840+
variant="outline"
841+
disabled={machineActionPending}
842+
onClick={() => void machineStart({ userId: data.user_id })}
843+
>
844+
{isMachineStarting ? (
845+
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
846+
) : (
847+
<Play className="mr-1 h-4 w-4" />
848+
)}
849+
Start Machine
850+
</Button>
851+
<Button
852+
size="sm"
853+
variant="outline"
854+
disabled={machineActionPending}
855+
onClick={() => void machineStop({ userId: data.user_id })}
856+
>
857+
{isMachineStopping ? (
858+
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
859+
) : (
860+
<Square className="mr-1 h-4 w-4" />
861+
)}
862+
Stop Machine
863+
</Button>
864+
</div>
865+
</CardContent>
866+
</Card>
867+
)}
868+
793869
{/* Gateway Process (controller) */}
794870
{isActive && (
795871
<Card>

src/routers/admin-kiloclaw-instances-router.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,28 @@ export const adminKiloclawInstancesRouter = createTRPCRouter({
448448
}
449449
}),
450450

451+
machineStart: adminProcedure.input(GatewayProcessSchema).mutation(async ({ input }) => {
452+
const fallbackMessage = 'Failed to start machine';
453+
try {
454+
const client = new KiloClawInternalClient();
455+
return await client.start(input.userId);
456+
} catch (err) {
457+
console.error('Failed to start machine for user:', input.userId, err);
458+
throwKiloclawAdminError(err, fallbackMessage);
459+
}
460+
}),
461+
462+
machineStop: adminProcedure.input(GatewayProcessSchema).mutation(async ({ input }) => {
463+
const fallbackMessage = 'Failed to stop machine';
464+
try {
465+
const client = new KiloClawInternalClient();
466+
return await client.stop(input.userId);
467+
} catch (err) {
468+
console.error('Failed to stop machine for user:', input.userId, err);
469+
throwKiloclawAdminError(err, fallbackMessage);
470+
}
471+
}),
472+
451473
destroy: adminProcedure.input(DestroyInstanceSchema).mutation(async ({ input, ctx }) => {
452474
const [instance] = await db
453475
.select({

0 commit comments

Comments
 (0)