- Add detectWorkspace() utility in packages/api/src/lib/workspace-detector.ts that walks up from process.cwd() looking for .gadget/workspace.json - Update gadget-drone to detect workspace at startup, change to workspace directory, and restore original startup directory on shutdown - Update gadget-tasks with same workspace detection pattern - Both tools now fail fast if started outside a managed workspace - Prevents creating invalid workspaces when started from subdirectories
229 lines
6.6 KiB
TypeScript
229 lines
6.6 KiB
TypeScript
// src/gadget-tasks.ts
|
|
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
|
// Licensed under the Apache License, Version 2.0
|
|
|
|
import env from "./config/env.ts";
|
|
|
|
import PlatformService from "./services/platform.ts";
|
|
import SchedulerService from "./services/scheduler.ts";
|
|
import TaskLockService from "./services/lock.ts";
|
|
|
|
import { GadgetLog, detectWorkspace, type IDroneRegistration, type IProject } from "@gadget/api";
|
|
import { GadgetProcess } from "./lib/process.ts";
|
|
|
|
const log = new GadgetLog({ name: "GadgetTasks", slug: "gadget-tasks" });
|
|
|
|
interface UserCredentials {
|
|
email: string;
|
|
password: string;
|
|
}
|
|
|
|
class GadgetTasks extends GadgetProcess {
|
|
private selectedDrone: IDroneRegistration | null = null;
|
|
private startupDir: string | undefined = undefined;
|
|
|
|
get name(): string {
|
|
return "GadgetTasks";
|
|
}
|
|
|
|
get slug(): string {
|
|
return "gadget-tasks";
|
|
}
|
|
|
|
constructor() {
|
|
super();
|
|
}
|
|
|
|
async start(): Promise<void> {
|
|
this.hookProcessSignals();
|
|
|
|
/*
|
|
* Detect workspace directory by walking up the directory tree.
|
|
* This allows starting from anywhere within a workspace's hierarchy.
|
|
*/
|
|
|
|
const detection = await detectWorkspace();
|
|
this.startupDir = detection.startupDir;
|
|
|
|
if (!detection.found) {
|
|
this.log.error("no workspace found", {
|
|
startupDir: detection.startupDir,
|
|
message: "Start gadget-tasks from within a managed workspace or its subdirectories. No .gadget/workspace.json found in any parent directory.",
|
|
});
|
|
throw new Error(
|
|
"No workspace found. Cannot start gadget-tasks in an unmanaged directory.",
|
|
);
|
|
}
|
|
|
|
// Change to the workspace directory for all operations
|
|
const workspaceDir = detection.workspaceDir!;
|
|
process.chdir(workspaceDir);
|
|
|
|
this.log.info("workspace detected", {
|
|
workspaceDir,
|
|
startupDir: detection.startupDir,
|
|
navigated: workspaceDir !== detection.startupDir,
|
|
});
|
|
|
|
// 1. Acquire singleton lock via Redis
|
|
const lockAcquired = await TaskLockService.acquire();
|
|
if (!lockAcquired) {
|
|
this.log.error("another instance of gadget-tasks is already running");
|
|
process.exit(1);
|
|
}
|
|
|
|
// 2. Get user credentials (CLI args or interactive prompt)
|
|
const credentials = await this.getUserCredentials();
|
|
|
|
// 3. Authenticate with platform
|
|
await PlatformService.start();
|
|
await PlatformService.authenticate(credentials.email, credentials.password);
|
|
this.log.info("authenticated with platform", { email: credentials.email });
|
|
|
|
// 4. Select a drone
|
|
this.selectedDrone = await this.selectDrone();
|
|
PlatformService.setSelectedDrone(this.selectedDrone);
|
|
this.log.info("drone selected", {
|
|
droneId: this.selectedDrone._id,
|
|
hostname: this.selectedDrone.hostname,
|
|
});
|
|
|
|
// 5. Connect Socket.IO
|
|
await PlatformService.connectSocket();
|
|
|
|
// 6. Start scheduler
|
|
await SchedulerService.start();
|
|
|
|
// 7. Fetch and schedule active tasks
|
|
await this.scheduleAllTasks();
|
|
|
|
this.log.info(`gadget-tasks v${env.pkg.version} started, ${SchedulerService.scheduledCount} tasks scheduled`);
|
|
}
|
|
|
|
async stop(): Promise<number> {
|
|
this.log.info(`gadget-tasks v${env.pkg.version} shutting down`);
|
|
|
|
await SchedulerService.stop();
|
|
await PlatformService.stop();
|
|
await TaskLockService.release();
|
|
|
|
/*
|
|
* Restore the original startup directory before exiting.
|
|
*/
|
|
if (this.startupDir) {
|
|
process.chdir(this.startupDir);
|
|
this.log.info("restored startup directory", { startupDir: this.startupDir });
|
|
}
|
|
|
|
this.log.info("shutdown complete");
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* Query all active projects and schedule their enabled tasks.
|
|
*/
|
|
async scheduleAllTasks(): Promise<void> {
|
|
const projects = await PlatformService.getProjects();
|
|
const activeProjects = projects.filter((p) => p.status === "active");
|
|
|
|
let taskCount = 0;
|
|
for (const project of activeProjects) {
|
|
for (const task of project.tasks) {
|
|
if (!task.enabled) continue;
|
|
SchedulerService.scheduleTask(task, project as unknown as IProject);
|
|
taskCount++;
|
|
}
|
|
}
|
|
|
|
this.log.info("tasks scheduled", {
|
|
projectCount: activeProjects.length,
|
|
taskCount,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get user credentials from CLI args or interactive prompt.
|
|
* Same pattern as gadget-drone startup.
|
|
*/
|
|
async getUserCredentials(): Promise<UserCredentials> {
|
|
const args = process.argv.slice(2);
|
|
const userArg = args.find((a) => a.startsWith("--user="));
|
|
const passArg = args.find((a) => a.startsWith("--password="));
|
|
|
|
if (userArg && passArg) {
|
|
return {
|
|
email: userArg.split("=")[1],
|
|
password: passArg.split("=")[1],
|
|
};
|
|
}
|
|
|
|
const { input: inqInput, password: inqPassword } = await import("@inquirer/prompts");
|
|
return {
|
|
email: await inqInput({ message: "📧 Enter Email: " }),
|
|
password: await inqPassword({ message: "🔑 Enter Password: " }),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Select a drone for task execution. If only one is available,
|
|
* auto-select it. Otherwise, present an interactive selection.
|
|
*/
|
|
async selectDrone(): Promise<IDroneRegistration> {
|
|
const drones = await PlatformService.getAvailableDrones();
|
|
if (drones.length === 0) {
|
|
throw new Error("No available drones. Start a gadget-drone instance first.");
|
|
}
|
|
if (drones.length === 1) {
|
|
this.log.info("auto-selecting only available drone", { droneId: drones[0]._id });
|
|
return drones[0];
|
|
}
|
|
|
|
console.log("\nAvailable Drones:");
|
|
drones.forEach((d, i) => {
|
|
console.log(` ${i + 1}. ${d.hostname} (${d.workspaceId}) - ${d.status}`);
|
|
});
|
|
|
|
const { select: inqSelect } = await import("@inquirer/prompts");
|
|
const selection = await inqSelect({
|
|
message: "Select a drone for task execution:",
|
|
choices: drones.map((d) => ({
|
|
name: `${d.hostname} (${d.workspaceId})`,
|
|
value: d,
|
|
})),
|
|
});
|
|
|
|
return selection;
|
|
}
|
|
|
|
hookProcessSignals(): void {
|
|
process.title = this.name;
|
|
|
|
process.on("unhandledRejection", async (error: Error) => {
|
|
this.log.error("Unhandled rejection", { error, stack: error.stack });
|
|
const exitCode = await this.stop();
|
|
process.exit(exitCode);
|
|
});
|
|
|
|
process.on("warning", (error) => {
|
|
if (error.name === "DeprecationWarning") return;
|
|
this.log.alert("warning", { error });
|
|
});
|
|
|
|
process.on("SIGINT", async () => {
|
|
this.log.info("SIGINT received");
|
|
const exitCode = await this.stop();
|
|
process.exit(exitCode);
|
|
});
|
|
}
|
|
}
|
|
|
|
(async () => {
|
|
try {
|
|
const tasks = new GadgetTasks();
|
|
await tasks.start();
|
|
} catch (error) {
|
|
console.error("failed to start gadget-tasks", error);
|
|
process.exit(-1);
|
|
}
|
|
})();
|