socket message and drone workspace cleanup

This commit is contained in:
Rob Colbert 2026-05-02 02:46:45 -04:00
parent 0bb789ea6b
commit 4642609d06
9 changed files with 558 additions and 290 deletions

View File

@ -1,6 +1,6 @@
const API_BASE = ''; const API_BASE = "";
const TOKEN_KEY = 'dtp_auth_token'; const TOKEN_KEY = "dtp_auth_token";
export interface ApiResponse<T = unknown> { export interface ApiResponse<T = unknown> {
success: boolean; success: boolean;
@ -21,21 +21,21 @@ function getToken(): string | null {
async function request<T>( async function request<T>(
method: string, method: string,
path: string, path: string,
body?: Record<string, unknown> body?: Record<string, unknown>,
): Promise<T> { ): Promise<T> {
const token = getToken(); const token = getToken();
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Content-Type': 'application/json', "Content-Type": "application/json",
}; };
if (token) { if (token) {
headers['Authorization'] = `Bearer ${token}`; headers["Authorization"] = `Bearer ${token}`;
} }
const options: RequestInit = { const options: RequestInit = {
method, method,
headers, headers,
credentials: 'include', credentials: "include",
}; };
if (body) { if (body) {
@ -46,7 +46,7 @@ async function request<T>(
const text = await response.text(); const text = await response.text();
if (!text) { if (!text) {
throw new Error('Empty response'); throw new Error("Empty response");
} }
try { try {
@ -56,7 +56,7 @@ async function request<T>(
} }
if (!json.success) { if (!json.success) {
throw new Error(json.message || 'Request failed'); throw new Error(json.message || "Request failed");
} }
if (json.token !== undefined && json.user !== undefined) { if (json.token !== undefined && json.user !== undefined) {
@ -69,12 +69,12 @@ async function request<T>(
} }
export const api = { export const api = {
get: <T>(path: string) => request<T>('GET', path), get: <T>(path: string) => request<T>("GET", path),
post: <T>(path: string, body: Record<string, unknown>) => post: <T>(path: string, body?: Record<string, unknown>) =>
request<T>('POST', path, body), request<T>("POST", path, body),
put: <T>(path: string, body: Record<string, unknown>) => put: <T>(path: string, body: Record<string, unknown>) =>
request<T>('PUT', path, body), request<T>("PUT", path, body),
delete: <T>(path: string) => request<T>('DELETE', path), delete: <T>(path: string) => request<T>("DELETE", path),
}; };
export interface User { export interface User {
@ -93,19 +93,26 @@ export interface Project {
_id: string; _id: string;
createdAt: string; createdAt: string;
user: string; user: string;
status: 'active' | 'inactive' | 'archived'; status: "active" | "inactive" | "archived";
name: string; name: string;
slug: string; slug: string;
gitUrl?: string; gitUrl?: string;
} }
export const projectApi = { export const projectApi = {
getAll: () => api.get<Project[]>('/api/v1/projects'), getAll: () => api.get<Project[]>("/api/v1/projects"),
get: (id: string) => api.get<Project>(`/api/v1/projects/${id}`), get: (id: string) => api.get<Project>(`/api/v1/projects/${id}`),
create: (data: { name: string; slug: string; gitUrl?: string }) => create: (data: { name: string; slug: string; gitUrl?: string }) =>
api.post<Project>('/api/v1/projects', data), api.post<Project>("/api/v1/projects", data),
update: (id: string, data: Partial<{ name: string; slug: string; gitUrl: string; status: string }>) => update: (
api.put<Project>(`/api/v1/projects/${id}`, data), id: string,
data: Partial<{
name: string;
slug: string;
gitUrl: string;
status: string;
}>,
) => api.put<Project>(`/api/v1/projects/${id}`, data),
delete: (id: string) => api.delete<void>(`/api/v1/projects/${id}`), delete: (id: string) => api.delete<void>(`/api/v1/projects/${id}`),
}; };
@ -113,16 +120,20 @@ export interface DroneRegistration {
_id: string; _id: string;
hostname: string; hostname: string;
workspaceDir: string; workspaceDir: string;
status: 'starting' | 'available' | 'busy' | 'offline'; status: "starting" | "available" | "busy" | "offline";
user: string; user: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
export const droneApi = { export const droneApi = {
getAll: () => api.get<DroneRegistration[]>('/api/v1/drone/registration'), getAll: () => api.get<DroneRegistration[]>("/api/v1/drone/registration"),
getForManager: () => api.get<DroneRegistration[]>('/api/v1/drone/registration/manager'), getForManager: () =>
terminate: (registrationId: string) => api.post<ApiResponse>(`/api/v1/drone/registration/${registrationId}/terminate`), api.get<DroneRegistration[]>("/api/v1/drone/registration/manager"),
terminate: (registrationId: string) =>
api.post<ApiResponse>(
`/api/v1/drone/registration/${registrationId}/terminate`,
),
}; };
export interface AiModelCapabilities { export interface AiModelCapabilities {
@ -153,7 +164,7 @@ export interface AiModel {
export interface AiProvider { export interface AiProvider {
_id: string; _id: string;
name: string; name: string;
apiType: 'ollama' | 'openai'; apiType: "ollama" | "openai";
baseUrl: string; baseUrl: string;
enabled: boolean; enabled: boolean;
models: AiModel[]; models: AiModel[];
@ -161,16 +172,16 @@ export interface AiProvider {
} }
export const providerApi = { export const providerApi = {
getAll: () => api.get<AiProvider[]>('/api/v1/providers'), getAll: () => api.get<AiProvider[]>("/api/v1/providers"),
get: (id: string) => api.get<AiProvider>(`/api/v1/providers/${id}`), get: (id: string) => api.get<AiProvider>(`/api/v1/providers/${id}`),
}; };
export enum ChatSessionMode { export enum ChatSessionMode {
Plan = 'plan', Plan = "plan",
Build = 'build', Build = "build",
Test = 'test', Test = "test",
Ship = 'ship', Ship = "ship",
Develop = 'dev', Develop = "dev",
} }
export interface ChatSessionStats { export interface ChatSessionStats {
@ -217,7 +228,7 @@ export interface ChatTurn {
provider: string | AiProvider; provider: string | AiProvider;
llm: string; llm: string;
mode: ChatSessionMode; mode: ChatSessionMode;
status: 'processing' | 'finished' | 'error'; status: "processing" | "finished" | "error";
prompts: ChatTurnPrompts; prompts: ChatTurnPrompts;
thinking?: string; thinking?: string;
response?: string; response?: string;
@ -234,7 +245,7 @@ export interface ChatTurn {
export const chatSessionApi = { export const chatSessionApi = {
getAll: (projectId?: string) => getAll: (projectId?: string) =>
api.get<ChatSession[]>( api.get<ChatSession[]>(
`/api/v1/chat-sessions${projectId ? `?projectId=${projectId}` : ''}` `/api/v1/chat-sessions${projectId ? `?projectId=${projectId}` : ""}`,
), ),
get: (id: string) => api.get<ChatSession>(`/api/v1/chat-sessions/${id}`), get: (id: string) => api.get<ChatSession>(`/api/v1/chat-sessions/${id}`),
create: (data: { create: (data: {
@ -243,9 +254,10 @@ export const chatSessionApi = {
selectedModel: string; selectedModel: string;
mode?: ChatSessionMode; mode?: ChatSessionMode;
name?: string; name?: string;
}) => api.post<ChatSession>('/api/v1/chat-sessions', data), }) => api.post<ChatSession>("/api/v1/chat-sessions", data),
update: (id: string, data: Partial<ChatSession>) => update: (id: string, data: Partial<ChatSession>) =>
api.put<ChatSession>(`/api/v1/chat-sessions/${id}`, data), api.put<ChatSession>(`/api/v1/chat-sessions/${id}`, data),
delete: (id: string) => api.delete<void>(`/api/v1/chat-sessions/${id}`), delete: (id: string) => api.delete<void>(`/api/v1/chat-sessions/${id}`),
getTurns: (id: string) => api.get<ChatTurn[]>(`/api/v1/chat-sessions/${id}/turns`), getTurns: (id: string) =>
api.get<ChatTurn[]>(`/api/v1/chat-sessions/${id}/turns`),
}; };

View File

@ -1,13 +1,22 @@
import { createContext } from 'react'; import { createContext } from "react";
import { io, Socket } from 'socket.io-client'; import { io, Socket } from "socket.io-client";
const SOCKET_URL = ''; const SOCKET_URL = "";
export interface ServerToClientEvents { export interface ServerToClientEvents {
thinking: (content: string) => void; thinking: (content: string) => void;
response: (content: string) => void; response: (content: string) => void;
toolCall: (callId: string, name: string, params: string, response: string) => void; toolCall: (
workOrderComplete: (turnId: string, success: boolean, message?: string) => void; callId: string,
name: string,
params: string,
response: string,
) => void;
workOrderComplete: (
turnId: string,
success: boolean,
message?: string,
) => void;
} }
export interface ClientToServerEvents { export interface ClientToServerEvents {
@ -16,92 +25,132 @@ export interface ClientToServerEvents {
registration: any, registration: any,
project: any, project: any,
chatSession: any, chatSession: any,
cb: (success: boolean, chatSessionId: string) => void cb: (success: boolean, chatSessionId: string) => void,
) => void; ) => void;
} }
export interface SocketEvents { export interface SocketEvents {
'agent:thinking': (data: { agentId: string; thinking: string }) => void; thinking: (content: string) => void;
'agent:response': (data: { agentId: string; chunk: string }) => void; response: (content: string) => void;
'agent:tool-call': (data: { agentId: string; tool: string; args: unknown }) => void; toolCall: (
'agent:tool-result': (data: { agentId: string; tool: string; result: unknown }) => void; callId: string,
'agent:complete': (data: { agentId: string }) => void; name: string,
'log:entry': (data: { level: string; message: string; timestamp: number }) => void; params: string,
'chat:message': (data: { agentId: string; message: string; role: 'user' | 'assistant' | 'system' }) => void; response: string,
) => void;
workOrderComplete: (
turnId: string,
success: boolean,
message?: string,
) => void;
"agent:thinking": (data: { agentId: string; thinking: string }) => void;
"agent:response": (data: { agentId: string; chunk: string }) => void;
"agent:tool-call": (data: {
agentId: string;
tool: string;
args: unknown;
}) => void;
"agent:tool-result": (data: {
agentId: string;
tool: string;
result: unknown;
}) => void;
"agent:complete": (data: { agentId: string }) => void;
"log:entry": (data: {
level: string;
message: string;
timestamp: number;
}) => void;
"chat:message": (data: {
agentId: string;
message: string;
role: "user" | "assistant" | "system";
}) => void;
connect: () => void; connect: () => void;
disconnect: () => void; disconnect: (reason: string) => void;
error: (error: Error) => void; error: (error: Error) => void;
} }
class SocketClient { class SocketClient {
private socket: Socket | null = null; private _socket: Socket | null = null;
private eventListeners: Map<string, Set<(...args: unknown[]) => void>> = new Map(); private eventListeners: Map<string, Set<(...args: unknown[]) => void>> =
new Map();
private reconnectAttempts = 0; private reconnectAttempts = 0;
private maxReconnectAttempts = 5; private maxReconnectAttempts = 5;
private jwt: string | null = null; private jwt: string | null = null;
get connected(): boolean { get connected(): boolean {
return this.socket?.connected ?? false; return this._socket?.connected ?? false;
} }
get socket(): Socket | null { get socket(): Socket | null {
return this.socket; return this._socket;
} }
connect(token: string): void { connect(token: string): void {
if (this.socket?.connected) { if (this._socket?.connected) {
return; return;
} }
this.jwt = token; this.jwt = token;
this.socket = io(SOCKET_URL, { this._socket = io(SOCKET_URL, {
auth: { auth: {
token: this.jwt, token: this.jwt,
}, },
transports: ['websocket', 'polling'], transports: ["websocket", "polling"],
reconnection: true, reconnection: true,
reconnectionAttempts: this.maxReconnectAttempts, reconnectionAttempts: this.maxReconnectAttempts,
reconnectionDelay: 1000, reconnectionDelay: 1000,
reconnectionDelayMax: 5000, reconnectionDelayMax: 5000,
}); });
if (!this.socket) {
return;
}
// Forward server events to our event listeners // Forward server events to our event listeners
this.socket.on('thinking', (content: string) => { this.socket.on("thinking", (content: string) => {
this.emit('thinking', content); this.emit("thinking", content);
}); });
this.socket.on('response', (content: string) => { this.socket.on("response", (content: string) => {
this.emit('response', content); this.emit("response", content);
}); });
this.socket.on('toolCall', (callId: string, name: string, params: string, response: string) => { this.socket.on(
this.emit('toolCall', callId, name, params, response); "toolCall",
}); (callId: string, name: string, params: string, response: string) => {
this.emit("toolCall", callId, name, params, response);
},
);
this.socket.on('workOrderComplete', (turnId: string, success: boolean, message?: string) => { this.socket.on(
this.emit('workOrderComplete', turnId, success, message); "workOrderComplete",
}); (turnId: string, success: boolean, message?: string) => {
this.emit("workOrderComplete", turnId, success, message);
},
);
this.socket.on('connect', () => { this._socket.on("connect", () => {
this.reconnectAttempts = 0; this.reconnectAttempts = 0;
this.emit('connect'); this.emit("connect");
}); });
this.socket.on('disconnect', (reason) => { this._socket.on("disconnect", (reason) => {
this.emit('disconnect', reason); this.emit("disconnect", reason);
}); });
this.socket.on('connect_error', (error) => { this._socket.on("connect_error", (error) => {
this.reconnectAttempts++; this.reconnectAttempts++;
this.emit('error', error); this.emit("error", error);
}); });
} }
disconnect(): void { disconnect(): void {
if (this.socket) { if (this._socket) {
this.socket.disconnect(); this._socket.disconnect();
this.socket = null; this._socket = null;
this.jwt = null; this.jwt = null;
} }
} }
@ -110,7 +159,9 @@ class SocketClient {
if (!this.eventListeners.has(event)) { if (!this.eventListeners.has(event)) {
this.eventListeners.set(event, new Set()); this.eventListeners.set(event, new Set());
} }
this.eventListeners.get(event)!.add(callback as (...args: unknown[]) => void); this.eventListeners
.get(event)!
.add(callback as (...args: unknown[]) => void);
} }
off<K extends keyof SocketEvents>(event: K, callback: SocketEvents[K]): void { off<K extends keyof SocketEvents>(event: K, callback: SocketEvents[K]): void {
@ -120,35 +171,48 @@ class SocketClient {
} }
} }
emit<K extends keyof SocketEvents>(event: K, ...args: Parameters<SocketEvents[K]>): void { emit<K extends keyof SocketEvents>(
event: K,
...args: Parameters<SocketEvents[K]>
): void {
const listeners = this.eventListeners.get(event); const listeners = this.eventListeners.get(event);
if (listeners) { if (listeners) {
listeners.forEach((callback) => callback(...args)); listeners.forEach((callback) => callback(...args));
} }
} }
send<K extends keyof SocketEvents>(event: K, ...args: Parameters<SocketEvents[K]>): void { send<K extends keyof SocketEvents>(
if (this.socket?.connected) { event: K,
this.socket.emit(event, ...args); ...args: Parameters<SocketEvents[K]>
): void {
if (this._socket?.connected) {
this._socket.emit(event, ...args);
} }
} }
emitServer<K extends keyof ClientToServerEvents>(event: K, ...args: Parameters<ClientToServerEvents[K]>): void { emitServer<K extends keyof ClientToServerEvents>(
if (this.socket?.connected) { event: K,
this.socket.emit(event, ...args); ...args: Parameters<ClientToServerEvents[K]>
): void {
if (this._socket?.connected) {
this._socket.emit(event, ...args);
} }
} }
requestSessionLock( requestSessionLock(
registration: any, registration: any,
project: any, project: any,
chatSession: any chatSession: any,
): Promise<boolean> { ): Promise<boolean> {
return new Promise((resolve) => { return new Promise((resolve) => {
if (this.socket?.connected) { if (this._socket?.connected) {
this.socket.emit('requestSessionLock', registration, project, chatSession, (success: boolean) => { this._socket.emit(
resolve(success); "requestSessionLock",
}); registration,
project,
chatSession,
resolve,
);
} else { } else {
resolve(false); resolve(false);
} }

View File

@ -19,7 +19,7 @@ function SystemReady() {
href="/sign-in" href="/sign-in"
className="inline-block px-4 py-2 border border-border-highlight text-text-primary hover:bg-bg-tertiary hover:text-text-primary rounded transition-colors" className="inline-block px-4 py-2 border border-border-highlight text-text-primary hover:bg-bg-tertiary hover:text-text-primary rounded transition-colors"
> >
[ Sign In ] Sign In
</a> </a>
</div> </div>
</div> </div>

View File

@ -1,35 +1,49 @@
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 type { User, Project } from '../lib/api'; import type { User, Project } from "../lib/api";
import { projectApi, droneApi, chatSessionApi, type DroneRegistration, type ChatSession, type AiProvider, providerApi } from '../lib/api'; import {
import { socketClient } from '../lib/socket'; projectApi,
droneApi,
chatSessionApi,
type DroneRegistration,
type ChatSession,
type AiProvider,
providerApi,
} from "../lib/api";
import { socketClient } from "../lib/socket";
interface ProjectManagerProps { interface ProjectManagerProps {
user: User | null; user: User | null;
} }
function NewProjectForm({ onCancel, onSuccess }: { onCancel: () => void; onSuccess: () => void }) { function NewProjectForm({
const [name, setName] = useState(''); onCancel,
const [slug, setSlug] = useState(''); onSuccess,
const [gitUrl, setGitUrl] = useState(''); }: {
onCancel: () => void;
onSuccess: () => void;
}) {
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [gitUrl, setGitUrl] = useState("");
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState("");
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!name || !slug) { if (!name || !slug) {
setError('Name and slug are required'); setError("Name and slug are required");
return; return;
} }
setSubmitting(true); setSubmitting(true);
setError(''); setError("");
try { try {
await projectApi.create({ name, slug, gitUrl: gitUrl || undefined }); await projectApi.create({ name, slug, gitUrl: gitUrl || undefined });
onSuccess(); onSuccess();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create project'); setError(err instanceof Error ? err.message : "Failed to create project");
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@ -40,7 +54,9 @@ function NewProjectForm({ onCancel, onSuccess }: { onCancel: () => void; onSucce
<h2 className="text-xl font-semibold mb-6">Create New Project</h2> <h2 className="text-xl font-semibold mb-6">Create New Project</h2>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="block text-sm text-text-secondary mb-1">Project Name *</label> <label className="block text-sm text-text-secondary mb-1">
Project Name *
</label>
<input <input
type="text" type="text"
value={name} value={name}
@ -50,7 +66,9 @@ function NewProjectForm({ onCancel, onSuccess }: { onCancel: () => void; onSucce
/> />
</div> </div>
<div> <div>
<label className="block text-sm text-text-secondary mb-1">Project Slug *</label> <label className="block text-sm text-text-secondary mb-1">
Project Slug *
</label>
<input <input
type="text" type="text"
value={slug} value={slug}
@ -58,10 +76,14 @@ function NewProjectForm({ onCancel, onSuccess }: { onCancel: () => void; onSucce
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded text-text-primary focus:border-brand focus:outline-none" 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" placeholder="my-project"
/> />
<p className="text-xs text-text-muted mt-1">Unique identifier for the project directory</p> <p className="text-xs text-text-muted mt-1">
Unique identifier for the project directory
</p>
</div> </div>
<div> <div>
<label className="block text-sm text-text-secondary mb-1">Git Repository URL</label> <label className="block text-sm text-text-secondary mb-1">
Git Repository URL
</label>
<input <input
type="text" type="text"
value={gitUrl} value={gitUrl}
@ -77,7 +99,7 @@ function NewProjectForm({ onCancel, onSuccess }: { onCancel: () => void; onSucce
disabled={submitting} disabled={submitting}
className="px-4 py-2 bg-brand text-white rounded hover:bg-red-700 transition-colors disabled:opacity-50" className="px-4 py-2 bg-brand text-white rounded hover:bg-red-700 transition-colors disabled:opacity-50"
> >
{submitting ? 'Creating...' : 'Create Project'} {submitting ? "Creating..." : "Create Project"}
</button> </button>
<button <button
type="button" type="button"
@ -101,7 +123,11 @@ function ProjectInspector({ project, onDelete }: ProjectInspectorProps) {
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const handleDelete = async () => { const handleDelete = async () => {
if (!confirm('Are you sure you want to delete this project? This cannot be undone.')) { if (
!confirm(
"Are you sure you want to delete this project? This cannot be undone.",
)
) {
return; return;
} }
setDeleting(true); setDeleting(true);
@ -109,7 +135,7 @@ function ProjectInspector({ project, onDelete }: ProjectInspectorProps) {
await projectApi.delete(project._id); await projectApi.delete(project._id);
onDelete(); onDelete();
} catch (err) { } catch (err) {
console.error('Failed to delete project', err); console.error("Failed to delete project", err);
} finally { } finally {
setDeleting(false); setDeleting(false);
} }
@ -123,7 +149,9 @@ function ProjectInspector({ project, onDelete }: ProjectInspectorProps) {
<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">
<div className="text-sm text-text-muted mb-1">Name</div> <div className="text-sm text-text-muted mb-1">Name</div>
<div className="text-text-primary font-medium">{project.name}</div> <div className="text-text-primary font-medium">
{project.name}
</div>
</div> </div>
<div className="p-4 bg-bg-secondary border border-border-default rounded"> <div className="p-4 bg-bg-secondary border border-border-default rounded">
<div className="text-sm text-text-muted mb-1">Slug</div> <div className="text-sm text-text-muted mb-1">Slug</div>
@ -134,7 +162,9 @@ function ProjectInspector({ project, onDelete }: ProjectInspectorProps) {
<div className="p-4 bg-bg-secondary border border-border-default rounded"> <div className="p-4 bg-bg-secondary border border-border-default rounded">
<div className="text-sm text-text-muted mb-1">Git URL</div> <div className="text-sm text-text-muted mb-1">Git URL</div>
<div className="text-text-primary font-mono text-sm break-all"> <div className="text-text-primary font-mono text-sm break-all">
{project.gitUrl || <span className="text-text-muted">Not configured</span>} {project.gitUrl || (
<span className="text-text-muted">Not configured</span>
)}
</div> </div>
</div> </div>
@ -142,10 +172,15 @@ function ProjectInspector({ project, onDelete }: ProjectInspectorProps) {
<div className="p-4 bg-bg-secondary border border-border-default rounded"> <div className="p-4 bg-bg-secondary border border-border-default rounded">
<div className="text-sm text-text-muted mb-1">Status</div> <div className="text-sm text-text-muted mb-1">Status</div>
<div className="text-text-primary capitalize"> <div className="text-text-primary capitalize">
<span className={`inline-block w-2 h-2 rounded-full mr-2 ${ <span
project.status === 'active' ? 'bg-green-500' : className={`inline-block w-2 h-2 rounded-full mr-2 ${
project.status === 'inactive' ? 'bg-yellow-500' : 'bg-gray-500' project.status === "active"
}`} /> ? "bg-green-500"
: project.status === "inactive"
? "bg-yellow-500"
: "bg-gray-500"
}`}
/>
{project.status} {project.status}
</div> </div>
</div> </div>
@ -163,7 +198,7 @@ function ProjectInspector({ project, onDelete }: ProjectInspectorProps) {
disabled={deleting} disabled={deleting}
className="px-4 py-2 border border-red-600 text-red-500 rounded hover:bg-red-900/20 transition-colors disabled:opacity-50" className="px-4 py-2 border border-red-600 text-red-500 rounded hover:bg-red-900/20 transition-colors disabled:opacity-50"
> >
{deleting ? 'Deleting...' : 'Delete Project'} {deleting ? "Deleting..." : "Delete Project"}
</button> </button>
</div> </div>
</div> </div>
@ -174,17 +209,25 @@ function ProjectInspector({ project, onDelete }: ProjectInspectorProps) {
interface RightSidebarProps { interface RightSidebarProps {
project: Project; project: Project;
selectedDrone: DroneRegistration | null;
onSelectDrone: (drone: DroneRegistration) => void;
onOpenChatSession: (sessionId: string) => void; onOpenChatSession: (sessionId: string) => void;
} }
function RightSidebar({ project, onOpenChatSession }: RightSidebarProps) { function RightSidebar({
project,
selectedDrone,
onSelectDrone,
onOpenChatSession,
}: RightSidebarProps) {
const [drones, setDrones] = useState<DroneRegistration[]>([]); const [drones, setDrones] = useState<DroneRegistration[]>([]);
const [chatSessions, setChatSessions] = useState<ChatSession[]>([]); const [chatSessions, setChatSessions] = useState<ChatSession[]>([]);
const [providers, setProviders] = useState<AiProvider[]>([]); const [providers, setProviders] = useState<AiProvider[]>([]);
const [selectedDrone, setSelectedDrone] = useState<DroneRegistration | null>(null);
const [showNewChatModal, setShowNewChatModal] = useState(false); const [showNewChatModal, setShowNewChatModal] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [deletingSessions, setDeletingSessions] = useState<Set<string>>(new Set()); const [deletingSessions, setDeletingSessions] = useState<Set<string>>(
new Set(),
);
useEffect(() => { useEffect(() => {
loadData(); loadData();
@ -205,16 +248,12 @@ function RightSidebar({ project, onOpenChatSession }: RightSidebarProps) {
const allProviders = await providerApi.getAll(); const allProviders = await providerApi.getAll();
setProviders(allProviders); setProviders(allProviders);
} catch (err) { } catch (err) {
console.error('Failed to load sidebar data', err); console.error("Failed to load sidebar data", err);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const handleSelectDrone = (drone: DroneRegistration) => {
setSelectedDrone(drone);
};
const handleCreateChatSession = async (data: { const handleCreateChatSession = async (data: {
providerId: string; providerId: string;
selectedModel: string; selectedModel: string;
@ -233,29 +272,33 @@ function RightSidebar({ project, onOpenChatSession }: RightSidebarProps) {
// Lock the drone to this session BEFORE navigating // Lock the drone to this session BEFORE navigating
if (selectedDrone) { if (selectedDrone) {
const success = await socketClient.requestSessionLock(selectedDrone, project, session); const success = await socketClient.requestSessionLock(
selectedDrone,
project,
session,
);
if (!success) { if (!success) {
console.error('Failed to lock drone session'); console.error("Failed to lock drone session");
} }
} }
onOpenChatSession(session._id); onOpenChatSession(session._id);
await loadData(); // Refresh list await loadData(); // Refresh list
} catch (err) { } catch (err) {
console.error('Failed to create chat session', err); console.error("Failed to create chat session", err);
} }
}; };
const handleDeleteChatSession = async (sessionId: string) => { const handleDeleteChatSession = async (sessionId: string) => {
if (!confirm('Delete this chat session?')) return; if (!confirm("Delete this chat session?")) return;
setDeletingSessions(prev => new Set(prev).add(sessionId)); setDeletingSessions((prev) => new Set(prev).add(sessionId));
try { try {
await chatSessionApi.delete(sessionId); await chatSessionApi.delete(sessionId);
await loadData(); await loadData();
} catch (err) { } catch (err) {
console.error('Failed to delete chat session', err); console.error("Failed to delete chat session", err);
} finally { } finally {
setDeletingSessions(prev => { setDeletingSessions((prev) => {
const next = new Set(prev); const next = new Set(prev);
next.delete(sessionId); next.delete(sessionId);
return next; return next;
@ -267,7 +310,10 @@ function RightSidebar({ project, onOpenChatSession }: RightSidebarProps) {
<> <>
<aside className="w-80 border-l border-border-subtle bg-bg-secondary flex flex-col overflow-hidden"> <aside className="w-80 border-l border-border-subtle bg-bg-secondary flex flex-col overflow-hidden">
{/* Available Drones Section - 40% of available space (excluding button row) */} {/* Available Drones Section - 40% of available space (excluding button row) */}
<div className="flex flex-col min-h-0 flex-[2]" style={{ minHeight: 0 }}> <div
className="flex flex-col min-h-0 flex-[2]"
style={{ minHeight: 0 }}
>
<div className="p-3 border-b border-border-subtle flex-shrink-0"> <div className="p-3 border-b border-border-subtle flex-shrink-0">
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider"> <h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider">
Available Drones ({drones.length}) Available Drones ({drones.length})
@ -278,7 +324,8 @@ function RightSidebar({ project, onOpenChatSession }: RightSidebarProps) {
<p className="text-sm text-text-muted p-2">Loading...</p> <p className="text-sm text-text-muted p-2">Loading...</p>
) : drones.length === 0 ? ( ) : drones.length === 0 ? (
<div className="text-text-muted text-sm p-2"> <div className="text-text-muted text-sm p-2">
No available drones. Start a gadget-drone instance to begin working. No available drones. Start a gadget-drone instance to begin
working.
</div> </div>
) : ( ) : (
drones.map((drone) => ( drones.map((drone) => (
@ -286,35 +333,37 @@ function RightSidebar({ project, onOpenChatSession }: RightSidebarProps) {
key={drone._id} key={drone._id}
className={`p-3 border rounded transition-colors ${ className={`p-3 border rounded transition-colors ${
selectedDrone?._id === drone._id selectedDrone?._id === drone._id
? 'border-brand bg-bg-tertiary' ? "border-brand bg-bg-tertiary"
: 'border-border-default bg-bg-tertiary hover:bg-bg-elevated' : "border-border-default bg-bg-tertiary hover:bg-bg-elevated"
}`} }`}
> >
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div <div
className={`w-2 h-2 rounded-full ${ className={`w-2 h-2 rounded-full ${
drone.status === 'available' drone.status === "available"
? 'bg-green-500' ? "bg-green-500"
: drone.status === 'busy' : drone.status === "busy"
? 'bg-yellow-500' ? "bg-yellow-500"
: 'bg-gray-500' : "bg-gray-500"
}`} }`}
/> />
<span className="font-mono text-sm font-medium">{drone.hostname}</span> <span className="font-mono text-sm font-medium">
{drone.hostname}
</span>
</div> </div>
<button <button
onClick={() => handleSelectDrone(drone)} onClick={() => onSelectDrone(drone)}
disabled={drone.status === 'busy'} disabled={drone.status === "busy"}
className={`px-2 py-1 text-xs rounded ${ className={`px-2 py-1 text-xs rounded ${
selectedDrone?._id === drone._id selectedDrone?._id === drone._id
? 'bg-brand text-white' ? "bg-brand text-white"
: drone.status === 'busy' : drone.status === "busy"
? 'bg-bg-tertiary text-text-muted cursor-not-allowed' ? "bg-bg-tertiary text-text-muted cursor-not-allowed"
: 'border border-border-default hover:bg-bg-elevated' : "border border-border-default hover:bg-bg-elevated"
}`} }`}
> >
{selectedDrone?._id === drone._id ? 'Selected' : 'Select'} {selectedDrone?._id === drone._id ? "Selected" : "Select"}
</button> </button>
</div> </div>
<div className="text-xs text-text-muted truncate"> <div className="text-xs text-text-muted truncate">
@ -330,7 +379,10 @@ function RightSidebar({ project, onOpenChatSession }: RightSidebarProps) {
</div> </div>
{/* Chat Sessions Section - 60% of available space (excluding button row) */} {/* Chat Sessions Section - 60% of available space (excluding button row) */}
<div className="flex flex-col min-h-0 flex-[3]" style={{ minHeight: 0 }}> <div
className="flex flex-col min-h-0 flex-[3]"
style={{ minHeight: 0 }}
>
<div className="p-3 border-b border-border-subtle flex-shrink-0 flex items-center justify-between"> <div className="p-3 border-b border-border-subtle flex-shrink-0 flex items-center justify-between">
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider"> <h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider">
Chat Sessions ({chatSessions.length}) Chat Sessions ({chatSessions.length})
@ -370,7 +422,17 @@ function RightSidebar({ project, onOpenChatSession }: RightSidebarProps) {
className="absolute top-2 right-2 p-1.5 rounded opacity-0 group-hover:opacity-100 hover:bg-red-900/50 text-text-muted hover:text-red-400 transition-all disabled:opacity-50" className="absolute top-2 right-2 p-1.5 rounded opacity-0 group-hover:opacity-100 hover:bg-red-900/50 text-text-muted hover:text-red-400 transition-all disabled:opacity-50"
title="Delete session" title="Delete session"
> >
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6"></polyline> <polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path> <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg> </svg>
@ -387,9 +449,13 @@ function RightSidebar({ project, onOpenChatSession }: RightSidebarProps) {
onClick={() => setShowNewChatModal(true)} onClick={() => setShowNewChatModal(true)}
disabled={!selectedDrone} disabled={!selectedDrone}
className="w-full px-4 py-2.5 bg-brand text-white rounded hover:bg-red-700 transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed" className="w-full px-4 py-2.5 bg-brand text-white rounded hover:bg-red-700 transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
title={!selectedDrone ? 'Select a drone first' : 'Create new chat session'} title={
!selectedDrone
? "Select a drone first"
: "Create new chat session"
}
> >
[New Chat Session] New Chat Session
</button> </button>
</div> </div>
</aside> </aside>
@ -411,17 +477,27 @@ interface NewChatSessionModalProps {
providers: AiProvider[]; providers: AiProvider[];
selectedDrone: DroneRegistration | null; selectedDrone: DroneRegistration | null;
onCancel: () => void; onCancel: () => void;
onCreate: (data: { providerId: string; selectedModel: string; mode: string; name?: string }) => void; onCreate: (data: {
providerId: string;
selectedModel: string;
mode: string;
name?: string;
}) => void;
} }
function NewChatSessionModal({ providers, selectedDrone, onCancel, onCreate }: NewChatSessionModalProps) { function NewChatSessionModal({
const [selectedProviderId, setSelectedProviderId] = useState(''); providers,
const [selectedModel, setSelectedModel] = useState(''); selectedDrone,
const [mode, setMode] = useState('build'); onCancel,
const [name, setName] = useState(''); onCreate,
}: NewChatSessionModalProps) {
const [selectedProviderId, setSelectedProviderId] = useState("");
const [selectedModel, setSelectedModel] = useState("");
const [mode, setMode] = useState("build");
const [name, setName] = useState("");
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const selectedProvider = providers.find(p => p._id === selectedProviderId); const selectedProvider = providers.find((p) => p._id === selectedProviderId);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@ -431,14 +507,14 @@ function NewChatSessionModal({ providers, selectedDrone, onCancel, onCreate }: N
setCreating(true); setCreating(true);
try { try {
await onCreate({ onCreate({
providerId: selectedProviderId, providerId: selectedProviderId,
selectedModel, selectedModel,
mode, mode,
name: name || undefined, name: name || undefined,
}); });
} catch (err) { } catch (err) {
console.error('Failed to create chat session', err); console.error("Failed to create chat session", err);
} finally { } finally {
setCreating(false); setCreating(false);
} }
@ -451,13 +527,19 @@ function NewChatSessionModal({ providers, selectedDrone, onCancel, onCreate }: N
{selectedDrone && ( {selectedDrone && (
<div className="mb-4 p-3 bg-bg-tertiary border border-border-default rounded"> <div className="mb-4 p-3 bg-bg-tertiary border border-border-default rounded">
<div className="text-xs text-text-muted mb-1">Selected Drone</div> <div className="text-xs text-text-muted mb-1">Selected Drone</div>
<div className="font-mono text-sm text-text-primary">{selectedDrone.hostname}</div> <div className="font-mono text-sm text-text-primary">
<div className="text-xs text-text-muted truncate">{selectedDrone.workspaceDir}</div> {selectedDrone.hostname}
</div>
<div className="text-xs text-text-muted truncate">
{selectedDrone.workspaceDir}
</div>
</div> </div>
)} )}
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="block text-sm text-text-secondary mb-1">Session Name (optional)</label> <label className="block text-sm text-text-secondary mb-1">
Session Name (optional)
</label>
<input <input
type="text" type="text"
value={name} value={name}
@ -468,12 +550,14 @@ function NewChatSessionModal({ providers, selectedDrone, onCancel, onCreate }: N
</div> </div>
<div> <div>
<label className="block text-sm text-text-secondary mb-1">AI Provider *</label> <label className="block text-sm text-text-secondary mb-1">
AI Provider *
</label>
<select <select
value={selectedProviderId} value={selectedProviderId}
onChange={(e) => { onChange={(e) => {
setSelectedProviderId(e.target.value); setSelectedProviderId(e.target.value);
setSelectedModel(''); setSelectedModel("");
}} }}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded text-text-primary focus:border-brand focus:outline-none" className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded text-text-primary focus:border-brand focus:outline-none"
> >
@ -488,7 +572,9 @@ function NewChatSessionModal({ providers, selectedDrone, onCancel, onCreate }: N
{selectedProvider && ( {selectedProvider && (
<div> <div>
<label className="block text-sm text-text-secondary mb-1">Model *</label> <label className="block text-sm text-text-secondary mb-1">
Model *
</label>
<select <select
value={selectedModel} value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)} onChange={(e) => setSelectedModel(e.target.value)}
@ -498,20 +584,24 @@ function NewChatSessionModal({ providers, selectedDrone, onCancel, onCreate }: N
<option value="">Select a model</option> <option value="">Select a model</option>
{selectedProvider.models.map((model) => ( {selectedProvider.models.map((model) => (
<option key={model.id} value={model.id}> <option key={model.id} value={model.id}>
{model.name} {model.parameterLabel ? `(${model.parameterLabel})` : ''} {model.name}{" "}
{model.parameterLabel ? `(${model.parameterLabel})` : ""}
</option> </option>
))} ))}
</select> </select>
{selectedProvider.models.length === 0 && ( {selectedProvider.models.length === 0 && (
<p className="text-xs text-text-muted mt-1"> <p className="text-xs text-text-muted mt-1">
No models discovered. Run `pnpm cli provider probe {selectedProvider._id}` to discover models. No models discovered. Run `pnpm cli provider probe{" "}
{selectedProvider._id}` to discover models.
</p> </p>
)} )}
</div> </div>
)} )}
<div> <div>
<label className="block text-sm text-text-secondary mb-1">Mode</label> <label className="block text-sm text-text-secondary mb-1">
Mode
</label>
<select <select
value={mode} value={mode}
onChange={(e) => setMode(e.target.value)} onChange={(e) => setMode(e.target.value)}
@ -531,7 +621,7 @@ function NewChatSessionModal({ providers, selectedDrone, onCancel, onCreate }: N
disabled={creating || !selectedProviderId || !selectedModel} disabled={creating || !selectedProviderId || !selectedModel}
className="px-4 py-2 bg-brand text-white rounded hover:bg-red-700 transition-colors disabled:opacity-50 flex-1" className="px-4 py-2 bg-brand text-white rounded hover:bg-red-700 transition-colors disabled:opacity-50 flex-1"
> >
{creating ? 'Creating...' : 'Create Session'} {creating ? "Creating..." : "Create Session"}
</button> </button>
<button <button
type="button" type="button"
@ -552,6 +642,7 @@ export default function ProjectManager({ user }: ProjectManagerProps) {
const { slug } = useParams(); const { slug } = useParams();
const [projects, setProjects] = useState<Project[]>([]); const [projects, setProjects] = useState<Project[]>([]);
const [selectedProject, setSelectedProject] = useState<Project | null>(null); const [selectedProject, setSelectedProject] = useState<Project | null>(null);
const [selectedDrone, setSelectedDrone] = useState<DroneRegistration | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [showNewForm, setShowNewForm] = useState(false); const [showNewForm, setShowNewForm] = useState(false);
@ -573,7 +664,7 @@ export default function ProjectManager({ user }: ProjectManagerProps) {
const data = await projectApi.getAll(); const data = await projectApi.getAll();
setProjects(data); setProjects(data);
} catch (err) { } catch (err) {
console.error('Failed to load projects', err); console.error("Failed to load projects", err);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -591,12 +682,30 @@ export default function ProjectManager({ user }: ProjectManagerProps) {
const handleProjectDeleted = () => { const handleProjectDeleted = () => {
setSelectedProject(null); setSelectedProject(null);
loadProjects(); loadProjects();
navigate('/projects'); navigate("/projects");
}; };
const handleOpenChatSession = (sessionId: string) => { const handleSelectDrone = (drone: DroneRegistration) => {
if (selectedProject) { setSelectedDrone(drone);
};
const handleOpenChatSession = async (sessionId: string) => {
if (!selectedProject || !selectedDrone) return;
try {
const session = await chatSessionApi.get(sessionId);
const success = await socketClient.requestSessionLock(
selectedDrone,
selectedProject,
session,
);
if (!success) {
console.error("Failed to lock drone session");
return;
}
navigate(`/projects/${selectedProject._id}/chat-session/${sessionId}`); navigate(`/projects/${selectedProject._id}/chat-session/${sessionId}`);
} catch (err) {
console.error("Failed to open chat session", err);
} }
}; };
@ -625,13 +734,15 @@ export default function ProjectManager({ user }: ProjectManagerProps) {
onClick={() => setShowNewForm(true)} onClick={() => setShowNewForm(true)}
className="w-full px-3 py-2 bg-brand text-white rounded hover:bg-red-700 transition-colors text-sm font-medium" className="w-full px-3 py-2 bg-brand text-white rounded hover:bg-red-700 transition-colors text-sm font-medium"
> >
[New Project] New Project
</button> </button>
</div> </div>
<div className="flex-1 overflow-y-auto p-2"> <div className="flex-1 overflow-y-auto p-2">
{showNewForm ? ( {showNewForm ? (
<div className="p-2"> <div className="p-2">
<p className="text-sm text-text-muted mb-2">Creating new project...</p> <p className="text-sm text-text-muted mb-2">
Creating new project...
</p>
<button <button
onClick={() => setShowNewForm(false)} onClick={() => setShowNewForm(false)}
className="text-sm text-text-secondary hover:text-text-primary transition-colors" className="text-sm text-text-secondary hover:text-text-primary transition-colors"
@ -654,8 +765,8 @@ export default function ProjectManager({ user }: ProjectManagerProps) {
onClick={() => handleSelectProject(project)} onClick={() => handleSelectProject(project)}
className={`w-full text-left px-2 py-1.5 text-sm rounded truncate transition-colors ${ className={`w-full text-left px-2 py-1.5 text-sm rounded truncate transition-colors ${
selectedProject?.slug === project.slug selectedProject?.slug === project.slug
? 'bg-brand text-white' ? "bg-brand text-white"
: 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary' : "text-text-secondary hover:bg-bg-tertiary hover:text-text-primary"
}`} }`}
> >
{project.name} {project.name}
@ -688,6 +799,8 @@ export default function ProjectManager({ user }: ProjectManagerProps) {
{/* Right Sidebar - Drones & Chat Sessions */} {/* Right Sidebar - Drones & Chat Sessions */}
<RightSidebar <RightSidebar
project={selectedProject} project={selectedProject}
selectedDrone={selectedDrone}
onSelectDrone={handleSelectDrone}
onOpenChatSession={handleOpenChatSession} onOpenChatSession={handleOpenChatSession}
/> />
</> </>

View File

@ -14,10 +14,10 @@ import {
IUser, IUser,
ChatTurnStatus, ChatTurnStatus,
GadgetId, GadgetId,
ChatTurnDocument,
} from "@gadget/api"; } from "@gadget/api";
import SocketService from "../services/socket.ts"; import { ChatSessionService, SocketService } from "../services/index.ts";
import { ChatTurn } from "../models/chat-turn.ts";
export class CodeSession extends SocketSession { export class CodeSession extends SocketSession {
protected type: SocketSessionType = SocketSessionType.Code; protected type: SocketSessionType = SocketSessionType.Code;
@ -107,31 +107,10 @@ export class CodeSession extends SocketSession {
const droneSession = SocketService.getDroneSession(this.selectedDrone); const droneSession = SocketService.getDroneSession(this.selectedDrone);
const turn = new ChatTurn({ let turn: ChatTurnDocument = await ChatSessionService.createTurn(
createdAt: new Date(), this.chatSession,
user: this.user._id, content,
project: this.project._id, );
session: this.chatSession._id,
provider: this.chatSession.provider,
llm: this.chatSession.selectedModel,
mode: this.chatSession.mode,
status: ChatTurnStatus.Processing,
prompts: {
user: content,
system: undefined,
},
toolCalls: [],
subagents: [],
stats: {
toolCallCount: 0,
inputTokens: 0,
thinkingTokenCount: 0,
responseTokens: 0,
durationMs: 0,
durationLabel: "pending",
},
});
await turn.save();
this.currentTurnId = turn._id; this.currentTurnId = turn._id;
this.log.info("ChatTurn created", { this.log.info("ChatTurn created", {

View File

@ -2,7 +2,15 @@
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us> // Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
// All Rights Reserved // All Rights Reserved
import { IChatSession, ChatSessionMode, GadgetId } from "@gadget/api"; import {
IChatSession,
ChatSessionMode,
GadgetId,
IUser,
IProject,
ChatTurnStatus,
ChatTurnDocument,
} from "@gadget/api";
import { DtpService } from "../lib/service.js"; import { DtpService } from "../lib/service.js";
import { PopulateOptions } from "mongoose"; import { PopulateOptions } from "mongoose";
@ -14,9 +22,34 @@ import AiProvider from "../models/ai-provider.js";
class ChatSessionService extends DtpService { class ChatSessionService extends DtpService {
private populateSession: PopulateOptions[] = [ private populateSession: PopulateOptions[] = [
{ path: "user", select: "-passwordSalt -password" }, { path: "user", select: "-passwordSalt -password" },
{ path: "project" }, {
path: "project",
populate: [
{
path: "user",
select: "-passwordSalt -password",
},
],
},
{ path: "provider" }, { path: "provider" },
]; ];
private populateChatTurn: PopulateOptions[] = [
{
path: "user",
select: "-passwordSalt -password",
},
{
path: "project",
},
{
path: "session",
populate: this.populateSession,
},
{
path: "provider",
select: "-models",
},
];
get name(): string { get name(): string {
return "ChatSessionService"; return "ChatSessionService";
@ -193,6 +226,45 @@ class ChatSessionService extends DtpService {
}); });
} }
async createTurn(
session: IChatSession,
prompt: string,
): Promise<ChatTurnDocument> {
const NOW = new Date();
const user: IUser = session.user as IUser;
const project: IProject = session.project as IProject;
let turn = new ChatTurn({
createdAt: NOW,
user: user._id,
project: project._id,
session: session._id,
provider: session.provider,
llm: session.selectedModel,
mode: session.mode,
status: ChatTurnStatus.Processing,
prompts: {
user: prompt,
system: undefined,
},
toolCalls: [],
subagents: [],
stats: {
toolCallCount: 0,
inputTokens: 0,
thinkingTokenCount: 0,
responseTokens: 0,
durationMs: 0,
durationLabel: "pending",
},
});
await turn.save();
turn = await ChatTurn.populate(turn, this.populateChatTurn);
return turn;
}
/** /**
* Gets all turns for a chat session. * Gets all turns for a chat session.
*/ */

View File

@ -40,6 +40,8 @@ class GadgetDrone extends GadgetProcess {
private user: IUser | undefined; private user: IUser | undefined;
private workspaceMode: WorkspaceMode = WorkspaceMode.Syncing; private workspaceMode: WorkspaceMode = WorkspaceMode.Syncing;
private chatSession: IChatSession | undefined;
private socket: ClientSocket | undefined; private socket: ClientSocket | undefined;
private isShuttingDown: boolean = false; private isShuttingDown: boolean = false;
@ -218,11 +220,10 @@ class GadgetDrone extends GadgetProcess {
chatSession: IChatSession, chatSession: IChatSession,
cb: RequestSessionLockCallback, cb: RequestSessionLockCallback,
) { ) {
this.log.info("requestSessionLock received", { /*
registration, * Validate gadget-drone registration to ensure correct sync with IDE
project, */
chatSession,
});
if (!this.registration) { if (!this.registration) {
this.log.warn( this.log.warn(
"received session lock request without a valid platform registration", "received session lock request without a valid platform registration",
@ -240,7 +241,37 @@ class GadgetDrone extends GadgetProcess {
return cb(false, "invalid registration"); return cb(false, "invalid registration");
} }
this.workspaceMode = WorkspaceMode.User; /*
* Check if the chat session lock is already held.
*/
if (this.chatSession) {
if (chatSession._id !== this.chatSession._id) {
this.log.warn("rejecting session lock request", {
chatSession: {
_id: this.chatSession._id,
name: this.chatSession.name,
},
workspaceMode: this.workspaceMode,
});
return cb(false, this.chatSession._id);
}
// fall through to grant this lock request
this.log.info("chat session is re-connecting to session lock");
}
/*
* Grant the chat session lock.
*/
this.log.info("granting session lock", {
registrationId: registration._id,
project: { _id: project._id, slug: project.slug },
chatSession: { _id: chatSession._id, name: chatSession.name },
});
this.chatSession = chatSession;
this.workspaceMode = WorkspaceMode.Idle;
cb(true, chatSession._id); cb(true, chatSession._id);
} }
@ -251,6 +282,16 @@ class GadgetDrone extends GadgetProcess {
mode: WorkspaceMode, mode: WorkspaceMode,
cb: RequestWorkspaceModeCallback, cb: RequestWorkspaceModeCallback,
) { ) {
if (!this.chatSession) {
return cb(false, this.workspaceMode);
}
if (chatSession._id !== this.chatSession._id) {
this.log.warn("rejecting workspace mode request", {
chatSession: { _id: chatSession._id, name: chatSession.name },
});
return cb(false, this.workspaceMode);
}
this.log.info("requestWorkspaceMode received", { this.log.info("requestWorkspaceMode received", {
registration, registration,
project, project,
@ -270,16 +311,26 @@ class GadgetDrone extends GadgetProcess {
turn: IChatTurn, turn: IChatTurn,
cb: ProcessWorkOrderCallback, cb: ProcessWorkOrderCallback,
) { ) {
if (!this.chatSession) {
return cb(false, "this drone is not locked to a chat session");
}
if (this.chatSession._id !== chatSession._id) {
return cb(false, "this drone is not locked to your chat session");
}
if (this.workspaceMode !== WorkspaceMode.Agent) {
return cb(false, "this drone's workspace is not in Agent mode");
}
const order: IAgentWorkOrder = { const order: IAgentWorkOrder = {
createdAt: turn.createdAt, createdAt: turn.createdAt,
context: [], context: [],
turn, turn,
}; };
this.log.info("processWorkOrder received", { this.log.info("processWorkOrder received", {
registration, registration: { _id: registration._id },
project, project: { _id: project._id, name: project.name },
chatSession, chatSession: { _id: chatSession._id, name: chatSession.name },
turn, turn: { _id: turn._id, mode: turn.mode, userPrompt: turn.prompts.user },
}); });
if (!this.socket) { if (!this.socket) {
@ -290,12 +341,7 @@ class GadgetDrone extends GadgetProcess {
// Write work order cache BEFORE processing (for crash recovery) // Write work order cache BEFORE processing (for crash recovery)
try { try {
await WorkspaceService.writeWorkOrderCache( await WorkspaceService.writeWorkOrderCache(turn);
turn._id,
chatSession._id,
project._id,
turn.prompts.user,
);
} catch (error) { } catch (error) {
const err = error as Error; const err = error as Error;
this.log.error("failed to write work order cache", { this.log.error("failed to write work order cache", {
@ -304,20 +350,18 @@ class GadgetDrone extends GadgetProcess {
// Continue anyway - cache is for recovery, not required // Continue anyway - cache is for recovery, not required
} }
cb(true); // the drone accepts the work order cb(true, "work order accepted"); // confirm that drone has the work order
AgentService.process(order, this.socket) try {
.then(async () => { await AgentService.process(order, this.socket);
// Remove cache after successful completion await WorkspaceService.removeWorkOrderCache();
await WorkspaceService.removeWorkOrderCache(); } catch (error) {
}) const err = error as Error;
.catch(async (error) => { this.log.error("work order processing failed", {
const err = error as Error; error: err.message,
this.log.error("work order processing failed", {
error: err.message,
});
// Leave cache in place for recovery
}); });
// Leave cache in place for recovery
}
} }
hookProcessSignals(): void { hookProcessSignals(): void {
@ -378,10 +422,10 @@ class GadgetDrone extends GadgetProcess {
return; return;
} }
const chatSession = cache.turn.session as IChatSession;
this.log.warn("incomplete work order found - initiating crash recovery", { this.log.warn("incomplete work order found - initiating crash recovery", {
turnId: cache.turnId, turnId: cache.turn._id,
chatSessionId: cache.chatSessionId, chatSessionId: chatSession._id,
workOrderId: cache.workOrderId,
}); });
if (!this.socket) { if (!this.socket) {
@ -392,8 +436,8 @@ class GadgetDrone extends GadgetProcess {
// Notify web service that this workspace has pending recovery // Notify web service that this workspace has pending recovery
this.socket.emit("requestCrashRecovery", { this.socket.emit("requestCrashRecovery", {
workspaceId: WorkspaceService.workspaceId!, workspaceId: WorkspaceService.workspaceId!,
turnId: cache.turnId, turnId: cache.turn._id,
chatSessionId: cache.chatSessionId, chatSessionId: chatSession._id,
}); });
} }

View File

@ -6,6 +6,7 @@ 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 { GadgetService } from "../lib/service.ts"; import { GadgetService } from "../lib/service.ts";
import { IChatTurn } from "@gadget/api";
export interface WorkspaceData { export interface WorkspaceData {
workspaceId: string; // UUID v4, immutable once created workspaceId: string; // UUID v4, immutable once created
@ -46,12 +47,9 @@ export interface WorkspaceData {
} }
export interface WorkOrderCache { export interface WorkOrderCache {
turnId: string; // ChatTurn._id for persistence updates id: string;
chatSessionId: string; // For routing events back to IDE
projectId: string; // For file operations
workOrderId: string; // Unique ID for this work order instance
receivedAt: string; // ISO 8601 timestamp receivedAt: string; // ISO 8601 timestamp
prompt: string; // User's prompt (for retry context) turn: IChatTurn;
status: "processing" | "completed" | "error"; status: "processing" | "completed" | "error";
error?: string; // Error message if status === 'error' error?: string; // Error message if status === 'error'
} }
@ -111,9 +109,7 @@ class WorkspaceService extends GadgetService {
/** /**
* Loads existing workspace data or creates new workspace. * Loads existing workspace data or creates new workspace.
*/ */
private async loadOrCreateWorkspaceData( private async loadOrCreateWorkspaceData(workspaceDir: string): Promise<void> {
workspaceDir: string,
): Promise<void> {
try { try {
if (await this.fileExists(this.workspaceFile)) { if (await this.fileExists(this.workspaceFile)) {
// Load existing workspace // Load existing workspace
@ -143,9 +139,7 @@ class WorkspaceService extends GadgetService {
} catch (error) { } catch (error) {
const err = error as Error; const err = error as Error;
this.log.error("failed to load workspace data", { error: err.message }); this.log.error("failed to load workspace data", { error: err.message });
throw new Error( throw new Error(`Failed to initialize workspace: ${err.message}`);
`Failed to initialize workspace: ${err.message}`,
);
} }
} }
@ -166,9 +160,7 @@ class WorkspaceService extends GadgetService {
/** /**
* Updates the chat session in workspace data. * Updates the chat session in workspace data.
*/ */
updateChatSession( updateChatSession(chatSession: { _id: string; name: string } | null): void {
chatSession: { _id: string; name: string } | null,
): void {
if (!this._workspaceData) return; if (!this._workspaceData) return;
this._workspaceData.chatSession = chatSession this._workspaceData.chatSession = chatSession
@ -198,11 +190,7 @@ class WorkspaceService extends GadgetService {
/** /**
* Adds a project to the workspace projects list. * Adds a project to the workspace projects list.
*/ */
addProject(project: { addProject(project: { _id: string; slug: string; gitUrl: string }): void {
_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(
@ -220,10 +208,12 @@ class WorkspaceService extends GadgetService {
/** /**
* Updates the registration in workspace data. * Updates the registration in workspace data.
*/ */
updateRegistration(registration: { updateRegistration(
_id: string; registration: {
status: string; _id: string;
} | null): void { status: string;
} | null,
): void {
if (!this._workspaceData) return; if (!this._workspaceData) return;
this._workspaceData.registration = registration this._workspaceData.registration = registration
@ -237,23 +227,15 @@ class WorkspaceService extends GadgetService {
/** /**
* Writes a work order cache file before processing. * Writes a work order cache file before processing.
*/ */
async writeWorkOrderCache( async writeWorkOrderCache(turn: IChatTurn): Promise<string> {
turnId: string,
chatSessionId: string,
projectId: string,
prompt: string,
): Promise<string> {
const crypto = await import("node:crypto"); const crypto = await import("node:crypto");
const workOrderId = crypto.randomUUID(); const workOrderId = crypto.randomUUID();
const cacheFile = path.join(this.cacheDir, "work-order.json"); const cacheFile = path.join(this.cacheDir, "work-order.json");
const cache: WorkOrderCache = { const cache: WorkOrderCache = {
turnId, id: workOrderId,
chatSessionId,
projectId,
workOrderId,
receivedAt: new Date().toISOString(), receivedAt: new Date().toISOString(),
prompt, turn,
status: "processing", status: "processing",
}; };
@ -262,10 +244,9 @@ class WorkspaceService extends GadgetService {
JSON.stringify(cache, null, 2), JSON.stringify(cache, null, 2),
"utf-8", "utf-8",
); );
this.log.info("work order cache written", { this.log.info("work order cache written", {
workOrderId, id: workOrderId,
turnId, turnId: turn._id,
}); });
return workOrderId; return workOrderId;

View File

@ -1,8 +1,11 @@
{ {
"folders": [ "folders": [
{ {
"path": "." "path": ".",
} },
], {
"settings": {} "path": "../../workspaces",
},
],
"settings": {},
} }