workspace mode management; drone status message socket events added

This commit is contained in:
Rob Colbert 2026-05-03 03:05:06 -04:00
parent d92d61024a
commit c5e5d16a51
11 changed files with 465 additions and 115 deletions

View File

@ -8,5 +8,8 @@
"build": "vite build" "build": "vite build"
}, },
"author": "Robert Colbert <rob.colbert@openplatform.us>", "author": "Robert Colbert <rob.colbert@openplatform.us>",
"license": "Apache-2.0" "license": "Apache-2.0",
"dependencies": {
"slug": "^11.0.1"
}
} }

View File

@ -1,5 +1,6 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import slug from "slug";
import type { User, Project } from "../lib/api"; import type { User, Project } from "../lib/api";
import { import {
projectApi, projectApi,
@ -114,13 +115,125 @@ function NewProjectForm({
); );
} }
interface EditProjectFormProps {
project: Project;
onCancel: () => void;
onSuccess: () => void;
}
function EditProjectForm({
project,
onCancel,
onSuccess,
}: EditProjectFormProps) {
const [name, setName] = useState(project.name);
const [slugValue, setSlugValue] = useState(project.slug);
const [gitUrl, setGitUrl] = useState(project.gitUrl || "");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name || !slugValue) {
setError("Name and slug are required");
return;
}
setSubmitting(true);
setError("");
try {
await projectApi.update(project._id, {
name,
slug: slug(slugValue),
gitUrl: gitUrl || undefined,
});
onSuccess();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to update project");
} finally {
setSubmitting(false);
}
};
return (
<div className="max-w-lg">
<h2 className="text-xl font-semibold mb-6">Edit Project</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm text-text-secondary mb-1">
Project Name *
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded text-text-primary focus:border-brand focus:outline-none"
placeholder="My Project"
/>
<p className="text-xs text-text-muted mt-1">
Used for display purposes only
</p>
</div>
<div>
<label className="block text-sm text-text-secondary mb-1">
Project Slug *
</label>
<input
type="text"
value={slugValue}
onChange={(e) => setSlugValue(e.target.value)}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded text-text-primary focus:border-brand focus:outline-none"
placeholder="my-project"
/>
<p className="text-xs text-text-muted mt-1">
Unique identifier for the project directory. Changing this will
affect the workspace directory name on drones.
</p>
</div>
<div>
<label className="block text-sm text-text-secondary mb-1">
Git Repository URL
</label>
<input
type="text"
value={gitUrl}
onChange={(e) => setGitUrl(e.target.value)}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded text-text-primary focus:border-brand focus:outline-none"
placeholder="https://github.com/user/repo.git"
/>
</div>
{error && <p className="text-red-500 text-sm">{error}</p>}
<div className="flex gap-3 pt-2">
<button
type="submit"
disabled={submitting}
className="px-4 py-2 bg-brand text-white rounded hover:bg-red-700 transition-colors disabled:opacity-50"
>
{submitting ? "Updating..." : "Save Changes"}
</button>
<button
type="button"
onClick={onCancel}
className="px-4 py-2 border border-border-default text-text-secondary rounded hover:bg-bg-tertiary transition-colors"
>
Cancel
</button>
</div>
</form>
</div>
);
}
interface ProjectInspectorProps { interface ProjectInspectorProps {
project: Project; project: Project;
onDelete: () => void; onDelete: () => void;
onUpdate: () => void;
} }
function ProjectInspector({ project, onDelete }: ProjectInspectorProps) { function ProjectInspector({ project, onDelete, onUpdate }: ProjectInspectorProps) {
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [editing, setEditing] = useState(false);
const handleDelete = async () => { const handleDelete = async () => {
if ( if (
@ -145,6 +258,17 @@ function ProjectInspector({ project, onDelete }: ProjectInspectorProps) {
<div className="flex-1 overflow-y-auto p-6"> <div className="flex-1 overflow-y-auto p-6">
<div className="max-w-3xl"> <div className="max-w-3xl">
<h2 className="text-xl font-semibold mb-6">Project Inspector</h2> <h2 className="text-xl font-semibold mb-6">Project Inspector</h2>
{editing ? (
<EditProjectForm
project={project}
onCancel={() => setEditing(false)}
onSuccess={() => {
setEditing(false);
onUpdate();
}}
/>
) : (
<>
<div className="space-y-6"> <div className="space-y-6">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="p-4 bg-bg-secondary border border-border-default rounded"> <div className="p-4 bg-bg-secondary border border-border-default rounded">
@ -192,7 +316,13 @@ function ProjectInspector({ project, onDelete }: ProjectInspectorProps) {
</div> </div>
</div> </div>
<div className="pt-6 border-t border-border-subtle"> <div className="pt-6 border-t border-border-subtle flex gap-3">
<button
onClick={() => setEditing(true)}
className="px-4 py-2 bg-brand text-white rounded hover:bg-red-700 transition-colors"
>
Edit Project
</button>
<button <button
onClick={handleDelete} onClick={handleDelete}
disabled={deleting} disabled={deleting}
@ -202,6 +332,8 @@ function ProjectInspector({ project, onDelete }: ProjectInspectorProps) {
</button> </button>
</div> </div>
</div> </div>
</>
)}
</div> </div>
</div> </div>
); );
@ -685,6 +817,10 @@ export default function ProjectManager({ user }: ProjectManagerProps) {
navigate("/projects"); navigate("/projects");
}; };
const handleProjectUpdated = () => {
loadProjects();
};
const handleSelectDrone = (drone: DroneRegistration) => { const handleSelectDrone = (drone: DroneRegistration) => {
setSelectedDrone(drone); setSelectedDrone(drone);
}; };
@ -795,6 +931,7 @@ export default function ProjectManager({ user }: ProjectManagerProps) {
<ProjectInspector <ProjectInspector
project={selectedProject} project={selectedProject}
onDelete={handleProjectDeleted} onDelete={handleProjectDeleted}
onUpdate={handleProjectUpdated}
/> />
{/* Right Sidebar - Drones & Chat Sessions */} {/* Right Sidebar - Drones & Chat Sessions */}

View File

@ -229,6 +229,12 @@ importers:
specifier: ^4.1.5 specifier: ^4.1.5
version: 4.1.5(@types/node@24.0.4)(jsdom@29.0.2)(vite@8.0.10(@types/node@24.0.4)(esbuild@0.25.5)(jiti@2.6.1)(less@4.3.0)(tsx@4.21.0)) version: 4.1.5(@types/node@24.0.4)(jsdom@29.0.2)(vite@8.0.10(@types/node@24.0.4)(esbuild@0.25.5)(jiti@2.6.1)(less@4.3.0)(tsx@4.21.0))
frontend:
dependencies:
slug:
specifier: ^11.0.1
version: 11.0.1
packages: packages:
'@adobe/css-tools@4.4.4': '@adobe/css-tools@4.4.4':
@ -2714,6 +2720,10 @@ packages:
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
engines: {node: '>=8'} engines: {node: '>=8'}
slug@11.0.1:
resolution: {integrity: sha512-VrM060OM/E7rdLQSnp6JHrzFfJFmqQBp0+TMhZStnEB8PfNliaZ9UWYjTHGHLUFVJorZ8TjVd/aKvIxHWU2O7g==}
hasBin: true
socket.io-adapter@2.5.5: socket.io-adapter@2.5.5:
resolution: {integrity: sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==} resolution: {integrity: sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==}
@ -5604,6 +5614,8 @@ snapshots:
slash@3.0.0: {} slash@3.0.0: {}
slug@11.0.1: {}
socket.io-adapter@2.5.5: socket.io-adapter@2.5.5:
dependencies: dependencies:
debug: 4.3.7 debug: 4.3.7

View File

@ -37,7 +37,10 @@ export class CodeSession extends SocketSession {
super.register(); super.register();
this.socket.on("requestSessionLock", this.onRequestSessionLock.bind(this)); this.socket.on("requestSessionLock", this.onRequestSessionLock.bind(this));
this.socket.on("requestWorkspaceMode", this.onRequestWorkspaceMode.bind(this)); this.socket.on(
"requestWorkspaceMode",
this.onRequestWorkspaceMode.bind(this),
);
this.socket.on("submitPrompt", this.onSubmitPrompt.bind(this)); this.socket.on("submitPrompt", this.onSubmitPrompt.bind(this));
} }
@ -84,7 +87,6 @@ export class CodeSession extends SocketSession {
this.project = project; this.project = project;
SocketService.registerChatSession(chatSession._id, this); SocketService.registerChatSession(chatSession._id, this);
droneSession.setChatSessionId(chatSession._id); droneSession.setChatSessionId(chatSession._id);
droneSession.setCodeSession(this);
} }
cb(success, chatSessionId); cb(success, chatSessionId);
}, },
@ -192,4 +194,8 @@ export class CodeSession extends SocketSession {
this.workspaceMode = mode; this.workspaceMode = mode;
this.socket.emit("workspaceModeChanged", mode); this.socket.emit("workspaceModeChanged", mode);
} }
onStatus(content: string): void {
this.socket.emit("status", content);
}
} }

View File

@ -23,7 +23,6 @@ export class DroneSession extends SocketSession {
chatSessionId: GadgetId | undefined; chatSessionId: GadgetId | undefined;
currentTurnId: GadgetId | undefined; currentTurnId: GadgetId | undefined;
workspaceMode: WorkspaceMode = WorkspaceMode.Idle; workspaceMode: WorkspaceMode = WorkspaceMode.Idle;
codeSession: import("./code-session.js").CodeSession | undefined;
constructor(socket: GadgetSocket, registration: IDroneRegistration) { constructor(socket: GadgetSocket, registration: IDroneRegistration) {
super(socket, registration.user as IUser); super(socket, registration.user as IUser);
@ -33,16 +32,41 @@ export class DroneSession extends SocketSession {
register() { register() {
super.register(); super.register();
this.socket.on("status", this.onStatus.bind(this));
this.socket.on(
"workspaceModeChanged",
this.onWorkspaceModeChanged.bind(this),
);
this.socket.on("thinking", this.onThinking.bind(this)); this.socket.on("thinking", this.onThinking.bind(this));
this.socket.on("response", this.onResponse.bind(this)); this.socket.on("response", this.onResponse.bind(this));
this.socket.on("toolCall", this.onToolCall.bind(this)); this.socket.on("toolCall", this.onToolCall.bind(this));
this.socket.on("workOrderComplete", this.onWorkOrderComplete.bind(this)); this.socket.on("workOrderComplete", this.onWorkOrderComplete.bind(this));
this.socket.on( this.socket.on(
"requestCrashRecovery", "requestCrashRecovery",
this.onRequestCrashRecovery.bind(this), this.onRequestCrashRecovery.bind(this),
); );
this.socket.on("requestTermination", this.onRequestTermination.bind(this)); this.socket.on("requestTermination", this.onRequestTermination.bind(this));
this.socket.on("workspaceModeChanged", this.onWorkspaceModeChanged.bind(this)); }
async onStatus(message: string): Promise<void> {
if (!this.chatSessionId) {
this.log.warn(
"drone status event received but no chat session is active",
);
return;
}
try {
const codeSession = SocketService.getCodeSessionByChatSessionId(
this.chatSessionId,
);
codeSession.socket.emit("status", message);
} catch (error) {
this.log.error("failed to route status message", { error });
}
} }
/** /**
@ -183,23 +207,22 @@ export class DroneSession extends SocketSession {
this.currentTurnId = turnId; this.currentTurnId = turnId;
} }
/**
* Sets the associated code session for routing events back to the IDE.
*/
setCodeSession(codeSession: import("./code-session.js").CodeSession): void {
this.codeSession = codeSession;
}
/** /**
* Called when the drone emits a workspace mode change. * Called when the drone emits a workspace mode change.
*/ */
async onWorkspaceModeChanged(mode: WorkspaceMode): Promise<void> { async onWorkspaceModeChanged(mode: WorkspaceMode): Promise<void> {
if (!this.chatSessionId) {
return;
}
this.workspaceMode = mode; this.workspaceMode = mode;
this.log.info("workspace mode changed", { mode }); this.log.info("workspace mode changed", { mode });
if (this.codeSession) { const codeSession = SocketService.getCodeSessionByChatSessionId(
this.codeSession.onWorkspaceModeChanged(mode); this.chatSessionId,
} );
codeSession.onWorkspaceModeChanged(mode);
} }
/** /**

View File

@ -32,6 +32,7 @@
"numeral": "^2.0.6", "numeral": "^2.0.6",
"ollama": "^0.6.3", "ollama": "^0.6.3",
"openai": "^6.34.0", "openai": "^6.34.0",
"simple-git": "^3.36.0",
"socket.io-client": "^4.8.3" "socket.io-client": "^4.8.3"
}, },
"devDependencies": { "devDependencies": {

View File

@ -226,6 +226,8 @@ class GadgetDrone extends GadgetProcess {
chatSession: IChatSession, chatSession: IChatSession,
cb: RequestSessionLockCallback, cb: RequestSessionLockCallback,
) { ) {
assert(this.socket, "invalid application state");
/* /*
* Validate gadget-drone registration to ensure correct sync with IDE * Validate gadget-drone registration to ensure correct sync with IDE
*/ */
@ -286,8 +288,45 @@ class GadgetDrone extends GadgetProcess {
session: chatSession, session: chatSession,
}; };
this.workspaceMode = WorkspaceMode.Idle; this.workspaceMode = WorkspaceMode.Idle;
this.socket.emit("status", "session lock granted");
/*
* Add the project to the workspace, lock to it, and deploy it.
*/
WorkspaceService.addProject({
_id: project._id,
slug: project.slug,
gitUrl: project.gitUrl,
});
WorkspaceService.updateLockedProject({
_id: project._id,
slug: project.slug,
gitUrl: project.gitUrl,
});
const projectDir = WorkspaceService.getProjectDirectory(project.slug);
let haveProjectDir = await WorkspaceService.exists(projectDir);
if (!haveProjectDir) {
this.socket.emit("status", `deploying project [slug=${project.slug}]`);
await WorkspaceService.deployProject(project);
/*
* Make sure a project directory got deployed
*/
haveProjectDir = await WorkspaceService.exists(projectDir);
if (!haveProjectDir) {
return cb(false, "failed to deploy project directory");
}
}
/*
* Commit the changes to the workspace.
*/
await WorkspaceService.writeWorkspaceData();
cb(true, chatSession._id); cb(true, chatSession._id);
this.socket.emit("status", "session lock granted");
} }
async onRequestWorkspaceMode( async onRequestWorkspaceMode(
@ -434,17 +473,29 @@ class GadgetDrone extends GadgetProcess {
cb(true, "work order accepted"); // confirm that drone has the work order cb(true, "work order accepted"); // confirm that drone has the work order
const workspaceDir =
WorkspaceService.workspaceData?.workspaceDir || process.cwd();
const projectDir = WorkspaceService.getProjectDirectory(project.slug);
process.chdir(projectDir);
this.isProcessingWorkOrder = true; this.isProcessingWorkOrder = true;
try { try {
this.socket.emit("status", "processing work order");
await AgentService.process(order, this.socket); await AgentService.process(order, this.socket);
this.socket.emit("status", "work order processing finished");
await WorkspaceService.removeWorkOrderCache(); await WorkspaceService.removeWorkOrderCache();
} catch (error) { } catch (error) {
const err = error as Error; const err = error as Error;
this.log.error("work order processing failed", { this.log.error("work order processing failed", {
error: err.message, error: err.message,
}); });
this.socket.emit(
"status",
`failed to process work order: ${(error as Error).message}`,
);
// Leave cache in place for recovery // Leave cache in place for recovery
} finally { } finally {
process.chdir(workspaceDir);
this.isProcessingWorkOrder = false; this.isProcessingWorkOrder = false;
this.log.info("work order processing complete", { this.log.info("work order processing complete", {
isProcessingWorkOrder: this.isProcessingWorkOrder, isProcessingWorkOrder: this.isProcessingWorkOrder,

View File

@ -2,11 +2,43 @@
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us> // Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0 // Licensed under the Apache License, Version 2.0
import assert from "node:assert";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import os from "node:os"; import os from "node:os";
import { simpleGit, SimpleGit, SimpleGitOptions } from "simple-git";
import { IChatTurn, IProject } from "@gadget/api";
import { GadgetService } from "../lib/service.ts"; import { GadgetService } from "../lib/service.ts";
import { IChatTurn } from "@gadget/api";
export interface WorkspaceChatSession {
_id: string; // MongoDB ChatSession._id
name: string; // Session name for display
lockedAt: string; // ISO 8601 timestamp
}
export interface WorkspaceLockedProject {
_id: string; // MongoDB Project._id
slug: string; // Project slug (directory name)
gitUrl?: string; // Remote git URL
lockedAt: string; // ISO 8601 timestamp
}
export interface WorkspaceProject {
_id: string;
slug: string;
gitUrl?: string;
clonedAt: string | null;
lastSyncAt: string | null;
}
export interface WorkspaceRegistration {
_id: string; // MongoDB DroneRegistration._id
status: string; // Current drone status
registeredAt: string; // ISO 8601 timestamp
}
export interface WorkspaceData { export interface WorkspaceData {
workspaceId: string; // UUID v4, immutable once created workspaceId: string; // UUID v4, immutable once created
@ -15,35 +47,16 @@ export interface WorkspaceData {
workspaceDir: string; // Absolute path to workspace directory workspaceDir: string; // Absolute path to workspace directory
// Active session state (null when idle) // Active session state (null when idle)
chatSession: { chatSession: WorkspaceChatSession | null;
_id: string; // MongoDB ChatSession._id
name: string; // Session name for display
lockedAt: string; // ISO 8601 timestamp
} | null;
// Project currently being worked on (null when idle) // Project currently being worked on (null when idle)
lockedProject: { lockedProject: WorkspaceLockedProject | null;
_id: string; // MongoDB Project._id
slug: string; // Project slug (directory name)
gitUrl: string; // Remote git URL
lockedAt: string; // ISO 8601 timestamp
} | null;
// All projects cloned into this workspace // All projects cloned into this workspace
projects: Array<{ projects: Array<WorkspaceProject>;
_id: string;
slug: string;
gitUrl: string;
clonedAt: string;
lastSyncAt: string;
}>;
// Drone registration (updated each startup) // Drone registration (updated each startup)
registration: { registration: WorkspaceRegistration | null;
_id: string; // MongoDB DroneRegistration._id
status: string; // Current drone status
registeredAt: string; // ISO 8601 timestamp
} | null;
} }
export interface WorkOrderCache { export interface WorkOrderCache {
@ -57,9 +70,12 @@ export interface WorkOrderCache {
class WorkspaceService extends GadgetService { class WorkspaceService extends GadgetService {
private gadgetDir: string = ""; private gadgetDir: string = "";
private cacheDir: string = ""; private cacheDir: string = "";
private workspaceFile: string = ""; private workspaceFile: string = "";
private _workspaceData: WorkspaceData | null = null; private _workspaceData: WorkspaceData | null = null;
private repos = new Map<string, SimpleGit>();
get name(): string { get name(): string {
return "WorkspaceService"; return "WorkspaceService";
} }
@ -111,7 +127,7 @@ class WorkspaceService extends GadgetService {
*/ */
private async loadOrCreateWorkspaceData(workspaceDir: string): Promise<void> { private async loadOrCreateWorkspaceData(workspaceDir: string): Promise<void> {
try { try {
if (await this.fileExists(this.workspaceFile)) { if (await this.exists(this.workspaceFile)) {
// Load existing workspace // Load existing workspace
const content = await fs.promises.readFile(this.workspaceFile, "utf-8"); const content = await fs.promises.readFile(this.workspaceFile, "utf-8");
this._workspaceData = JSON.parse(content) as WorkspaceData; this._workspaceData = JSON.parse(content) as WorkspaceData;
@ -175,34 +191,59 @@ class WorkspaceService extends GadgetService {
* Updates the locked project in workspace data. * Updates the locked project in workspace data.
*/ */
updateLockedProject( updateLockedProject(
project: { _id: string; slug: string; gitUrl: string } | null, project: {
_id: string; // MongoDB Project._id
slug: string; // Project slug (directory name)
gitUrl?: string; // Remote git URL
} | null,
): void { ): void {
if (!this._workspaceData) return; if (!this._workspaceData) return;
if (!project) {
this._workspaceData.lockedProject = project this._workspaceData.lockedProject = null;
? { return;
...project,
lockedAt: new Date().toISOString(),
} }
: null; this._workspaceData.lockedProject = Object.assign(
{
lockedAt: new Date().toISOString(),
},
project,
);
} }
/** /**
* Adds a project to the workspace projects list. * Adds a project to the workspace projects list.
*/ */
addProject(project: { _id: string; slug: string; gitUrl: string }): void { addProject(project: { _id: string; slug: string; gitUrl?: string }): void {
if (!this._workspaceData) return; if (!this._workspaceData) return;
const existing = this._workspaceData.projects.find( const existing = this._workspaceData.projects.find(
(p) => p.slug === project.slug, (p) => p.slug === project.slug,
); );
if (!existing) { if (existing) {
existing._id = project._id;
if (project.gitUrl) {
existing.gitUrl = project.gitUrl;
} else {
delete existing.gitUrl;
}
return;
}
this._workspaceData.projects.push({ this._workspaceData.projects.push({
...project, ...project,
clonedAt: new Date().toISOString(), clonedAt: null,
lastSyncAt: new Date().toISOString(), lastSyncAt: null,
}); });
} }
async deployProject(project: IProject): Promise<void> {
assert(this.workspaceData, "workspace uninitialized");
if (project.gitUrl) {
const git: SimpleGit = this.getGitRepo(project.slug);
git.clone(project.gitUrl);
return;
}
} }
/** /**
@ -258,7 +299,7 @@ class WorkspaceService extends GadgetService {
async removeWorkOrderCache(): Promise<void> { async removeWorkOrderCache(): Promise<void> {
const cacheFile = path.join(this.cacheDir, "work-order.json"); const cacheFile = path.join(this.cacheDir, "work-order.json");
try { try {
if (await this.fileExists(cacheFile)) { if (await this.exists(cacheFile)) {
await fs.promises.unlink(cacheFile); await fs.promises.unlink(cacheFile);
this.log.info("work order cache removed"); this.log.info("work order cache removed");
} }
@ -276,7 +317,7 @@ class WorkspaceService extends GadgetService {
async readWorkOrderCache(): Promise<WorkOrderCache | null> { async readWorkOrderCache(): Promise<WorkOrderCache | null> {
const cacheFile = path.join(this.cacheDir, "work-order.json"); const cacheFile = path.join(this.cacheDir, "work-order.json");
try { try {
if (await this.fileExists(cacheFile)) { if (await this.exists(cacheFile)) {
const content = await fs.promises.readFile(cacheFile, "utf-8"); const content = await fs.promises.readFile(cacheFile, "utf-8");
return JSON.parse(content) as WorkOrderCache; return JSON.parse(content) as WorkOrderCache;
} }
@ -290,9 +331,9 @@ class WorkspaceService extends GadgetService {
} }
/** /**
* Checks if a file exists. * Checks if a file or directory exists.
*/ */
private async fileExists(filePath: string): Promise<boolean> { async exists(filePath: string): Promise<boolean> {
try { try {
await fs.promises.access(filePath); await fs.promises.access(filePath);
return true; return true;
@ -300,6 +341,35 @@ class WorkspaceService extends GadgetService {
return false; return false;
} }
} }
getProjectDirectory(slug: string): string {
assert(this.workspaceData, "workspace uninitialized");
return path.join(this.workspaceData?.workspaceDir, slug);
}
getGitRepo(slug: string): SimpleGit {
assert(this.workspaceData, "workspace uninitialized");
/*
* If we have a SimpleGit instance for the project slug, return it.
*/
let git: SimpleGit | undefined = this.repos.get(slug);
if (git) {
return git;
}
/*
* Create a new SimpleGit instance rooted in the project's workspace
* directory, add it to the repo cache, and return it.
*/
const options: Partial<SimpleGitOptions> = {
baseDir: this.getProjectDirectory(slug),
};
git = simpleGit(options);
this.repos.set(slug, git);
return git;
}
} }
export default new WorkspaceService(); export default new WorkspaceService();

View File

@ -18,6 +18,8 @@ export type ProcessWorkOrderMessage = (
cb: ProcessWorkOrderCallback, cb: ProcessWorkOrderCallback,
) => void; ) => void;
export type StatusMessage = (content: string) => void;
export type ThinkingMessage = (content: string) => void; export type ThinkingMessage = (content: string) => void;
export type ResponseMessage = (content: string) => void; export type ResponseMessage = (content: string) => void;

View File

@ -3,6 +3,7 @@
// Licensed under the Apache License, Version 2.0 // Licensed under the Apache License, Version 2.0
import { import {
StatusMessage,
ProcessWorkOrderMessage, ProcessWorkOrderMessage,
ResponseMessage, ResponseMessage,
ThinkingMessage, ThinkingMessage,
@ -50,6 +51,7 @@ export interface ClientToServerEvents {
* gadget-drone => gadget-code:web * gadget-drone => gadget-code:web
*/ */
status: StatusMessage;
thinking: ThinkingMessage; thinking: ThinkingMessage;
response: ResponseMessage; response: ResponseMessage;
toolCall: ToolCallMessage; toolCall: ToolCallMessage;
@ -74,6 +76,7 @@ export interface ServerToClientEvents {
* gadget-code:web => gadget-code:ide * gadget-code:web => gadget-code:ide
*/ */
status: StatusMessage;
thinking: ThinkingMessage; thinking: ThinkingMessage;
response: ResponseMessage; response: ResponseMessage;
toolCall: ToolCallMessage; toolCall: ToolCallMessage;

View File

@ -287,6 +287,9 @@ importers:
openai: openai:
specifier: ^6.34.0 specifier: ^6.34.0
version: 6.34.0(ws@8.18.3) version: 6.34.0(ws@8.18.3)
simple-git:
specifier: ^3.36.0
version: 3.36.0
socket.io-client: socket.io-client:
specifier: ^4.8.3 specifier: ^4.8.3
version: 4.8.3 version: 4.8.3
@ -943,6 +946,12 @@ packages:
'@kurkle/color@0.3.4': '@kurkle/color@0.3.4':
resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==}
'@kwsites/file-exists@1.1.1':
resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==}
'@kwsites/promise-deferred@1.1.1':
resolution: {integrity: sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==}
'@mediapipe/tasks-vision@0.10.17': '@mediapipe/tasks-vision@0.10.17':
resolution: {integrity: sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==} resolution: {integrity: sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==}
@ -1150,6 +1159,12 @@ packages:
'@rolldown/pluginutils@1.0.0-rc.7': '@rolldown/pluginutils@1.0.0-rc.7':
resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==} resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==}
'@simple-git/args-pathspec@1.0.3':
resolution: {integrity: sha512-ngJMaHlsWDTfjyq9F3VIQ8b7NXbBLq5j9i5bJ6XLYtD6qlDXT7fdKY2KscWWUF8t18xx052Y/PUO1K1TRc9yKA==}
'@simple-git/argv-parser@1.1.1':
resolution: {integrity: sha512-Q9lBcfQ+VQCpQqGJFHe5yooOS5hGdLFFbJ5R+R5aDsnkPCahtn1hSkMcORX65J2Z5lxSkD0lQorMsncuBQxYUw==}
'@socket.io/component-emitter@3.1.2': '@socket.io/component-emitter@3.1.2':
resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==}
@ -3171,6 +3186,9 @@ packages:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'} engines: {node: '>=14'}
simple-git@3.36.0:
resolution: {integrity: sha512-cGQjLjK8bxJw4QuYT7gxHw3/IouVESbhahSsHrX97MzCL1gu2u7oy38W6L2ZIGECEfIBG4BabsWDPjBxJENv9Q==}
slash@3.0.0: slash@3.0.0:
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -4103,6 +4121,14 @@ snapshots:
'@kurkle/color@0.3.4': {} '@kurkle/color@0.3.4': {}
'@kwsites/file-exists@1.1.1':
dependencies:
debug: 4.4.3
transitivePeerDependencies:
- supports-color
'@kwsites/promise-deferred@1.1.1': {}
'@mediapipe/tasks-vision@0.10.17': {} '@mediapipe/tasks-vision@0.10.17': {}
'@mongodb-js/saslprep@1.4.9': '@mongodb-js/saslprep@1.4.9':
@ -4265,6 +4291,12 @@ snapshots:
'@rolldown/pluginutils@1.0.0-rc.7': {} '@rolldown/pluginutils@1.0.0-rc.7': {}
'@simple-git/args-pathspec@1.0.3': {}
'@simple-git/argv-parser@1.1.1':
dependencies:
'@simple-git/args-pathspec': 1.0.3
'@socket.io/component-emitter@3.1.2': {} '@socket.io/component-emitter@3.1.2': {}
'@standard-schema/spec@1.1.0': {} '@standard-schema/spec@1.1.0': {}
@ -6421,6 +6453,16 @@ snapshots:
signal-exit@4.1.0: {} signal-exit@4.1.0: {}
simple-git@3.36.0:
dependencies:
'@kwsites/file-exists': 1.1.1
'@kwsites/promise-deferred': 1.1.1
'@simple-git/args-pathspec': 1.0.3
'@simple-git/argv-parser': 1.1.1
debug: 4.4.3
transitivePeerDependencies:
- supports-color
slash@3.0.0: {} slash@3.0.0: {}
slug@11.0.1: {} slug@11.0.1: {}