diff --git a/gadget-drone/src/gadget-drone.ts b/gadget-drone/src/gadget-drone.ts index 5c2712d..9f9e9f9 100644 --- a/gadget-drone/src/gadget-drone.ts +++ b/gadget-drone/src/gadget-drone.ts @@ -10,6 +10,8 @@ import path from "node:path"; import { io, ManagerOptions, SocketOptions, Socket } from "socket.io-client"; import { input as inqInput, password as inqPassword } from "@inquirer/prompts"; +import { detectWorkspace } from "@gadget/api"; + import AgentService, { IAgentWorkOrder } from "./services/agent.ts"; import AiService from "./services/ai.ts"; import PlatformService from "./services/platform.ts"; @@ -62,6 +64,7 @@ class GadgetDrone extends GadgetProcess { private socket: ClientSocket | undefined; private isShuttingDown: boolean = false; private heartbeatTimer: ReturnType | null = null; + private startupDir: string | undefined = undefined; get name(): string { return "GadgetDrone"; @@ -83,11 +86,38 @@ class GadgetDrone extends GadgetProcess { this.hookProcessSignals(); await this.startServices(); + /* + * Detect workspace directory by walking up the directory tree. + * This allows starting the drone 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 the drone 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 drone 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, + }); + /* * Initialize workspace directory structure and load/create workspace identity. */ - const workspaceDir = process.cwd(); await WorkspaceService.initialize(workspaceDir); this.log.info("workspace initialized", { workspaceId: WorkspaceService.workspaceId, @@ -170,6 +200,16 @@ class GadgetDrone extends GadgetProcess { await this.stopServices(); + /* + * Restore the original startup directory before exiting. + * This ensures the shell returns to the directory where the + * drone was originally started. + */ + if (this.startupDir) { + process.chdir(this.startupDir); + this.log.info("restored startup directory", { startupDir: this.startupDir }); + } + return 0; } diff --git a/gadget-tasks/src/gadget-tasks.ts b/gadget-tasks/src/gadget-tasks.ts index 4688dea..717cabf 100644 --- a/gadget-tasks/src/gadget-tasks.ts +++ b/gadget-tasks/src/gadget-tasks.ts @@ -8,7 +8,7 @@ import PlatformService from "./services/platform.ts"; import SchedulerService from "./services/scheduler.ts"; import TaskLockService from "./services/lock.ts"; -import { GadgetLog, type IDroneRegistration, type IProject } from "@gadget/api"; +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" }); @@ -20,6 +20,7 @@ interface UserCredentials { class GadgetTasks extends GadgetProcess { private selectedDrone: IDroneRegistration | null = null; + private startupDir: string | undefined = undefined; get name(): string { return "GadgetTasks"; @@ -36,6 +37,34 @@ class GadgetTasks extends GadgetProcess { async start(): Promise { 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) { @@ -78,6 +107,14 @@ class GadgetTasks extends GadgetProcess { 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; } diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 251017c..ae1edf2 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -10,6 +10,7 @@ export * from "./lib/log-transport-console.ts"; export * from "./lib/log-transport-file.ts"; export * from "./lib/log-transport-socket.ts"; export * from "./lib/log-file.ts"; +export * from "./lib/workspace-detector.ts"; /* * Data Model Interfaces diff --git a/packages/api/src/lib/workspace-detector.ts b/packages/api/src/lib/workspace-detector.ts new file mode 100644 index 0000000..a7a9d43 --- /dev/null +++ b/packages/api/src/lib/workspace-detector.ts @@ -0,0 +1,58 @@ +// src/lib/workspace-detector.ts +// Copyright (C) 2026 Rob Colbert +// Licensed under the Apache License, Version 2.0 + +import fs from "node:fs/promises"; +import path from "node:path"; + +export interface WorkspaceDetectionResult { + found: boolean; + workspaceDir?: string; + startupDir: string; +} + +/** + * Walks up the directory tree from startDir looking for .gadget/workspace.json. + * Returns the workspace directory if found, or { found: false } if not. + * + * @param startDir - The directory to start searching from (defaults to process.cwd()) + * @returns Promise resolving to workspace detection result + */ +export async function detectWorkspace( + startDir: string = process.cwd(), +): Promise { + const startupDir = path.resolve(startDir); + let currentDir = startupDir; + + // Walk up the directory tree until we hit root + while (true) { + const workspaceFile = path.join(currentDir, ".gadget", "workspace.json"); + + try { + await fs.access(workspaceFile); + // Found the workspace + return { + found: true, + workspaceDir: currentDir, + startupDir, + }; + } catch { + // workspace.json not found in this directory, go up one level + } + + const parentDir = path.dirname(currentDir); + + // Check if we've reached the root (parent equals current) + if (parentDir === currentDir) { + break; + } + + currentDir = parentDir; + } + + // No workspace.json found in any parent directory + return { + found: false, + startupDir, + }; +} \ No newline at end of file