- Drones
+
{loading ? (
Loading...
@@ -172,9 +182,7 @@ function DashboardSidebar({ onNavigate, selectedDrone, onSelectDrone }: Dashboar
No drones available.
) : (
- {drones
- .filter((d) => d.status === 'available' || d.status === 'busy')
- .map((drone) => (
+ {drones.map((drone) => (
onSelectDrone(drone)}
diff --git a/gadget-code/frontend/src/pages/ProjectManager.tsx b/gadget-code/frontend/src/pages/ProjectManager.tsx
index b29fbc2..ab2e25a 100644
--- a/gadget-code/frontend/src/pages/ProjectManager.tsx
+++ b/gadget-code/frontend/src/pages/ProjectManager.tsx
@@ -191,9 +191,8 @@ function RightSidebar({ project, onOpenChatSession }: RightSidebarProps) {
const loadData = async () => {
setLoading(true);
try {
- // Load available drones only (filter out offline)
- const allDrones = await droneApi.getAll();
- const availableDrones = allDrones.filter(d => d.status !== 'offline');
+ // Load available drones (backend filters offline)
+ const availableDrones = await droneApi.getAll();
setDrones(availableDrones);
// Load chat sessions for this project
diff --git a/gadget-code/src/controllers/api/v1/drone.ts b/gadget-code/src/controllers/api/v1/drone.ts
index c44c2da..c264e51 100644
--- a/gadget-code/src/controllers/api/v1/drone.ts
+++ b/gadget-code/src/controllers/api/v1/drone.ts
@@ -3,7 +3,9 @@
// All Rights Reserved
import { Request, Response } from "express";
+import { Types } from "mongoose";
+import { DroneStatus } from "@gadget/api";
import DroneService from "../../../services/drone.ts";
import UserService from "../../../services/user.ts";
@@ -48,6 +50,18 @@ export class DroneApiControllerV1 extends DtpController {
this.getRegistrations.bind(this),
);
+ this.router.get(
+ "/registration/manager",
+ requireUser,
+ this.getManagerRegistrations.bind(this),
+ );
+
+ this.router.post(
+ "/registration/:registrationId/terminate",
+ requireUser,
+ this.postTerminate.bind(this),
+ );
+
this.router.delete(
"/registration/:registrationId",
this.deleteRegistration.bind(this),
@@ -92,7 +106,10 @@ export class DroneApiControllerV1 extends DtpController {
async getRegistrations(req: Request, res: Response): Promise {
try {
- const registrations = await DroneService.getForUser(req.user);
+ const registrations = await DroneService.getForUser(req.user, [
+ DroneStatus.Starting,
+ DroneStatus.Offline,
+ ]);
res.status(200).json({
success: true,
data: registrations,
@@ -108,6 +125,46 @@ export class DroneApiControllerV1 extends DtpController {
}
}
+ async getManagerRegistrations(req: Request, res: Response): Promise {
+ try {
+ const registrations = await DroneService.getForManager(req.user);
+ res.status(200).json({
+ success: true,
+ data: registrations,
+ });
+ } catch (error) {
+ this.log.error("failed to present drone manager registrations for User", {
+ error,
+ });
+ res.status((error as Error).statusCode || 500).json({
+ success: false,
+ message: (error as Error).message,
+ });
+ }
+ }
+
+ async postTerminate(req: Request, res: Response): Promise {
+ try {
+ const registrationId = Types.ObjectId.createFromHexString(
+ req.params.registrationId as string,
+ );
+ const result = await DroneService.requestTermination(registrationId);
+ res.status(200).json({
+ success: result.success,
+ message: result.message,
+ });
+ } catch (error) {
+ this.log.error("failed to terminate drone", {
+ registrationId: req.params.registrationId,
+ error,
+ });
+ res.status((error as Error).statusCode || 500).json({
+ success: false,
+ message: (error as Error).message,
+ });
+ }
+ }
+
async deleteRegistration(_req: Request, res: Response): Promise {
try {
await DroneService.unregister(res.locals.registration);
diff --git a/gadget-code/src/services/drone.ts b/gadget-code/src/services/drone.ts
index 5f12fb6..3b3f72c 100644
--- a/gadget-code/src/services/drone.ts
+++ b/gadget-code/src/services/drone.ts
@@ -8,6 +8,7 @@ import { IUser, DroneStatus, IDroneRegistration } from "@gadget/api";
import DroneRegistration from "../models/drone-registration.ts";
import { DtpService } from "../lib/service.ts";
+import SocketService from "./socket.ts";
export interface IDroneDefinition {
hostname: string;
@@ -105,15 +106,136 @@ class DroneService extends DtpService {
/**
* Request a User's list of registered drones.
* @param user The user for which a list of registered drones is being requested
+ * @param statusFilter Optional array of statuses to exclude from the results
* @returns The list of drone registrations for the user.
*/
- async getForUser(user: IUser): Promise {
- const registrations = await DroneRegistration.find({ user: user._id })
+ async getForUser(
+ user: IUser,
+ statusFilter?: DroneStatus[],
+ ): Promise {
+ const query: Record = { user: user._id };
+
+ if (statusFilter && statusFilter.length > 0) {
+ query.status = { $nin: statusFilter };
+ }
+
+ const registrations = await DroneRegistration.find(query)
.sort({ hostname: 1, workspaceDir: 1 })
.populate(this.populateDroneRegistration);
return registrations;
}
+ /**
+ * Request a User's complete list of drone registrations for the Drone Manager.
+ * Returns all drones (online and offline) without filtering.
+ * @param user The user for which a list of drone registrations is being requested
+ * @returns The complete list of drone registrations for the user.
+ */
+ async getForManager(user: IUser): Promise {
+ const registrations = await DroneRegistration.find({ user: user._id })
+ .sort({ status: 1, hostname: 1, workspaceDir: 1 })
+ .populate(this.populateDroneRegistration);
+ return registrations;
+ }
+
+ /**
+ * Request a drone to terminate itself. Sets a 10-second timeout to wait for
+ * the drone to mark itself as offline. If the timeout expires, forces the
+ * status to offline.
+ * @param registrationId The _id of the drone registration to terminate
+ * @returns Promise that resolves when termination is complete (success or timeout)
+ */
+ async requestTermination(
+ registrationId: Types.ObjectId,
+ ): Promise<{ success: boolean; message?: string }> {
+ const registration = await this.getById(registrationId);
+
+ if (registration.status === DroneStatus.Offline) {
+ return { success: false, message: "Drone is already offline" };
+ }
+
+ let droneSession: any;
+ try {
+ droneSession = SocketService.getDroneSession(registration);
+ } catch (error) {
+ // Drone is not connected - mark as offline immediately
+ this.log.warn("drone not connected, marking offline", {
+ registrationId: registrationId.toHexString(),
+ hostname: registration.hostname,
+ });
+ await this.setStatus(registration, DroneStatus.Offline);
+ return {
+ success: true,
+ message: "Drone was not connected. Marked offline.",
+ };
+ }
+
+ return new Promise((resolve) => {
+ let resolved = false;
+ let timeoutHandle: NodeJS.Timeout;
+
+ const cleanup = () => {
+ clearTimeout(timeoutHandle);
+ resolved = true;
+ };
+
+ timeoutHandle = setTimeout(async () => {
+ if (resolved) return;
+ cleanup();
+
+ this.log.warn("drone termination timed out, forcing offline", {
+ registrationId: registrationId.toHexString(),
+ hostname: registration.hostname,
+ });
+
+ await this.setStatus(registration, DroneStatus.Offline);
+ resolve({
+ success: true,
+ message: "Drone termination timed out. Marked offline.",
+ });
+ }, 10000);
+
+ droneSession.socket.emit(
+ "requestTermination",
+ async (success: boolean) => {
+ if (resolved) return;
+
+ if (!success) {
+ cleanup();
+ resolve({
+ success: false,
+ message: "Drone rejected termination request",
+ });
+ return;
+ }
+
+ this.log.info("drone accepted termination request", {
+ registrationId: registrationId.toHexString(),
+ hostname: registration.hostname,
+ });
+
+ const checkInterval = setInterval(async () => {
+ try {
+ const updated = await this.getById(registrationId);
+ if (updated.status === DroneStatus.Offline) {
+ clearInterval(checkInterval);
+ cleanup();
+ resolve({ success: true });
+ }
+ } catch (error) {
+ clearInterval(checkInterval);
+ cleanup();
+ resolve({
+ success: true,
+ message: "Drone termination timed out. Marked offline.",
+ });
+ }
+ }, 500);
+ },
+ );
+ });
+ }
+
/**
* The drone calls this to update it's status in the database.
* @param registration The registration for which a status is being updated.
diff --git a/gadget-drone/.gadget/workspace.json b/gadget-drone/.gadget/workspace.json
new file mode 100644
index 0000000..b792271
--- /dev/null
+++ b/gadget-drone/.gadget/workspace.json
@@ -0,0 +1,14 @@
+{
+ "workspaceId": "9737cffd-ae5b-418f-b181-e30553bec83d",
+ "createdAt": "2026-04-30T12:37:32.429Z",
+ "hostname": "mysterymachine",
+ "workspaceDir": "/home/rob/projects/gadget/gadget-drone",
+ "chatSession": null,
+ "lockedProject": null,
+ "projects": [],
+ "registration": {
+ "_id": "69f3533069d00859987b4e56",
+ "status": "available",
+ "registeredAt": "2026-04-30T13:03:45.023Z"
+ }
+}
\ No newline at end of file
diff --git a/gadget-drone/src/gadget-drone.ts b/gadget-drone/src/gadget-drone.ts
index 16a66b6..8e94100 100644
--- a/gadget-drone/src/gadget-drone.ts
+++ b/gadget-drone/src/gadget-drone.ts
@@ -122,13 +122,22 @@ class GadgetDrone extends GadgetProcess {
async stop(): Promise {
this.log.info(`Gadget Drone v${env.pkg.version} shutting down`);
+ this.isShuttingDown = true;
if (this.socket) {
this.socket.disconnect();
delete this.socket;
}
- await PlatformService.unregister();
+ try {
+ await PlatformService.unregister();
+ } catch (error) {
+ this.log.error("failed to unregister drone from Gadget Code platform", {
+ error,
+ });
+ // fall through
+ }
+
await this.stopServices();
return 0;
@@ -191,6 +200,10 @@ class GadgetDrone extends GadgetProcess {
this.onRequestWorkspaceMode.bind(this),
);
this.socket.on("processWorkOrder", this.onProcessWorkOrder.bind(this));
+ this.socket.on(
+ "requestTermination",
+ this.onRequestTermination.bind(this),
+ );
});
}
@@ -357,6 +370,16 @@ class GadgetDrone extends GadgetProcess {
chatSessionId: cache.chatSessionId,
});
}
+
+ async onRequestTermination(cb: (success: boolean) => void): Promise {
+ this.log.info("requestTermination received from platform", {
+ registrationId: this.registration?._id,
+ });
+
+ cb(true);
+
+ process.kill(process.pid, "SIGINT");
+ }
}
(async () => {
diff --git a/packages/api/src/messages/drone.ts b/packages/api/src/messages/drone.ts
index a442002..57cbaf4 100644
--- a/packages/api/src/messages/drone.ts
+++ b/packages/api/src/messages/drone.ts
@@ -47,3 +47,7 @@ export type CrashRecoveryResponseMessage = (data: {
action: "discard" | "retry";
retryDelay?: number;
}) => void;
+
+export type RequestTerminationMessage = (
+ cb: (success: boolean) => void,
+) => void;
diff --git a/packages/api/src/messages/socket.ts b/packages/api/src/messages/socket.ts
index 9973451..c7ac222 100644
--- a/packages/api/src/messages/socket.ts
+++ b/packages/api/src/messages/socket.ts
@@ -10,6 +10,7 @@ import {
WorkOrderCompleteMessage,
RequestCrashRecoveryMessage,
CrashRecoveryResponseMessage,
+ RequestTerminationMessage,
} from "./drone.ts";
import {
RequestSessionLockMessage,
@@ -53,6 +54,7 @@ export interface ClientToServerEvents {
toolCall: ToolCallMessage;
workOrderComplete: WorkOrderCompleteMessage;
requestCrashRecovery: RequestCrashRecoveryMessage;
+ requestTermination: RequestTerminationMessage;
}
export interface ServerToClientEvents {
@@ -64,6 +66,7 @@ export interface ServerToClientEvents {
requestWorkspaceMode: RequestWorkspaceModeMessage;
processWorkOrder: ProcessWorkOrderMessage;
crashRecoveryResponse: CrashRecoveryResponseMessage;
+ requestTermination: RequestTerminationMessage;
/*
* gadget-code:web => gadget-code:ide