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:
parent
c90e8ce0e8
commit
e7857016e3
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
58
packages/api/src/lib/workspace-detector.ts
Normal file
58
packages/api/src/lib/workspace-detector.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user