gadget/gadget-tasks/src/gadget-tasks.ts
Rob Colbert e7857016e3 feat(workspace): intelligent startup - detect workspace by walking up directory hierarchy
- 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
2026-05-17 17:09:16 -04:00

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);
}
})();