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
This commit is contained in:
Rob Colbert 2026-05-17 17:09:16 -04:00
parent c90e8ce0e8
commit e7857016e3
4 changed files with 138 additions and 2 deletions

View File

@ -10,6 +10,8 @@ import path from "node:path";
import { io, ManagerOptions, SocketOptions, Socket } from "socket.io-client"; import { io, ManagerOptions, SocketOptions, Socket } from "socket.io-client";
import { input as inqInput, password as inqPassword } from "@inquirer/prompts"; import { input as inqInput, password as inqPassword } from "@inquirer/prompts";
import { detectWorkspace } from "@gadget/api";
import AgentService, { IAgentWorkOrder } from "./services/agent.ts"; import AgentService, { IAgentWorkOrder } from "./services/agent.ts";
import AiService from "./services/ai.ts"; import AiService from "./services/ai.ts";
import PlatformService from "./services/platform.ts"; import PlatformService from "./services/platform.ts";
@ -62,6 +64,7 @@ class GadgetDrone extends GadgetProcess {
private socket: ClientSocket | undefined; private socket: ClientSocket | undefined;
private isShuttingDown: boolean = false; private isShuttingDown: boolean = false;
private heartbeatTimer: ReturnType<typeof setTimeout> | null = null; private heartbeatTimer: ReturnType<typeof setTimeout> | null = null;
private startupDir: string | undefined = undefined;
get name(): string { get name(): string {
return "GadgetDrone"; return "GadgetDrone";
@ -83,11 +86,38 @@ class GadgetDrone extends GadgetProcess {
this.hookProcessSignals(); this.hookProcessSignals();
await this.startServices(); 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. * Initialize workspace directory structure and load/create workspace identity.
*/ */
const workspaceDir = process.cwd();
await WorkspaceService.initialize(workspaceDir); await WorkspaceService.initialize(workspaceDir);
this.log.info("workspace initialized", { this.log.info("workspace initialized", {
workspaceId: WorkspaceService.workspaceId, workspaceId: WorkspaceService.workspaceId,
@ -170,6 +200,16 @@ class GadgetDrone extends GadgetProcess {
await this.stopServices(); 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; return 0;
} }

View File

@ -8,7 +8,7 @@ import PlatformService from "./services/platform.ts";
import SchedulerService from "./services/scheduler.ts"; import SchedulerService from "./services/scheduler.ts";
import TaskLockService from "./services/lock.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"; import { GadgetProcess } from "./lib/process.ts";
const log = new GadgetLog({ name: "GadgetTasks", slug: "gadget-tasks" }); const log = new GadgetLog({ name: "GadgetTasks", slug: "gadget-tasks" });
@ -20,6 +20,7 @@ interface UserCredentials {
class GadgetTasks extends GadgetProcess { class GadgetTasks extends GadgetProcess {
private selectedDrone: IDroneRegistration | null = null; private selectedDrone: IDroneRegistration | null = null;
private startupDir: string | undefined = undefined;
get name(): string { get name(): string {
return "GadgetTasks"; return "GadgetTasks";
@ -36,6 +37,34 @@ class GadgetTasks extends GadgetProcess {
async start(): Promise<void> { async start(): Promise<void> {
this.hookProcessSignals(); 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 // 1. Acquire singleton lock via Redis
const lockAcquired = await TaskLockService.acquire(); const lockAcquired = await TaskLockService.acquire();
if (!lockAcquired) { if (!lockAcquired) {
@ -78,6 +107,14 @@ class GadgetTasks extends GadgetProcess {
await PlatformService.stop(); await PlatformService.stop();
await TaskLockService.release(); 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"); this.log.info("shutdown complete");
return 0; return 0;
} }

View File

@ -10,6 +10,7 @@ export * from "./lib/log-transport-console.ts";
export * from "./lib/log-transport-file.ts"; export * from "./lib/log-transport-file.ts";
export * from "./lib/log-transport-socket.ts"; export * from "./lib/log-transport-socket.ts";
export * from "./lib/log-file.ts"; export * from "./lib/log-file.ts";
export * from "./lib/workspace-detector.ts";
/* /*
* Data Model Interfaces * Data Model Interfaces

View File

@ -0,0 +1,58 @@
// src/lib/workspace-detector.ts
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// 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<WorkspaceDetectionResult> {
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,
};
}