move from Types.ObjectId to GadgetId (a string)

This commit is contained in:
Rob Colbert 2026-05-01 14:31:00 -04:00
parent 50b9618d4e
commit 404532012e
54 changed files with 877 additions and 813 deletions

View File

@ -7,8 +7,9 @@ Fixed critical bugs in the Gadget Code socket messaging system that prevented me
## Problems Identified
### 1. **Critical Bug: Socket Session Lookup Failure**
- **Location**: `gadget-code/src/services/socket.ts`
- **Issue**: `getDroneSession(registration)` looked up sessions using `registration._id.toHexString()` as the key, but sessions were stored with `socket.id` as the key
- **Issue**: `getDroneSession(registration)` looked up sessions using `registration._id` as the key, but sessions were stored with `socket.id` as the key
- **Impact**: ALL drone messaging was broken:
- `requestSessionLock` - couldn't lock drones
- `submitPrompt` - couldn't submit work orders
@ -16,11 +17,13 @@ Fixed critical bugs in the Gadget Code socket messaging system that prevented me
- **Same issue existed for**: `getCodeSession(user)` looking up by `user._id` but storing by `socket.id`
### 2. **Missing requestTermination Handler**
- **Location**: `gadget-code/src/lib/drone-session.ts`
- **Issue**: No handler registered for `requestTermination` event
- **Impact**: Even if session lookup worked, termination messages wouldn't be forwarded to drones
### 3. **No Test Coverage**
- **Issue**: Zero tests for socket session management or termination flow
- **Impact**: Bugs went undetected, no way to verify fixes
@ -31,6 +34,7 @@ Fixed critical bugs in the Gadget Code socket messaging system that prevented me
**File**: `gadget-code/src/services/socket.ts`
Added dual-index architecture:
```typescript
// Primary storage by socket.id
private droneSessions: DroneSessionMap = new Map<string, DroneSession>();
@ -42,35 +46,38 @@ private codeSessionUserIndex: CodeSessionMap = new Map<string, CodeSession>();
```
Updated `onSocketAuth()` to populate both indexes:
```typescript
// For drones
this.droneSessions.set(socket.id, droneSession);
this.droneRegistrationIndex.set(registration._id.toHexString(), droneSession);
this.droneRegistrationIndex.set(registration._id, droneSession);
// For code/IDE sessions
this.codeSessions.set(socket.id, session);
this.codeSessionUserIndex.set(user._id.toHexString(), session);
this.codeSessionUserIndex.set(user._id, session);
```
Updated `onSocketDisconnect()` to clean up both indexes:
```typescript
case SocketSessionType.Drone:
const droneSession = this.droneSessions.get(socket.id);
if (droneSession) {
this.droneRegistrationIndex.delete(droneSession.registration._id.toHexString());
this.droneRegistrationIndex.delete(droneSession.registration._id);
}
this.droneSessions.delete(socket.id);
```
Updated lookup methods to use correct indexes:
```typescript
getDroneSession(registration: IDroneRegistration): DroneSession {
const session = this.droneRegistrationIndex.get(registration._id.toHexString());
const session = this.droneRegistrationIndex.get(registration._id);
// ... error handling
}
getCodeSession(ideSession: IIdeSession): CodeSession {
const session = this.codeSessionUserIndex.get(ideSession._id.toHexString());
const session = this.codeSessionUserIndex.get(ideSession._id);
// ... error handling
}
```
@ -80,6 +87,7 @@ getCodeSession(ideSession: IIdeSession): CodeSession {
**File**: `gadget-code/src/lib/drone-session.ts`
Added handler registration:
```typescript
register() {
super.register();
@ -93,10 +101,11 @@ register() {
```
Added handler implementation:
```typescript
async onRequestTermination(cb: (success: boolean) => void): Promise<void> {
this.log.info("requestTermination received, forwarding to drone", {
registrationId: this.registration._id.toHexString(),
registrationId: this.registration._id,
});
this.socket.emit("requestTermination", (success: boolean) => {
@ -111,17 +120,20 @@ async onRequestTermination(cb: (success: boolean) => void): Promise<void> {
Created new test files and utilities:
**Test Utilities**:
- `tests/helpers/socket-test-helpers.ts` - Mock factories and utilities
- `tests/fixtures/index.ts` - Export helpers for easy import
**New Test Files**:
- `tests/socket-service.test.ts` - 12 tests for session indexing
- `tests/drone-service.test.ts` - 6 tests for termination flow
- `tests/drone-session.test.ts` - 2 new tests for requestTermination handler
**Test Coverage**:
- ✅ Drone session storage and lookup by registration._id
- ✅ Code session storage and lookup by user._id
- ✅ Drone session storage and lookup by registration.\_id
- ✅ Code session storage and lookup by user.\_id
- ✅ Chat session index operations
- ✅ Session cleanup on disconnect
- ✅ requestTermination handler registration
@ -137,6 +149,7 @@ Created new test files and utilities:
**File**: `docs/socket-protocol.md`
Added:
- `requestTermination` to event maps (both directions)
- Complete drone termination flow sequence (Section 3.4)
- Message signatures for termination
@ -148,6 +161,7 @@ Added:
**File**: `scripts/seed-socket-test-data.ts`
Created script to seed test data:
- Test user account
- Test AI provider
- Test project (unique per run)
@ -187,11 +201,13 @@ IDE (updates UI)
## Files Changed
### Core Implementation
- `gadget-code/src/services/socket.ts` - Dual-index architecture
- `gadget-code/src/lib/drone-session.ts` - requestTermination handler
- `gadget-code/src/services/drone.ts` - No changes (already correct)
### Tests
- `tests/helpers/socket-test-helpers.ts` - NEW
- `tests/fixtures/index.ts` - NEW
- `tests/socket-service.test.ts` - NEW (12 tests)
@ -199,9 +215,11 @@ IDE (updates UI)
- `tests/drone-session.test.ts` - MODIFIED (+2 tests)
### Documentation
- `docs/socket-protocol.md` - Updated with termination flow and indexing
### Scripts
- `scripts/seed-socket-test-data.ts` - NEW
## Test Results
@ -241,6 +259,7 @@ Failed: tests/app.test.ts - Frontend build warning (unrelated)
## Conclusion
The socket messaging system is now rock-solid with:
- ✅ Correct session indexing and lookup
- ✅ Complete test coverage (67 tests)
- ✅ Proper error handling

View File

@ -51,6 +51,7 @@
"mongoose": "^8.16.1",
"morgan": "^1.10.0",
"multer": "^2.0.1",
"nanoid": "^5.1.11",
"nodemailer": "^7.0.3",
"numeral": "^2.0.6",
"pug": "^3.0.3",

View File

@ -5,31 +5,31 @@
/**
* Seeds the database with test data for socket messaging tests.
*
*
* This script creates:
* - Test user (if not exists)
* - Test project (unique per run)
* - Test chat session (unique per run)
* - Test drone registrations (unique per run)
*
*
* IMPORTANT: This script does NOT delete existing data. It only creates new,
* unique records. Tests are responsible for cleaning up their own data.
*
*
* Usage:
* npx tsx scripts/seed-socket-test-data.ts
*
*
* Output:
* JSON object with created resource IDs for test cleanup
*/
import mongoose from 'mongoose';
import { config } from 'dotenv';
import { IUser } from '@gadget/api';
import User from '../src/models/user';
import Project from '../src/models/project';
import ChatSession from '../src/models/chat-session';
import DroneRegistration from '../src/models/drone-registration';
import { AiProvider } from '../src/models/ai-provider';
import mongoose from "mongoose";
import { config } from "dotenv";
import { IUser } from "@gadget/api";
import User from "../src/models/user";
import Project from "../src/models/project";
import ChatSession from "../src/models/chat-session";
import DroneRegistration from "../src/models/drone-registration";
import { AiProvider } from "../src/models/ai-provider";
config();
@ -45,57 +45,61 @@ interface SeedResult {
async function seedSocketTestData(): Promise<SeedResult> {
const NOW = new Date();
const timestamp = NOW.toISOString().replace(/[:.]/g, '-');
const timestamp = NOW.toISOString().replace(/[:.]/g, "-");
try {
// Connect to database
await mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/gadget-code');
console.log('Connected to MongoDB');
await mongoose.connect(
process.env.MONGODB_URI || "mongodb://localhost:27017/gadget-code",
);
console.log("Connected to MongoDB");
// Find or create test user
let user = await User.findOne({ email: 'test-socket@gadget-code.test' });
let user = await User.findOne({ email: "test-socket@gadget-code.test" });
if (!user) {
user = new User({
email: 'test-socket@gadget-code.test',
displayName: 'Socket Test User',
passwordSalt: 'test-salt',
passwordHash: 'test-hash',
email: "test-socket@gadget-code.test",
displayName: "Socket Test User",
passwordSalt: "test-salt",
passwordHash: "test-hash",
banned: false,
admin: false,
createdAt: NOW,
updatedAt: NOW,
});
await user.save();
console.log('Created test user:', user._id);
console.log("Created test user:", user._id);
} else {
console.log('Found existing test user:', user._id);
console.log("Found existing test user:", user._id);
}
// Find or create a test AI provider
let provider = await AiProvider.findOne({ name: 'Test Socket Provider' });
let provider = await AiProvider.findOne({ name: "Test Socket Provider" });
if (!provider) {
provider = new AiProvider({
name: 'Test Socket Provider',
apiType: 'ollama',
baseUrl: 'http://localhost:11434',
name: "Test Socket Provider",
apiType: "ollama",
baseUrl: "http://localhost:11434",
enabled: true,
models: [{
id: 'llama3.2',
name: 'Llama 3.2',
capabilities: {
canCallTools: true,
hasVision: false,
hasEmbedding: false,
hasThinking: false,
isInstructTuned: true,
models: [
{
id: "llama3.2",
name: "Llama 3.2",
capabilities: {
canCallTools: true,
hasVision: false,
hasEmbedding: false,
hasThinking: false,
isInstructTuned: true,
},
},
}],
],
lastModelRefresh: NOW,
});
await provider.save();
console.log('Created test provider:', provider._id);
console.log("Created test provider:", provider._id);
} else {
console.log('Found existing test provider:', provider._id);
console.log("Found existing test provider:", provider._id);
}
// Create test project (unique per run)
@ -103,20 +107,20 @@ async function seedSocketTestData(): Promise<SeedResult> {
user: user._id,
slug: `socket-test-${timestamp}`,
name: `Socket Test Project ${timestamp}`,
gitUrl: 'https://github.com/test/socket-test.git',
gitUrl: "https://github.com/test/socket-test.git",
createdAt: NOW,
updatedAt: NOW,
});
await project.save();
console.log('Created test project:', project._id);
console.log("Created test project:", project._id);
// Create test chat session (unique per run)
const chatSession = new ChatSession({
user: user._id,
project: project._id,
provider: provider._id,
selectedModel: 'llama3.2',
mode: 'build',
selectedModel: "llama3.2",
mode: "build",
name: `Socket Test Session ${timestamp}`,
stats: {
toolCallCount: 0,
@ -127,46 +131,46 @@ async function seedSocketTestData(): Promise<SeedResult> {
updatedAt: NOW,
});
await chatSession.save();
console.log('Created test chat session:', chatSession._id);
console.log("Created test chat session:", chatSession._id);
// Create test drone registrations (unique per run)
const droneCount = 3;
const droneIds: string[] = [];
for (let i = 0; i < droneCount; i++) {
const drone = new DroneRegistration({
user: user._id,
hostname: `test-drone-${timestamp}-${i}`,
workspaceDir: `/tmp/socket-test-${timestamp}-${i}`,
status: 'available',
status: "available",
createdAt: NOW,
updatedAt: NOW,
});
await drone.save();
droneIds.push(drone._id.toHexString());
droneIds.push(drone._id);
console.log(`Created test drone ${i + 1}/${droneCount}:`, drone._id);
}
const result: SeedResult = {
userId: user._id.toHexString(),
projectId: project._id.toHexString(),
chatSessionId: chatSession._id.toHexString(),
userId: user._id,
projectId: project._id,
chatSessionId: chatSession._id,
droneIds,
providerId: provider._id.toHexString(),
providerId: provider._id,
createdAt: NOW.toISOString(),
note: 'TEST DATA - Safe to delete. Created for socket messaging tests.',
note: "TEST DATA - Safe to delete. Created for socket messaging tests.",
};
console.log('\n✅ Seed complete! Save this output for test cleanup:');
console.log("\n✅ Seed complete! Save this output for test cleanup:");
console.log(JSON.stringify(result, null, 2));
return result;
} catch (error) {
console.error('❌ Seed failed:', error);
console.error("❌ Seed failed:", error);
process.exit(1);
} finally {
await mongoose.disconnect();
console.log('\nDisconnected from MongoDB');
console.log("\nDisconnected from MongoDB");
}
}
@ -176,6 +180,6 @@ seedSocketTestData()
process.exit(0);
})
.catch((error) => {
console.error('Unhandled error:', error);
console.error("Unhandled error:", error);
process.exit(1);
});

View File

@ -24,8 +24,16 @@ class ChatSessionController extends DtpController {
this.router.post("/", this.requireUser(), this.createSession.bind(this));
this.router.get("/:id", this.requireUser(), this.getSession.bind(this));
this.router.put("/:id", this.requireUser(), this.updateSession.bind(this));
this.router.delete("/:id", this.requireUser(), this.deleteSession.bind(this));
this.router.get("/:id/turns", this.requireUser(), this.getSessionTurns.bind(this));
this.router.delete(
"/:id",
this.requireUser(),
this.deleteSession.bind(this),
);
this.router.get(
"/:id/turns",
this.requireUser(),
this.getSessionTurns.bind(this),
);
}
/**
@ -46,7 +54,7 @@ class ChatSessionController extends DtpController {
if (projectId) {
sessions = await ChatSessionService.getByProject(projectId);
} else {
sessions = await ChatSessionService.getByUser(user._id.toHexString());
sessions = await ChatSessionService.getByUser(user._id);
}
res.json({
@ -101,12 +109,12 @@ class ChatSessionController extends DtpController {
return;
}
const sessionMode = mode
const sessionMode = mode
? ChatSessionMode[mode as keyof typeof ChatSessionMode]
: ChatSessionMode.Build;
const session = await ChatSessionService.create(
user._id.toHexString(),
user._id,
projectId,
providerId,
selectedModel,
@ -134,7 +142,9 @@ class ChatSessionController extends DtpController {
*/
private async getSession(req: Request, res: Response): Promise<void> {
try {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const id = Array.isArray(req.params.id)
? req.params.id[0]
: req.params.id;
if (!id) {
res.status(400).json({
success: false,
@ -152,7 +162,7 @@ class ChatSessionController extends DtpController {
} catch (error) {
const err = error as Error;
this.log.error("failed to get chat session", { error: err.message });
if (err.message.includes("not found")) {
res.status(404).json({
success: false,
@ -173,7 +183,9 @@ class ChatSessionController extends DtpController {
*/
private async updateSession(req: Request, res: Response): Promise<void> {
try {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const id = Array.isArray(req.params.id)
? req.params.id[0]
: req.params.id;
if (!id) {
res.status(400).json({
success: false,
@ -201,7 +213,8 @@ class ChatSessionController extends DtpController {
allowedUpdates.selectedModel = updates.selectedModel;
}
if (updates.mode !== undefined) {
allowedUpdates.mode = ChatSessionMode[updates.mode as keyof typeof ChatSessionMode];
allowedUpdates.mode =
ChatSessionMode[updates.mode as keyof typeof ChatSessionMode];
}
const session = await ChatSessionService.update(id, allowedUpdates);
@ -213,7 +226,7 @@ class ChatSessionController extends DtpController {
} catch (error) {
const err = error as Error;
this.log.error("failed to update chat session", { error: err.message });
if (err.message.includes("not found")) {
res.status(404).json({
success: false,
@ -234,7 +247,9 @@ class ChatSessionController extends DtpController {
*/
private async deleteSession(req: Request, res: Response): Promise<void> {
try {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const id = Array.isArray(req.params.id)
? req.params.id[0]
: req.params.id;
if (!id) {
res.status(400).json({
success: false,
@ -252,7 +267,7 @@ class ChatSessionController extends DtpController {
} catch (error) {
const err = error as Error;
this.log.error("failed to delete chat session", { error: err.message });
if (err.message.includes("not found")) {
res.status(404).json({
success: false,
@ -273,7 +288,9 @@ class ChatSessionController extends DtpController {
*/
private async getSessionTurns(req: Request, res: Response): Promise<void> {
try {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const id = Array.isArray(req.params.id)
? req.params.id[0]
: req.params.id;
if (!id) {
res.status(400).json({
success: false,
@ -290,7 +307,9 @@ class ChatSessionController extends DtpController {
});
} catch (error) {
const err = error as Error;
this.log.error("failed to get chat session turns", { error: err.message });
this.log.error("failed to get chat session turns", {
error: err.message,
});
res.status(500).json({
success: false,
message: err.message,

View File

@ -3,8 +3,6 @@
// All Rights Reserved
import { Request, Response } from "express";
import { Types } from "mongoose";
import { DroneStatus } from "@gadget/api";
import DroneService from "../../../services/drone.ts";
import UserService from "../../../services/user.ts";
@ -94,7 +92,7 @@ export class DroneApiControllerV1 extends DtpController {
res.status(200).json({ success: true });
} catch (error) {
this.log.error("failed to update drone status", {
_id: res.locals.registration._id.toHexString(),
_id: res.locals.registration._id,
error,
});
res.status((error as Error).statusCode || 500).json({
@ -145,10 +143,9 @@ export class DroneApiControllerV1 extends DtpController {
async postTerminate(req: Request, res: Response): Promise<void> {
try {
const registrationId = Types.ObjectId.createFromHexString(
const result = await DroneService.requestTermination(
req.params.registrationId as string,
);
const result = await DroneService.requestTermination(registrationId);
res.status(200).json({
success: result.success,
message: result.message,

View File

@ -5,7 +5,7 @@
import { Request, Response } from "express";
import { DtpController } from "../../../lib/controller.js";
import { ProjectStatus } from "@gadget/api";
import { IUser, ProjectStatus } from "@gadget/api";
import projectService from "../../../services/project.js";
export class ProjectApiControllerV1 extends DtpController {
@ -93,7 +93,8 @@ export class ProjectApiControllerV1 extends DtpController {
return;
}
if (!project.user._id.equals(req.user._id)) {
const user = project.user as IUser;
if (user._id !== req.user._id) {
res.status(403).json({
success: false,
message: "access denied",
@ -126,7 +127,8 @@ export class ProjectApiControllerV1 extends DtpController {
return;
}
if (!project.user._id.equals(req.user._id)) {
const user = project.user as IUser;
if (user._id !== req.user._id) {
res.status(403).json({
success: false,
message: "access denied",
@ -167,7 +169,8 @@ export class ProjectApiControllerV1 extends DtpController {
return;
}
if (!project.user._id.equals(req.user._id)) {
const user = project.user as IUser;
if (user._id !== req.user._id) {
res.status(403).json({
success: false,
message: "access denied",

View File

@ -4,8 +4,6 @@
import assert from "node:assert";
import { Types } from "mongoose";
import { NextFunction, Request, RequestHandler, Response } from "express";
import { DtpController } from "../../lib/controller.ts";
@ -29,8 +27,7 @@ export function populateUserById(
): Promise<void> {
assert(userId, "User ID is required");
try {
const userIdObj = Types.ObjectId.createFromHexString(userId);
res.locals.userAccount = await UserService.getById(userIdObj);
res.locals.userAccount = await UserService.getById(userId);
if (options.requireObject && !res.locals.user) {
const error = new Error("User not found");
error.statusCode = 404;
@ -60,9 +57,7 @@ export function populateDroneRegistrationById(
): Promise<void> {
assert(registrationId, "Drone registration ID is required");
try {
const registrationIdObj =
Types.ObjectId.createFromHexString(registrationId);
res.locals.registration = await DroneService.getById(registrationIdObj);
res.locals.registration = await DroneService.getById(registrationId);
return next();
} catch (error) {
controller.log.error("failed to populate User by ID", {

View File

@ -2,7 +2,6 @@
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
// All Rights Reserved
import { Types } from "mongoose";
import {
GadgetSocket,
SocketSession,
@ -14,6 +13,7 @@ import {
IProject,
IUser,
ChatTurnStatus,
GadgetId,
} from "@gadget/api";
import SocketService from "../services/socket.ts";
@ -25,7 +25,7 @@ export class CodeSession extends SocketSession {
protected project: IProject | undefined;
protected chatSession: IChatSession | undefined;
protected selectedDrone: IDroneRegistration | undefined;
protected currentTurnId: Types.ObjectId | undefined;
protected currentTurnId: GadgetId | undefined;
constructor(socket: GadgetSocket, user: IUser) {
super(socket, user);
@ -48,10 +48,7 @@ export class CodeSession extends SocketSession {
/**
* Sets the active chat session and project for this code session.
*/
setChatSession(
chatSession: IChatSession,
project: IProject,
): void {
setChatSession(chatSession: IChatSession, project: IProject): void {
this.chatSession = chatSession;
this.project = project;
}
@ -82,7 +79,7 @@ export class CodeSession extends SocketSession {
this.selectedDrone = registration;
this.chatSession = chatSession;
this.project = project;
SocketService.registerChatSession(chatSession._id.toHexString(), this);
SocketService.registerChatSession(chatSession._id, this);
droneSession.setChatSessionId(chatSession._id);
}
cb(success, chatSessionId);
@ -138,8 +135,8 @@ export class CodeSession extends SocketSession {
this.currentTurnId = turn._id;
this.log.info("ChatTurn created", {
turnId: turn._id.toHexString(),
chatSessionId: this.chatSession._id.toHexString(),
turnId: turn._id,
chatSessionId: this.chatSession._id,
});
droneSession.setCurrentTurnId(turn._id);
@ -153,12 +150,12 @@ export class CodeSession extends SocketSession {
(success: boolean, message?: string) => {
if (success) {
this.log.info("work order accepted by drone", {
turnId: turn._id.toHexString(),
turnId: turn._id,
message,
});
} else {
this.log.error("work order rejected by drone", {
turnId: turn._id.toHexString(),
turnId: turn._id,
message,
});
turn.status = ChatTurnStatus.Error;

View File

@ -8,7 +8,6 @@ import path from "node:path";
import { v4 as uuidv4 } from "uuid";
import { rateLimit } from "express-rate-limit";
import { Types } from "mongoose";
import {
Router,
Request,
@ -29,7 +28,7 @@ export interface CsrfTokenOptions {
import { ApiClientStatus } from "../models/api-client.js";
import { CsrfToken, ICsrfToken } from "../models/csrf-token.js";
import { IUser } from "@gadget/api";
import { GadgetId, IUser } from "@gadget/api";
import { DtpComponent } from "./component.js";
import { DtpPaginationParameters } from "./pagination-parameters.js";
@ -97,10 +96,7 @@ export abstract class DtpController implements DtpComponent {
return;
}
const apiClientIdObj = Types.ObjectId.createFromHexString(
apiClientId as string,
);
const apiClient = await ApiClientService.getById(apiClientIdObj);
const apiClient = await ApiClientService.getById(apiClientId as GadgetId);
if (!apiClient) {
this.log.error("API client not found", { _id: apiClientId });
res.status(404).json({

View File

@ -2,8 +2,12 @@
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
// All Rights Reserved
import { Types } from "mongoose";
import { IUser, IDroneRegistration, ChatTurnStatus } from "@gadget/api";
import {
IUser,
IDroneRegistration,
ChatTurnStatus,
GadgetId,
} from "@gadget/api";
import {
GadgetSocket,
SocketSession,
@ -15,8 +19,8 @@ import { ChatTurn } from "../models/chat-turn";
export class DroneSession extends SocketSession {
protected type: SocketSessionType = SocketSessionType.Drone;
registration: IDroneRegistration;
chatSessionId: Types.ObjectId | undefined;
currentTurnId: Types.ObjectId | undefined;
chatSessionId: GadgetId | undefined;
currentTurnId: GadgetId | undefined;
constructor(socket: GadgetSocket, registration: IDroneRegistration) {
super(socket, registration.user as IUser);
@ -30,7 +34,10 @@ export class DroneSession extends SocketSession {
this.socket.on("response", this.onResponse.bind(this));
this.socket.on("toolCall", this.onToolCall.bind(this));
this.socket.on("workOrderComplete", this.onWorkOrderComplete.bind(this));
this.socket.on("requestCrashRecovery", this.onRequestCrashRecovery.bind(this));
this.socket.on(
"requestCrashRecovery",
this.onRequestCrashRecovery.bind(this),
);
this.socket.on("requestTermination", this.onRequestTermination.bind(this));
}
@ -131,7 +138,9 @@ export class DroneSession extends SocketSession {
message?: string,
): Promise<void> {
if (!this.chatSessionId) {
this.log.warn("workOrderComplete event received but no chat session is active");
this.log.warn(
"workOrderComplete event received but no chat session is active",
);
return;
}
@ -159,14 +168,14 @@ export class DroneSession extends SocketSession {
/**
* Sets the active chat session ID for this drone session.
*/
setChatSessionId(chatSessionId: Types.ObjectId): void {
setChatSessionId(chatSessionId: GadgetId): void {
this.chatSessionId = chatSessionId;
}
/**
* Sets the current turn ID being processed by this drone.
*/
setCurrentTurnId(turnId: Types.ObjectId): void {
setCurrentTurnId(turnId: GadgetId): void {
this.currentTurnId = turnId;
}
@ -256,7 +265,7 @@ export class DroneSession extends SocketSession {
*/
async onRequestTermination(cb: (success: boolean) => void): Promise<void> {
this.log.info("requestTermination received, forwarding to drone", {
registrationId: this.registration._id.toHexString(),
registrationId: this.registration._id,
});
this.socket.emit("requestTermination", (success: boolean) => {

View File

@ -3,6 +3,7 @@
// All Rights Reserved
import { Schema, model } from "mongoose";
import { nanoid } from "nanoid";
import {
IAiModel,
@ -58,6 +59,7 @@ export const AiModelSchema = new Schema<IAiModel>(
);
export const AiProviderSchema = new Schema<IAiProvider>({
_id: { type: String, required: true, default: nanoid, unique: true },
name: { type: String, required: true },
apiType: { type: String, enum: ["ollama", "openai"], required: true },
baseUrl: { type: String, required: true },

View File

@ -2,24 +2,22 @@
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
// All Rights Reserved
import { Types, Schema, Document, model } from "mongoose";
import { Schema, model } from "mongoose";
import { DtpLog } from "../lib/log.js";
import { IApiClient } from "./api-client.js";
const log = new DtpLog({
name: "ApiClientModel",
slug: "apiClient",
});
import { GadgetId } from "@gadget/api";
import { nanoid } from "nanoid";
export interface IApiClientLog extends Document {
_id: Types.ObjectId;
client: IApiClient | Types.ObjectId;
export interface IApiClientLog {
_id: GadgetId;
client: IApiClient | GadgetId;
createdAt: Date;
method: string;
url: string;
}
export const ApiClientLogSchema = new Schema<IApiClientLog>({
client: { type: Types.ObjectId, required: true, index: 1, ref: "ApiClient" },
_id: { type: String, default: nanoid, required: true, unique: true },
client: { type: String, required: true, index: 1, ref: "ApiClient" },
createdAt: {
type: Date,
default: Date.now,
@ -33,11 +31,10 @@ export const ApiClientLogSchema = new Schema<IApiClientLog>({
export const ApiClientLog = model<IApiClientLog>(
"ApiClientLog",
ApiClientLogSchema
ApiClientLogSchema,
);
export default ApiClientLog;
(async () => {
log.info("Syncing indexes...");
await ApiClientLog.syncIndexes();
})();

View File

@ -2,13 +2,10 @@
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
// All Rights Reserved
import { Types, Schema, Document, model } from "mongoose";
import { Schema, model } from "mongoose";
import { DtpLog } from "../lib/log.js";
const log = new DtpLog({
name: "ApiClientModel",
slug: "apiClient",
});
import { GadgetId } from "@gadget/api";
import { nanoid } from "nanoid";
export enum ApiClientStatus {
Active = "active",
@ -16,8 +13,8 @@ export enum ApiClientStatus {
Archived = "archived",
}
export interface IApiClient extends Document {
_id: Types.ObjectId;
export interface IApiClient {
_id: GadgetId;
createdAt: Date;
updatedAt: Date;
status: ApiClientStatus;
@ -26,6 +23,7 @@ export interface IApiClient extends Document {
secret: string;
}
const ApiClientSchema = new Schema<IApiClient>({
_id: { type: String, default: nanoid, required: true, unique: true },
createdAt: { type: Date, required: true },
updatedAt: { type: Date, required: true },
status: {
@ -44,6 +42,5 @@ export const ApiClient = model<IApiClient>("ApiClient", ApiClientSchema);
export default ApiClient;
(async () => {
log.info("Syncing indexes...");
await ApiClient.syncIndexes();
})();

View File

@ -2,18 +2,20 @@
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
// All Rights Reserved
import { Types, Schema, model } from "mongoose";
import { Schema, model } from "mongoose";
import { ChatSessionMode, IChatSession, IChatSessionPin } from "@gadget/api";
import { nanoid } from "nanoid";
export const ChatSessionPinSchema = new Schema<IChatSessionPin>({
content: { type: String, required: true },
});
export const ChatSessionSchema = new Schema<IChatSession>({
_id: { type: String, default: nanoid, required: true, unique: true },
createdAt: { type: Date, default: Date.now, required: true },
lastMessageAt: { type: Date },
user: { type: Types.ObjectId, required: true, index: 1, ref: "User" },
project: { type: Types.ObjectId, required: false, index: 1, ref: "Project" },
user: { type: String, required: true, index: 1, ref: "User" },
project: { type: String, required: false, index: 1, ref: "Project" },
name: { type: String, default: "New Session", required: true },
mode: {
type: String,
@ -21,7 +23,7 @@ export const ChatSessionSchema = new Schema<IChatSession>({
default: ChatSessionMode.Build,
required: true,
},
provider: { type: Schema.Types.ObjectId, required: true, ref: "AiProvider" },
provider: { type: String, required: true, ref: "AiProvider" },
selectedModel: { type: String, required: true },
stats: {
turnCount: { type: Number, default: 0, required: true },

View File

@ -2,7 +2,7 @@
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
// All Rights Reserved
import { Types, Schema, model } from "mongoose";
import { Schema, model } from "mongoose";
import {
ChatSessionMode,
@ -45,10 +45,10 @@ export const ChatSubagentProcessSchema = new Schema<IChatSubagentProcess>({
export const ChatTurnSchema = new Schema<IChatTurn>({
createdAt: { type: Date, default: Date.now, required: true },
user: { type: Types.ObjectId, required: true, ref: "User" },
project: { type: Types.ObjectId, required: false, ref: "Project" },
session: { type: Types.ObjectId, required: true, ref: "ChatSession" },
provider: { type: Types.ObjectId, required: true, ref: "AiProvider" },
user: { type: String, required: true, ref: "User" },
project: { type: String, required: false, ref: "Project" },
session: { type: String, required: true, ref: "ChatSession" },
provider: { type: String, required: true, ref: "AiProvider" },
llm: { type: String, required: true }, // id/name of the model used to process the prompt
mode: {
type: String,

View File

@ -2,22 +2,17 @@
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
// All Rights Reserved
import { Schema, Types, model } from "mongoose";
import { Schema, model } from "mongoose";
import { DtpLog } from "../lib/log.js";
import { IUser } from "@gadget/api";
const log = new DtpLog({
name: "CsrfTokenModel",
slug: "csrfToken",
});
import { GadgetId, IUser } from "@gadget/api";
export interface ICsrfToken {
_id: Types.ObjectId;
_id: GadgetId;
created: Date;
expires: Date;
claimed?: Date;
token: string;
user?: IUser | Types.ObjectId;
user?: IUser | GadgetId;
ip: string;
/*
@ -37,7 +32,7 @@ const CsrfTokenSchema = new Schema<ICsrfToken>({
expires: { type: Date, required: true, default: Date.now, index: -1 },
claimed: { type: Date },
token: { type: String, required: true, index: 1 },
user: { type: Types.ObjectId, ref: "User" },
user: { type: String, ref: "User" },
ip: { type: String, required: true },
});
@ -45,6 +40,5 @@ export const CsrfToken = model<ICsrfToken>("CsrfToken", CsrfTokenSchema);
export default CsrfToken;
(async () => {
log.info("Syncing indexes...");
await CsrfToken.syncIndexes();
})();

View File

@ -2,7 +2,7 @@
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
// All Rights Reserved
import { Types, Schema, model } from "mongoose";
import { Schema, model } from "mongoose";
import { IDroneMonitor, IMemoryMonitor } from "@gadget/api";
export const MemoryMonitorSchema = new Schema<IMemoryMonitor>({
@ -11,7 +11,7 @@ export const MemoryMonitorSchema = new Schema<IMemoryMonitor>({
});
export const DroneMonitorSchema = new Schema<IDroneMonitor>({
registration: { type: Types.ObjectId, required: true, index: 1 },
registration: { type: String, required: true, index: 1 },
timestamp: { type: Date, required: true, index: -1 },
memory: {
rss: { type: Number, required: true },

View File

@ -4,11 +4,13 @@
import { Schema, model } from "mongoose";
import { DroneStatus, IDroneRegistration } from "@gadget/api";
import { nanoid } from "nanoid";
export const DroneRegistrationSchema = new Schema<IDroneRegistration>({
_id: { type: String, default: nanoid, required: true, unique: true },
createdAt: { type: Date, required: true },
updatedAt: { type: Date, required: false },
user: { type: Schema.Types.ObjectId, ref: "User", required: true },
user: { type: String, ref: "User", required: true },
hostname: { type: String, required: true },
workspaceDir: { type: String, required: true },
status: {

View File

@ -2,16 +2,13 @@
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
// All Rights Reserved
import { Schema, Types, Document, model } from "mongoose";
import { Schema, model } from "mongoose";
import { DtpLog } from "../lib/log.js";
const log = new DtpLog({
name: "EmailLogModel",
slug: "emailLog",
});
import { GadgetId } from "@gadget/api";
import { nanoid } from "nanoid";
export interface IEmailLog extends Document {
_id: Types.ObjectId;
export interface IEmailLog {
_id: GadgetId;
created: Date;
from: string;
to: string;
@ -20,6 +17,7 @@ export interface IEmailLog extends Document {
messageId: string;
}
export const EmailLogSchema = new Schema<IEmailLog>({
_id: { type: String, default: nanoid, required: true, unique: true },
created: { type: Date, default: Date.now, required: true, index: -1 },
from: { type: String, required: true },
to: { type: String, required: true },
@ -32,6 +30,5 @@ export const EmailLog = model<IEmailLog>("EmailLog", EmailLogSchema);
export default EmailLog;
(async () => {
log.info("Syncing indexes...");
await EmailLog.syncIndexes();
})();

View File

@ -2,14 +2,8 @@
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
// All Rights Reserved
import { Schema, Types, model } from "mongoose";
import { IUser } from "@gadget/api";
import { DtpLog } from "../lib/log.js";
const log = new DtpLog({
name: "EmailVerificationModel",
slug: "emailVerification",
});
import { Schema, model } from "mongoose";
import { GadgetId, IUser } from "@gadget/api";
export enum EmailVerificationStatus {
Pending = "pending",
@ -19,13 +13,13 @@ export enum EmailVerificationStatus {
export interface IEmailVerification {
createdAt: Date;
user: IUser | Types.ObjectId;
user: IUser | GadgetId;
code: string;
status: EmailVerificationStatus;
}
export const EmailVerificationSchema = new Schema<IEmailVerification>({
createdAt: { type: Date, required: true, default: Date.now },
user: { type: Schema.Types.ObjectId, required: true, index: 1, ref: "User" },
user: { type: String, required: true, index: 1, ref: "User" },
code: { type: String, required: true, unique: true },
status: {
type: String,
@ -42,6 +36,5 @@ export const EmailVerification = model<IEmailVerification>(
export default EmailVerification;
(async () => {
log.info("Syncing indexes...");
await EmailVerification.syncIndexes();
})();

View File

@ -2,18 +2,15 @@
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
// All Rights Reserved
import { Schema, Types, model } from "mongoose";
import { Schema, model } from "mongoose";
import { nanoid } from "nanoid";
import { DtpLog } from "../lib/log.js";
import { IIdeSession } from "@gadget/api";
const log = new DtpLog({
name: "IdeSessionModel",
slug: "model:ide-session",
});
const IdeSessionSchema = new Schema<IIdeSession>({
_id: { type: String, default: nanoid, required: true, unique: true },
createdAt: { type: Date, default: Date.now, required: true },
user: { type: Types.ObjectId, required: true, ref: "User" },
user: { type: String, required: true, ref: "User" },
});
IdeSessionSchema.index({
@ -25,6 +22,5 @@ export const IdeSession = model<IIdeSession>("IdeSession", IdeSessionSchema);
export default IdeSession;
(async () => {
log.info("Syncing indexes...");
await IdeSession.syncIndexes();
})();

View File

@ -2,13 +2,13 @@
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
// All Rights Reserved
import { Types, Schema, model } from "mongoose";
import { Schema, model } from "mongoose";
import { ProjectStatus, IProject } from "@gadget/api";
export const ProjectSchema = new Schema<IProject>({
createdAt: { type: Date, default: Date.now, required: true },
user: { type: Types.ObjectId, required: true, index: 1, ref: "User" },
user: { type: String, required: true, index: 1, ref: "User" },
status: { type: String, enum: ProjectStatus, required: true },
name: { type: String, default: "New Project", required: true },
slug: {

View File

@ -4,12 +4,8 @@
import { Schema, model } from "mongoose";
import { DtpLog } from "../lib/log.js";
import { IUser, IUserFlags } from "@gadget/api";
const log = new DtpLog({
name: "UserModel",
slug: "user",
});
import { nanoid } from "nanoid";
export const UserFlagsSchema = new Schema<IUserFlags>(
{
@ -22,6 +18,7 @@ export const UserFlagsSchema = new Schema<IUserFlags>(
);
export const UserSchema = new Schema<IUser>({
_id: { type: String, default: nanoid, required: true, unique: true },
email: { type: String, required: true },
email_lc: { type: String, required: true, lowercase: true, unique: true },
passwordSalt: { type: String, required: true, select: false },
@ -34,6 +31,5 @@ export const User = model<IUser>("User", UserSchema);
export default User;
(async () => {
log.info("Syncing indexes...");
await User.syncIndexes();
})();

View File

@ -2,27 +2,24 @@
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
// All Rights Reserved
import { Schema, Types, model } from "mongoose";
import { Schema, model } from "mongoose";
import { IUser } from "@gadget/api";
import { GadgetId, IUser } from "@gadget/api";
import { DtpLog } from "../lib/log.js";
const log = new DtpLog({
name: "WebTokenModel",
slug: "webToken",
});
import { nanoid } from "nanoid";
export interface IWebToken {
_id: Types.ObjectId;
_id: GadgetId;
created: Date;
expires: Date;
user: IUser | Types.ObjectId;
user: IUser | GadgetId;
token: string;
}
export const WebTokenSchema = new Schema<IWebToken>({
_id: { type: String, default: nanoid, required: true, unique: true },
created: { type: Date, required: true, index: 1 },
expires: { type: Date, required: true, index: -1 },
user: { type: Schema.Types.ObjectId, required: true, ref: "User" },
user: { type: String, required: true, ref: "User" },
token: { type: String, required: true },
});
@ -30,6 +27,5 @@ export const WebToken = model<IWebToken>("WebToken", WebTokenSchema);
export default WebToken;
(async () => {
log.info("Syncing indexes...");
await WebToken.syncIndexes();
})();

View File

@ -2,21 +2,16 @@
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
// All Rights Reserved
import { Schema, Types, Document, model } from "mongoose";
import { Schema, model } from "mongoose";
import { DtpLog } from "../lib/log.js";
import { IUser } from "@gadget/api";
import { GadgetId, IUser } from "@gadget/api";
import { nanoid } from "nanoid";
const log = new DtpLog({
name: "WebVisitModel",
slug: "webVisit",
});
export interface IWebVisit extends Document {
_id: Types.ObjectId;
export interface IWebVisit {
_id: GadgetId;
created: Date;
url: string;
user?: IUser | Types.ObjectId;
user?: IUser | GadgetId;
userAgent?: string;
referrer?: string;
ipAddress?: string;
@ -30,9 +25,10 @@ export interface IWebVisit extends Document {
metroCode?: number;
}
export const WebVisitSchema = new Schema<IWebVisit>({
_id: { type: String, default: nanoid, required: true, unique: true },
created: { type: Date, default: Date.now },
url: { type: String, required: true },
user: { type: Types.ObjectId, index: 1, ref: "User" },
user: { type: String, index: 1, ref: "User" },
userAgent: { type: String },
referrer: { type: String },
ipAddress: { type: String },
@ -48,6 +44,5 @@ export const WebVisitSchema = new Schema<IWebVisit>({
export const WebVisit = model<IWebVisit>("WebVisit", WebVisitSchema);
(async () => {
log.info("Syncing indexes...");
await WebVisit.syncIndexes();
})();

View File

@ -5,11 +5,6 @@
// import env, { getCountryName } from "../config/env.js";
import assert from "node:assert";
import {
Types,
// MongooseQueryOptions,
// MongooseUpdateQueryOptions,
} from "mongoose";
import { Request } from "express";
import { filterText } from "dtp-cleantext";
@ -22,6 +17,7 @@ import ApiClient, {
import ApiClientLog, { IApiClientLog } from "../models/api-client-log.js";
import { DtpService } from "../lib/service.js";
import { GadgetId } from "@gadget/api";
class ApiClientService extends DtpService {
get name(): string {
@ -80,7 +76,7 @@ class ApiClientService extends DtpService {
return newClient;
}
async getById(clientId: Types.ObjectId): Promise<IApiClient | null> {
async getById(clientId: GadgetId): Promise<IApiClient | null> {
const client = await ApiClient.findOne({ _id: clientId });
return client;
}

View File

@ -2,8 +2,7 @@
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
// All Rights Reserved
import { Types } from "mongoose";
import { IChatSession, ChatSessionMode } from "@gadget/api";
import { IChatSession, ChatSessionMode, GadgetId } from "@gadget/api";
import { DtpService } from "../lib/service.js";
import ChatSession from "../models/chat-session.js";
@ -31,36 +30,32 @@ class ChatSessionService extends DtpService {
* Creates a new chat session.
*/
async create(
userId: string,
projectId: string,
providerId: string,
userId: GadgetId,
projectId: GadgetId,
providerId: GadgetId,
selectedModel: string,
mode: ChatSessionMode = ChatSessionMode.Build,
name?: string,
): Promise<IChatSession> {
const userObj = new Types.ObjectId(userId);
const projectObj = new Types.ObjectId(projectId);
const providerObj = new Types.ObjectId(providerId);
// Validate project exists
const project = await Project.findById(projectObj);
const project = await Project.findById(projectId);
if (!project) {
throw new Error(`Project not found: ${projectId}`);
}
// Validate provider exists
const provider = await AiProvider.findById(providerObj);
const provider = await AiProvider.findById(providerId);
if (!provider) {
throw new Error(`AI Provider not found: ${providerId}`);
}
const session = new ChatSession({
createdAt: new Date(),
user: userObj,
project: projectObj,
user: userId,
project: projectId,
name: name || "New Chat Session",
mode,
provider: providerObj,
provider: providerId,
selectedModel,
stats: {
turnCount: 0,
@ -74,7 +69,7 @@ class ChatSessionService extends DtpService {
await session.save();
this.log.info("chat session created", {
sessionId: session._id.toHexString(),
sessionId: session._id,
projectId,
providerId,
model: selectedModel,
@ -86,7 +81,7 @@ class ChatSessionService extends DtpService {
/**
* Gets a chat session by ID.
*/
async getById(chatSessionId: string): Promise<IChatSession> {
async getById(chatSessionId: GadgetId): Promise<IChatSession> {
const session = await ChatSession.findById(chatSessionId)
.populate("user")
.populate("project")
@ -103,9 +98,8 @@ class ChatSessionService extends DtpService {
/**
* Gets all chat sessions for a project.
*/
async getByProject(projectId: string): Promise<IChatSession[]> {
const projectObj = new Types.ObjectId(projectId);
const sessions = await ChatSession.find({ project: projectObj })
async getByProject(projectId: GadgetId): Promise<IChatSession[]> {
const sessions = await ChatSession.find({ project: projectId })
.populate("user")
.populate("project")
.populate("provider")
@ -118,9 +112,8 @@ class ChatSessionService extends DtpService {
/**
* Gets all chat sessions for a user.
*/
async getByUser(userId: string): Promise<IChatSession[]> {
const userObj = new Types.ObjectId(userId);
const sessions = await ChatSession.find({ user: userObj })
async getByUser(userId: GadgetId): Promise<IChatSession[]> {
const sessions = await ChatSession.find({ user: userId })
.populate("user")
.populate("project")
.populate("provider")
@ -134,10 +127,10 @@ class ChatSessionService extends DtpService {
* Updates a chat session.
*/
async update(
chatSessionId: string,
chatSessionId: GadgetId,
updates: Partial<{
name: string;
provider: string;
provider: GadgetId;
selectedModel: string;
mode: ChatSessionMode;
}>,
@ -153,7 +146,7 @@ class ChatSessionService extends DtpService {
if (!provider) {
throw new Error(`AI Provider not found: ${updates.provider}`);
}
session.provider = new Types.ObjectId(updates.provider);
session.provider = updates.provider;
}
if (updates.name !== undefined) {
@ -179,14 +172,14 @@ class ChatSessionService extends DtpService {
/**
* Deletes a chat session.
*/
async delete(chatSessionId: string): Promise<void> {
async delete(chatSessionId: GadgetId): Promise<void> {
const session = await ChatSession.findByIdAndDelete(chatSessionId);
if (!session) {
throw new Error(`Chat session not found: ${chatSessionId}`);
}
// Delete all turns associated with this session
await ChatTurn.deleteMany({ session: new Types.ObjectId(chatSessionId) });
await ChatTurn.deleteMany({ session: chatSessionId });
this.log.info("chat session deleted", {
sessionId: chatSessionId,
@ -196,9 +189,8 @@ class ChatSessionService extends DtpService {
/**
* Gets all turns for a chat session.
*/
async getTurns(chatSessionId: string): Promise<any[]> {
const sessionObj = new Types.ObjectId(chatSessionId);
const turns = await ChatTurn.find({ session: sessionObj })
async getTurns(chatSessionId: GadgetId): Promise<any[]> {
const turns = await ChatTurn.find({ session: chatSessionId })
.populate("user")
.populate("project")
.populate("provider")

View File

@ -2,9 +2,9 @@
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
// All Rights Reserved
import { PopulateOptions, Types } from "mongoose";
import { PopulateOptions } from "mongoose";
import { IUser, DroneStatus, IDroneRegistration } from "@gadget/api";
import { IUser, DroneStatus, IDroneRegistration, GadgetId } from "@gadget/api";
import DroneRegistration from "../models/drone-registration.ts";
import { DtpService } from "../lib/service.ts";
@ -91,7 +91,7 @@ class DroneService extends DtpService {
* @param registrationId The _id of the drone registration to be fetched
* @returns the drone registration document
*/
async getById(registrationId: Types.ObjectId): Promise<IDroneRegistration> {
async getById(registrationId: GadgetId): Promise<IDroneRegistration> {
const registration = await DroneRegistration.findById(
registrationId,
).populate(this.populateDroneRegistration);
@ -146,7 +146,7 @@ class DroneService extends DtpService {
* @returns Promise that resolves when termination is complete (success or timeout)
*/
async requestTermination(
registrationId: Types.ObjectId,
registrationId: GadgetId,
): Promise<{ success: boolean; message?: string }> {
const registration = await this.getById(registrationId);
@ -160,7 +160,7 @@ class DroneService extends DtpService {
} catch (error) {
// Drone is not connected - mark as offline immediately
this.log.warn("drone not connected, marking offline", {
registrationId: registrationId.toHexString(),
registrationId: registrationId,
hostname: registration.hostname,
});
await this.setStatus(registration, DroneStatus.Offline);
@ -184,7 +184,7 @@ class DroneService extends DtpService {
cleanup();
this.log.warn("drone termination timed out, forcing offline", {
registrationId: registrationId.toHexString(),
registrationId: registrationId,
hostname: registration.hostname,
});
@ -210,7 +210,7 @@ class DroneService extends DtpService {
}
this.log.info("drone accepted termination request", {
registrationId: registrationId.toHexString(),
registrationId: registrationId,
hostname: registration.hostname,
});

View File

@ -111,7 +111,7 @@ class ProjectService extends DtpService {
}
this.log.info("project updated", {
old: project.toObject ? project.toObject() : project,
old: project,
new: newProject.toObject(),
});
@ -132,7 +132,7 @@ class ProjectService extends DtpService {
this.log.info("project status updated", {
project: {
_id: project._id.toHexString(),
_id: project._id,
slug: project.slug,
},
old: project.status,
@ -154,7 +154,9 @@ class ProjectService extends DtpService {
}
async findBySlug(slug: string, user: IUser): Promise<IProject | null> {
return Project.findOne({ slug, user: user._id }).populate(this.populateProject);
return Project.findOne({ slug, user: user._id }).populate(
this.populateProject,
);
}
async delete(project: IProject): Promise<void> {

View File

@ -4,14 +4,13 @@
import env from "../config/env.js";
import { Types } from "mongoose";
import { Request } from "express";
import jwt from "jsonwebtoken";
import dayjs from "dayjs";
import geoip from "geoip-lite";
import { IUser } from "@gadget/api";
import { GadgetId, IUser } from "@gadget/api";
import { IWebToken, WebToken } from "../models/web-token.js";
import { WebVisit } from "../models/web-visit.js";
@ -27,7 +26,7 @@ interface UserWebToken {
_id: string;
email: string;
displayName: string;
webToken: IWebToken | Types.ObjectId;
webToken: IWebToken | GadgetId;
}
class SessionService extends DtpService {
@ -68,7 +67,7 @@ class SessionService extends DtpService {
try {
const NOW = new Date();
const payload = jwt.verify(token, env.auth.jwtSecret) as UserWebToken;
const userId = Types.ObjectId.createFromHexString(payload._id);
const userId = payload._id;
const webToken = await WebToken.findOne({ _id: payload.webToken });
if (!webToken) {
@ -84,7 +83,7 @@ class SessionService extends DtpService {
error.statusCode = 401;
throw error;
}
if (!userId.equals(webToken.user._id)) {
if (userId !== (webToken.user as IUser)._id) {
const error = new Error("JSON Web Token ownership mismatch");
error.name = "TokenOwnershipMismatch";
error.statusCode = 401;

View File

@ -3,9 +3,7 @@
// All Rights Reserved
import env from "../config/env.ts";
import http from "node:http";
import { Types } from "mongoose";
import { DisconnectReason, ExtendedError, Socket, Server } from "socket.io";
@ -32,8 +30,14 @@ type ChatSessionCodeSessionMap = Map<string, CodeSession>;
class SocketService extends DtpService {
private codeSessions: CodeSessionMap = new Map<string, CodeSession>();
private droneSessions: DroneSessionMap = new Map<string, DroneSession>();
private chatSessionIndex: ChatSessionCodeSessionMap = new Map<string, CodeSession>();
private droneRegistrationIndex: DroneSessionMap = new Map<string, DroneSession>();
private chatSessionIndex: ChatSessionCodeSessionMap = new Map<
string,
CodeSession
>();
private droneRegistrationIndex: DroneSessionMap = new Map<
string,
DroneSession
>();
private codeSessionUserIndex: CodeSessionMap = new Map<string, CodeSession>();
private io?: Server<
@ -98,7 +102,7 @@ class SocketService extends DtpService {
const session: CodeSession = new CodeSession(socket, user);
this.codeSessions.set(socket.id, session);
this.codeSessionUserIndex.set(user._id.toHexString(), session);
this.codeSessionUserIndex.set(user._id, session);
session.register();
socket.data = { sessionType: SocketSessionType.Code };
@ -122,12 +126,12 @@ class SocketService extends DtpService {
* If not a User JWT, try to validate as a Drone session
*/
try {
const registrationId = Types.ObjectId.createFromHexString(token);
const registrationId = token;
const registration = await DroneService.getById(registrationId);
const droneSession: DroneSession = new DroneSession(socket, registration);
this.droneSessions.set(socket.id, droneSession);
this.droneRegistrationIndex.set(registration._id.toHexString(), droneSession);
this.droneRegistrationIndex.set(registration._id, droneSession);
droneSession.register();
socket.data = { sessionType: SocketSessionType.Drone };
@ -165,7 +169,7 @@ class SocketService extends DtpService {
}
this.log.info("code socket connected", {
id: socket.id,
userId: session.user._id.toHexString(),
userId: session.user._id,
});
}
@ -178,7 +182,7 @@ class SocketService extends DtpService {
}
this.log.info("drone socket connected", {
id: socket.id,
registrationId: session.registration._id.toHexString(),
registrationId: session.registration._id,
});
}
@ -196,7 +200,7 @@ class SocketService extends DtpService {
if (codeUserIndex) {
const session = this.codeSessions.get(socket.id);
if (session) {
codeUserIndex.delete(session.user._id.toHexString());
codeUserIndex.delete(session.user._id);
}
}
return;
@ -205,7 +209,7 @@ class SocketService extends DtpService {
this.log.info("closing drone socket session", { id: socket.id });
const droneSession = this.droneSessions.get(socket.id);
if (droneSession) {
this.droneRegistrationIndex.delete(droneSession.registration._id.toHexString());
this.droneRegistrationIndex.delete(droneSession.registration._id);
}
this.droneSessions.delete(socket.id);
return;
@ -221,7 +225,7 @@ class SocketService extends DtpService {
}
getCodeSession(ideSession: IIdeSession): CodeSession {
const session = this.codeSessionUserIndex.get(ideSession._id.toHexString());
const session = this.codeSessionUserIndex.get(ideSession._id);
if (!session) {
const error = new Error("code session not found");
error.statusCode = 404;
@ -231,7 +235,7 @@ class SocketService extends DtpService {
}
getDroneSession(registration: IDroneRegistration): DroneSession {
const session = this.droneRegistrationIndex.get(registration._id.toHexString());
const session = this.droneRegistrationIndex.get(registration._id);
if (!session) {
const error = new Error("drone session not found");
error.statusCode = 404;
@ -250,11 +254,8 @@ class SocketService extends DtpService {
/**
* Gets a code session by its chat session ID.
*/
getCodeSessionByChatSessionId(chatSessionId: Types.ObjectId | string): CodeSession {
const chatSessionIdStr = typeof chatSessionId === "string"
? chatSessionId
: chatSessionId.toHexString();
const session = this.chatSessionIndex.get(chatSessionIdStr);
getCodeSessionByChatSessionId(chatSessionId: string): CodeSession {
const session = this.chatSessionIndex.get(chatSessionId);
if (!session) {
const error = new Error("code session not found for chat session");
error.statusCode = 404;

View File

@ -4,12 +4,12 @@
import assert from "node:assert";
import { MongooseBaseQueryOptions, Types } from "mongoose";
import { MongooseBaseQueryOptions } from "mongoose";
import { v4 as uuidv4 } from "uuid";
import { filterText } from "dtp-cleantext";
import User from "../models/user.ts";
import { IUser } from "@gadget/api";
import { GadgetId, IUser, UserDocument } from "@gadget/api";
import ContactService from "./contact.ts";
import CryptoService from "./crypto.ts";
@ -52,7 +52,7 @@ class UserService extends DtpService {
email: string,
password: string,
displayName?: string,
): Promise<IUser> {
): Promise<UserDocument> {
if (!email) {
const error = new Error("must specify email address");
error.statusCode = 400;
@ -254,7 +254,7 @@ class UserService extends DtpService {
return user;
}
async getById(userId: Types.ObjectId): Promise<IUser> {
async getById(userId: GadgetId): Promise<IUser> {
if (!userId) {
const error = new Error("must specify email address");
error.statusCode = 400;

View File

@ -45,7 +45,6 @@ import SocketService from "./services/socket.js";
import SessionService, { SessionType } from "./services/session.js";
import StorageService from "./services/storage.js";
import { Types } from "mongoose";
import { User } from "./models/user.js";
class DtpWebAppServer implements DtpComponent {
@ -123,7 +122,10 @@ class DtpWebAppServer implements DtpComponent {
this.app.use(
favicon(path.join(env.installDir, "assets", "icon", "icon-32x32.png")),
);
this.app.use("/assets", express.static(path.resolve(env.installDir, "assets")));
this.app.use(
"/assets",
express.static(path.resolve(env.installDir, "assets")),
);
this.app.use(
"/client",
express.static(path.resolve(env.installDir, "dist", "client")),
@ -334,8 +336,7 @@ class DtpWebAppServer implements DtpComponent {
if (token) {
req.user = await SessionService.verifyJsonWebToken(token);
} else {
const userId = Types.ObjectId.createFromHexString(req.session.user._id);
req.user = await User.findOne({ _id: userId });
req.user = await User.findOne({ _id: req.session.user._id });
}
res.locals.user = req.user;

View File

@ -3,7 +3,6 @@
// All Rights Reserved
import { v4 as uuidv4 } from "uuid";
import { Types } from "mongoose";
import "./lib/db.js";
@ -171,8 +170,7 @@ class DtpWebCli extends DtpProcess {
if (!clientId) {
throw new Error("client ID is required");
}
const clientIdObj = Types.ObjectId.createFromHexString(clientId);
const client = await ApiClientService.getById(clientIdObj);
const client = await ApiClientService.getById(clientId);
if (!client) {
throw new Error("Client not found");
}
@ -191,8 +189,7 @@ class DtpWebCli extends DtpProcess {
if (!clientId) {
throw new Error("client ID is required");
}
const clientIdObj = Types.ObjectId.createFromHexString(clientId);
const client = await ApiClientService.getById(clientIdObj);
const client = await ApiClientService.getById(clientId);
if (!client) {
throw new Error("Client not found");
}
@ -235,9 +232,7 @@ class DtpWebCli extends DtpProcess {
const user = await UserService.create(email, password, displayName);
this.log.info(
`user created: id:${user._id.toHexString()}, email:${user.email}`,
);
this.log.info(`user created: id:${user._id}, email:${user.email}`);
}
async onUserRemove(argv: string[]): Promise<void> {
@ -393,7 +388,7 @@ class DtpWebCli extends DtpProcess {
await provider.save();
this.log.info("provider added", {
_id: provider._id.toHexString(),
_id: provider._id,
name: provider.name,
apiType: provider.apiType,
baseUrl: provider.baseUrl,
@ -401,7 +396,7 @@ class DtpWebCli extends DtpProcess {
// Auto-probe for models
this.log.info("probing provider for models...");
await this.onProviderProbe([provider._id.toHexString()]);
await this.onProviderProbe([provider._id]);
}
async onProviderList(_argv: string[]): Promise<void> {
@ -435,8 +430,7 @@ class DtpWebCli extends DtpProcess {
if (!providerId) {
throw new Error("provider ID is required");
}
const providerIdObj = Types.ObjectId.createFromHexString(providerId);
const provider = await AiProvider.findById(providerIdObj);
const provider = await AiProvider.findById(providerId);
if (!provider) {
throw new Error("Provider not found");
}
@ -463,8 +457,7 @@ class DtpWebCli extends DtpProcess {
if (!providerId) {
throw new Error("provider ID is required");
}
const providerIdObj = Types.ObjectId.createFromHexString(providerId);
const provider = await AiProvider.findById(providerIdObj).select("+apiKey");
const provider = await AiProvider.findById(providerId).select("+apiKey");
if (!provider) {
throw new Error("Provider not found");
}
@ -479,8 +472,7 @@ class DtpWebCli extends DtpProcess {
if (!providerId) {
throw new Error("provider ID is required");
}
const providerIdObj = Types.ObjectId.createFromHexString(providerId);
const provider = await AiProvider.findById(providerIdObj).select("+apiKey");
const provider = await AiProvider.findById(providerId).select("+apiKey");
if (!provider) {
throw new Error("Provider not found");
}
@ -499,7 +491,7 @@ class DtpWebCli extends DtpProcess {
const api = createAiApi(
{
_id: provider._id.toHexString(),
_id: provider._id,
name: provider.name,
sdk: provider.apiType,
baseUrl: provider.baseUrl,

View File

@ -1,15 +1,22 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { Types } from 'mongoose';
import { CodeSession } from '../src/lib/code-session';
import { IChatSession, IProject, IUser, IDroneRegistration, ChatTurnStatus } from '@gadget/api';
import SocketService from '../src/services/socket';
import { ChatTurn } from '../src/models/chat-turn';
import { describe, it, expect, beforeEach, vi } from "vitest";
import { Types } from "mongoose";
import { CodeSession } from "../src/lib/code-session";
import {
IChatSession,
IProject,
IUser,
IDroneRegistration,
ChatTurnStatus,
} from "@gadget/api";
import SocketService from "../src/services/socket";
import { ChatTurn } from "../src/models/chat-turn";
import { nanoid } from "nanoid";
// Mock dependencies
vi.mock('../src/services/socket');
vi.mock('../src/models/chat-turn');
vi.mock("../src/services/socket");
vi.mock("../src/models/chat-turn");
describe('CodeSession', () => {
describe("CodeSession", () => {
let mockSocket: any;
let mockUser: IUser;
let mockDrone: IDroneRegistration;
@ -21,88 +28,93 @@ describe('CodeSession', () => {
vi.clearAllMocks();
mockSocket = {
id: 'test-socket-id',
id: "test-socket-id",
on: vi.fn(),
emit: vi.fn(),
};
mockUser = {
_id: new Types.ObjectId(),
email: 'test@example.com',
displayName: 'Test User',
_id: nanoid(),
email: "test@example.com",
displayName: "Test User",
} as IUser;
mockDrone = {
_id: new Types.ObjectId(),
hostname: 'test-host',
workspaceDir: '/test/workspace',
status: 'available',
_id: nanoid(),
hostname: "test-host",
workspaceDir: "/test/workspace",
status: "available",
} as IDroneRegistration;
mockChatSession = {
_id: new Types.ObjectId(),
name: 'Test Session',
mode: 'build',
provider: new Types.ObjectId(),
selectedModel: 'llama3.1',
_id: nanoid(),
name: "Test Session",
mode: "build",
provider: nanoid(),
selectedModel: "llama3.1",
} as IChatSession;
mockProject = {
_id: new Types.ObjectId(),
slug: 'test-project',
name: 'Test Project',
_id: nanoid(),
slug: "test-project",
name: "Test Project",
} as IProject;
codeSession = new CodeSession(mockSocket, mockUser);
});
describe('setSelectedDrone', () => {
it('should set the selected drone', () => {
describe("setSelectedDrone", () => {
it("should set the selected drone", () => {
codeSession.setSelectedDrone(mockDrone);
// Can't directly access private property, but we can verify through behavior
expect(() => codeSession.setSelectedDrone(mockDrone)).not.toThrow();
});
});
describe('setChatSession', () => {
it('should set the chat session and project', () => {
describe("setChatSession", () => {
it("should set the chat session and project", () => {
codeSession.setChatSession(mockChatSession, mockProject);
expect(() => codeSession.setChatSession(mockChatSession, mockProject)).not.toThrow();
expect(() =>
codeSession.setChatSession(mockChatSession, mockProject),
).not.toThrow();
});
});
describe('onSubmitPrompt', () => {
it('should throw error if no drone is selected', async () => {
describe("onSubmitPrompt", () => {
it("should throw error if no drone is selected", async () => {
codeSession.setChatSession(mockChatSession, mockProject);
await expect(codeSession.onSubmitPrompt('test prompt'))
.rejects.toThrow('No drone selected');
await expect(codeSession.onSubmitPrompt("test prompt")).rejects.toThrow(
"No drone selected",
);
});
it('should throw error if no chat session is active', async () => {
it("should throw error if no chat session is active", async () => {
codeSession.setSelectedDrone(mockDrone);
await expect(codeSession.onSubmitPrompt('test prompt'))
.rejects.toThrow('No chat session active');
await expect(codeSession.onSubmitPrompt("test prompt")).rejects.toThrow(
"No chat session active",
);
});
it('should throw error if no project is selected', async () => {
it("should throw error if no project is selected", async () => {
codeSession.setSelectedDrone(mockDrone);
codeSession.setChatSession(mockChatSession, undefined as any);
await expect(codeSession.onSubmitPrompt('test prompt'))
.rejects.toThrow('No project selected');
await expect(codeSession.onSubmitPrompt("test prompt")).rejects.toThrow(
"No project selected",
);
});
it('should create a ChatTurn and emit processWorkOrder to drone', async () => {
it("should create a ChatTurn and emit processWorkOrder to drone", async () => {
codeSession.setSelectedDrone(mockDrone);
codeSession.setChatSession(mockChatSession, mockProject);
const mockTurn = {
_id: new Types.ObjectId(),
_id: nanoid(),
save: vi.fn().mockResolvedValue(undefined),
};
vi.mocked(ChatTurn).mockImplementation(function() {
vi.mocked(ChatTurn).mockImplementation(function () {
return mockTurn as any;
});
@ -113,45 +125,49 @@ describe('CodeSession', () => {
setChatSessionId: vi.fn(),
setCurrentTurnId: vi.fn(),
};
vi.mocked(SocketService.getDroneSession).mockReturnValue(mockDroneSession as any);
vi.mocked(SocketService.getDroneSession).mockReturnValue(
mockDroneSession as any,
);
await codeSession.onSubmitPrompt('test prompt');
await codeSession.onSubmitPrompt("test prompt");
expect(ChatTurn).toHaveBeenCalledWith(expect.objectContaining({
user: mockUser._id,
project: mockProject._id,
session: mockChatSession._id,
provider: mockChatSession.provider,
llm: mockChatSession.selectedModel,
status: ChatTurnStatus.Processing,
prompts: {
user: 'test prompt',
system: undefined,
},
}));
expect(ChatTurn).toHaveBeenCalledWith(
expect.objectContaining({
user: mockUser._id,
project: mockProject._id,
session: mockChatSession._id,
provider: mockChatSession.provider,
llm: mockChatSession.selectedModel,
status: ChatTurnStatus.Processing,
prompts: {
user: "test prompt",
system: undefined,
},
}),
);
expect(mockTurn.save).toHaveBeenCalled();
expect(mockDroneSession.socket.emit).toHaveBeenCalledWith(
'processWorkOrder',
"processWorkOrder",
mockDrone,
mockProject,
mockChatSession,
mockTurn,
expect.any(Function)
expect.any(Function),
);
});
it('should update ChatTurn to Error status if drone rejects work order', async () => {
it("should update ChatTurn to Error status if drone rejects work order", async () => {
codeSession.setSelectedDrone(mockDrone);
codeSession.setChatSession(mockChatSession, mockProject);
const mockTurn = {
_id: new Types.ObjectId(),
_id: nanoid(),
status: ChatTurnStatus.Processing,
response: '',
response: "",
save: vi.fn().mockResolvedValue(undefined),
};
vi.mocked(ChatTurn).mockImplementation(function() {
vi.mocked(ChatTurn).mockImplementation(function () {
return mockTurn as any;
});
@ -159,59 +175,75 @@ describe('CodeSession', () => {
socket: {
emit: vi.fn((event, ...args) => {
const callback = args[args.length - 1];
callback(false, 'Drone is busy');
callback(false, "Drone is busy");
}),
},
setChatSessionId: vi.fn(),
setCurrentTurnId: vi.fn(),
};
vi.mocked(SocketService.getDroneSession).mockReturnValue(mockDroneSession as any);
vi.mocked(SocketService.getDroneSession).mockReturnValue(
mockDroneSession as any,
);
await codeSession.onSubmitPrompt('test prompt');
await codeSession.onSubmitPrompt("test prompt");
expect(mockTurn.status).toBe(ChatTurnStatus.Error);
expect(mockTurn.response).toBe('Drone is busy');
expect(mockTurn.response).toBe("Drone is busy");
expect(mockTurn.save).toHaveBeenCalled();
});
});
describe('onRequestSessionLock', () => {
it('should set selected drone, chat session, and project on success', () => {
describe("onRequestSessionLock", () => {
it("should set selected drone, chat session, and project on success", () => {
const mockDroneSession = {
socket: {
emit: vi.fn((event, ...args) => {
const callback = args[args.length - 1];
callback(true, mockChatSession._id.toHexString());
callback(true, mockChatSession._id);
}),
},
setChatSessionId: vi.fn(),
setCurrentTurnId: vi.fn(),
};
vi.mocked(SocketService.getDroneSession).mockReturnValue(mockDroneSession as any);
vi.mocked(SocketService.getDroneSession).mockReturnValue(
mockDroneSession as any,
);
const callback = vi.fn();
codeSession.onRequestSessionLock(mockDrone, mockProject, mockChatSession, callback);
codeSession.onRequestSessionLock(
mockDrone,
mockProject,
mockChatSession,
callback,
);
expect(callback).toHaveBeenCalledWith(true, mockChatSession._id.toHexString());
expect(callback).toHaveBeenCalledWith(true, mockChatSession._id);
});
it('should not set session data on failure', () => {
it("should not set session data on failure", () => {
const mockDroneSession = {
socket: {
emit: vi.fn((event, ...args) => {
const callback = args[args.length - 1];
callback(false, '');
callback(false, "");
}),
},
setChatSessionId: vi.fn(),
setCurrentTurnId: vi.fn(),
};
vi.mocked(SocketService.getDroneSession).mockReturnValue(mockDroneSession as any);
vi.mocked(SocketService.getDroneSession).mockReturnValue(
mockDroneSession as any,
);
const callback = vi.fn();
codeSession.onRequestSessionLock(mockDrone, mockProject, mockChatSession, callback);
codeSession.onRequestSessionLock(
mockDrone,
mockProject,
mockChatSession,
callback,
);
expect(callback).toHaveBeenCalledWith(false, '');
expect(callback).toHaveBeenCalledWith(false, "");
});
});
});

View File

@ -1,134 +1,174 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { Types } from 'mongoose';
import { DroneSession } from '../src/lib/drone-session';
import { IDroneRegistration, IUser, ChatTurnStatus } from '@gadget/api';
import SocketService from '../src/services/socket';
import { ChatTurn } from '../src/models/chat-turn';
import { describe, it, expect, beforeEach, vi } from "vitest";
import { Types } from "mongoose";
import { DroneSession } from "../src/lib/drone-session";
import {
IDroneRegistration,
IUser,
ChatTurnStatus,
GadgetId,
} from "@gadget/api";
import SocketService from "../src/services/socket";
import { ChatTurn } from "../src/models/chat-turn";
import { nanoid } from "nanoid";
// Mock dependencies
vi.mock('../src/services/socket');
vi.mock('../src/models/chat-turn');
vi.mock("../src/services/socket");
vi.mock("../src/models/chat-turn");
describe('DroneSession', () => {
describe("DroneSession", () => {
let mockSocket: any;
let mockRegistration: IDroneRegistration;
let mockUser: IUser;
let droneSession: DroneSession;
let mockChatSessionId: Types.ObjectId;
let mockTurnId: Types.ObjectId;
let mockChatSessionId: GadgetId;
let mockTurnId: GadgetId;
beforeEach(() => {
vi.clearAllMocks();
mockSocket = {
id: 'test-drone-socket',
id: "test-drone-socket",
on: vi.fn(),
emit: vi.fn(),
};
mockUser = {
_id: new Types.ObjectId(),
email: 'test@example.com',
displayName: 'Test User',
_id: nanoid(),
email: "test@example.com",
displayName: "Test User",
} as IUser;
mockRegistration = {
_id: new Types.ObjectId(),
_id: nanoid(),
user: mockUser,
hostname: 'test-host',
workspaceDir: '/test/workspace',
status: 'available',
hostname: "test-host",
workspaceDir: "/test/workspace",
status: "available",
} as IDroneRegistration;
mockChatSessionId = new Types.ObjectId();
mockTurnId = new Types.ObjectId();
mockChatSessionId = nanoid();
mockTurnId = nanoid();
droneSession = new DroneSession(mockSocket, mockRegistration);
});
describe('register', () => {
it('should register event handlers for drone events', () => {
describe("register", () => {
it("should register event handlers for drone events", () => {
droneSession.register();
expect(mockSocket.on).toHaveBeenCalledWith('thinking', expect.any(Function));
expect(mockSocket.on).toHaveBeenCalledWith('response', expect.any(Function));
expect(mockSocket.on).toHaveBeenCalledWith('toolCall', expect.any(Function));
expect(mockSocket.on).toHaveBeenCalledWith('workOrderComplete', expect.any(Function));
expect(mockSocket.on).toHaveBeenCalledWith('requestCrashRecovery', expect.any(Function));
expect(mockSocket.on).toHaveBeenCalledWith('requestTermination', expect.any(Function));
expect(mockSocket.on).toHaveBeenCalledWith(
"thinking",
expect.any(Function),
);
expect(mockSocket.on).toHaveBeenCalledWith(
"response",
expect.any(Function),
);
expect(mockSocket.on).toHaveBeenCalledWith(
"toolCall",
expect.any(Function),
);
expect(mockSocket.on).toHaveBeenCalledWith(
"workOrderComplete",
expect.any(Function),
);
expect(mockSocket.on).toHaveBeenCalledWith(
"requestCrashRecovery",
expect.any(Function),
);
expect(mockSocket.on).toHaveBeenCalledWith(
"requestTermination",
expect.any(Function),
);
});
});
describe('setChatSessionId', () => {
it('should set the chat session ID', () => {
describe("setChatSessionId", () => {
it("should set the chat session ID", () => {
droneSession.setChatSessionId(mockChatSessionId);
expect(() => droneSession.setChatSessionId(mockChatSessionId)).not.toThrow();
expect(() =>
droneSession.setChatSessionId(mockChatSessionId),
).not.toThrow();
});
});
describe('setCurrentTurnId', () => {
it('should set the current turn ID', () => {
describe("setCurrentTurnId", () => {
it("should set the current turn ID", () => {
droneSession.setCurrentTurnId(mockTurnId);
expect(() => droneSession.setCurrentTurnId(mockTurnId)).not.toThrow();
});
});
describe('onThinking', () => {
it('should route thinking event to code session and update ChatTurn', async () => {
describe("onThinking", () => {
it("should route thinking event to code session and update ChatTurn", async () => {
const mockCodeSession = {
socket: { emit: vi.fn() },
};
vi.mocked(SocketService.getCodeSessionByChatSessionId).mockReturnValue(mockCodeSession as any);
vi.mocked(SocketService.getCodeSessionByChatSessionId).mockReturnValue(
mockCodeSession as any,
);
vi.mocked(ChatTurn.findByIdAndUpdate).mockResolvedValue({} as any);
droneSession.setChatSessionId(mockChatSessionId);
droneSession.setCurrentTurnId(mockTurnId);
await droneSession.onThinking('thinking content');
await droneSession.onThinking("thinking content");
expect(SocketService.getCodeSessionByChatSessionId).toHaveBeenCalledWith(mockChatSessionId);
expect(mockCodeSession.socket.emit).toHaveBeenCalledWith('thinking', 'thinking content');
expect(SocketService.getCodeSessionByChatSessionId).toHaveBeenCalledWith(
mockChatSessionId,
);
expect(mockCodeSession.socket.emit).toHaveBeenCalledWith(
"thinking",
"thinking content",
);
expect(ChatTurn.findByIdAndUpdate).toHaveBeenCalledWith(mockTurnId, {
thinking: 'thinking content',
thinking: "thinking content",
});
});
it('should log warning if no chat session is active', async () => {
await droneSession.onThinking('thinking content');
it("should log warning if no chat session is active", async () => {
await droneSession.onThinking("thinking content");
// No exception thrown, warning logged internally
expect(mockSocket.emit).not.toHaveBeenCalled();
});
});
describe('onResponse', () => {
it('should route response event to code session and update ChatTurn', async () => {
describe("onResponse", () => {
it("should route response event to code session and update ChatTurn", async () => {
const mockCodeSession = {
socket: { emit: vi.fn() },
};
vi.mocked(SocketService.getCodeSessionByChatSessionId).mockReturnValue(mockCodeSession as any);
vi.mocked(SocketService.getCodeSessionByChatSessionId).mockReturnValue(
mockCodeSession as any,
);
vi.mocked(ChatTurn.findByIdAndUpdate).mockResolvedValue({} as any);
droneSession.setChatSessionId(mockChatSessionId);
droneSession.setCurrentTurnId(mockTurnId);
await droneSession.onResponse('response content');
await droneSession.onResponse("response content");
expect(SocketService.getCodeSessionByChatSessionId).toHaveBeenCalledWith(mockChatSessionId);
expect(mockCodeSession.socket.emit).toHaveBeenCalledWith('response', 'response content');
expect(SocketService.getCodeSessionByChatSessionId).toHaveBeenCalledWith(
mockChatSessionId,
);
expect(mockCodeSession.socket.emit).toHaveBeenCalledWith(
"response",
"response content",
);
expect(ChatTurn.findByIdAndUpdate).toHaveBeenCalledWith(mockTurnId, {
response: 'response content',
response: "response content",
});
});
it('should log warning if no chat session is active', async () => {
await droneSession.onResponse('response content');
it("should log warning if no chat session is active", async () => {
await droneSession.onResponse("response content");
// No exception thrown, warning logged internally
expect(mockSocket.emit).not.toHaveBeenCalled();
});
});
describe('onToolCall', () => {
it('should route toolCall event to code session and update ChatTurn', async () => {
describe("onToolCall", () => {
it("should route toolCall event to code session and update ChatTurn", async () => {
const mockCodeSession = {
socket: { emit: vi.fn() },
};
@ -137,37 +177,52 @@ describe('DroneSession', () => {
stats: { toolCallCount: 0 },
save: vi.fn().mockResolvedValue(undefined),
};
vi.mocked(SocketService.getCodeSessionByChatSessionId).mockReturnValue(mockCodeSession as any);
vi.mocked(SocketService.getCodeSessionByChatSessionId).mockReturnValue(
mockCodeSession as any,
);
vi.mocked(ChatTurn.findById).mockResolvedValue(mockTurn as any);
droneSession.setChatSessionId(mockChatSessionId);
droneSession.setCurrentTurnId(mockTurnId);
await droneSession.onToolCall('call-123', 'readFile', '{"path":"test.ts"}', 'file contents');
await droneSession.onToolCall(
"call-123",
"readFile",
'{"path":"test.ts"}',
"file contents",
);
expect(SocketService.getCodeSessionByChatSessionId).toHaveBeenCalledWith(mockChatSessionId);
expect(mockCodeSession.socket.emit).toHaveBeenCalledWith('toolCall', 'call-123', 'readFile', '{"path":"test.ts"}', 'file contents');
expect(SocketService.getCodeSessionByChatSessionId).toHaveBeenCalledWith(
mockChatSessionId,
);
expect(mockCodeSession.socket.emit).toHaveBeenCalledWith(
"toolCall",
"call-123",
"readFile",
'{"path":"test.ts"}',
"file contents",
);
expect(ChatTurn.findById).toHaveBeenCalledWith(mockTurnId);
expect(mockTurn.toolCalls).toHaveLength(1);
expect(mockTurn.toolCalls[0]).toEqual({
callId: 'call-123',
name: 'readFile',
callId: "call-123",
name: "readFile",
parameters: '{"path":"test.ts"}',
response: 'file contents',
response: "file contents",
});
expect(mockTurn.stats.toolCallCount).toBe(1);
expect(mockTurn.save).toHaveBeenCalled();
});
it('should log warning if no chat session is active', async () => {
await droneSession.onToolCall('call-1', 'test', '{}', 'result');
it("should log warning if no chat session is active", async () => {
await droneSession.onToolCall("call-1", "test", "{}", "result");
// No exception thrown, warning logged internally
expect(mockSocket.emit).not.toHaveBeenCalled();
});
});
describe('onWorkOrderComplete', () => {
it('should update ChatTurn status and emit to code session on success', async () => {
describe("onWorkOrderComplete", () => {
it("should update ChatTurn status and emit to code session on success", async () => {
const mockCodeSession = {
socket: { emit: vi.fn() },
};
@ -175,56 +230,69 @@ describe('DroneSession', () => {
status: ChatTurnStatus.Processing,
save: vi.fn().mockResolvedValue(undefined),
};
vi.mocked(SocketService.getCodeSessionByChatSessionId).mockReturnValue(mockCodeSession as any);
vi.mocked(SocketService.getCodeSessionByChatSessionId).mockReturnValue(
mockCodeSession as any,
);
vi.mocked(ChatTurn.findById).mockResolvedValue(mockTurn as any);
droneSession.setChatSessionId(mockChatSessionId);
await droneSession.onWorkOrderComplete(mockTurnId.toHexString(), true);
await droneSession.onWorkOrderComplete(mockTurnId, true);
expect(ChatTurn.findById).toHaveBeenCalledWith(mockTurnId.toHexString());
expect(ChatTurn.findById).toHaveBeenCalledWith(mockTurnId);
expect(mockTurn.status).toBe(ChatTurnStatus.Finished);
expect(mockTurn.save).toHaveBeenCalled();
expect(mockCodeSession.socket.emit).toHaveBeenCalledWith('workOrderComplete', mockTurnId.toHexString(), true, undefined);
expect(mockCodeSession.socket.emit).toHaveBeenCalledWith(
"workOrderComplete",
mockTurnId,
true,
undefined,
);
expect(droneSession.currentTurnId).toBeUndefined();
});
it('should update ChatTurn to Error status on failure', async () => {
it("should update ChatTurn to Error status on failure", async () => {
const mockCodeSession = {
socket: { emit: vi.fn() },
};
const mockTurn = {
status: ChatTurnStatus.Processing,
response: '',
response: "",
save: vi.fn().mockResolvedValue(undefined),
};
vi.mocked(SocketService.getCodeSessionByChatSessionId).mockReturnValue(mockCodeSession as any);
vi.mocked(SocketService.getCodeSessionByChatSessionId).mockReturnValue(
mockCodeSession as any,
);
vi.mocked(ChatTurn.findById).mockResolvedValue(mockTurn as any);
droneSession.setChatSessionId(mockChatSessionId);
await droneSession.onWorkOrderComplete(mockTurnId.toHexString(), false, 'Agent crashed');
await droneSession.onWorkOrderComplete(
mockTurnId,
false,
"Agent crashed",
);
expect(mockTurn.status).toBe(ChatTurnStatus.Error);
expect(mockTurn.response).toBe('Agent crashed');
expect(mockTurn.response).toBe("Agent crashed");
expect(mockTurn.save).toHaveBeenCalled();
});
it('should log warning if no chat session is active', async () => {
await droneSession.onWorkOrderComplete(mockTurnId.toHexString(), true);
it("should log warning if no chat session is active", async () => {
await droneSession.onWorkOrderComplete(mockTurnId, true);
// No exception thrown, warning logged internally
expect(mockSocket.emit).not.toHaveBeenCalled();
});
});
describe('onRequestTermination', () => {
it('should forward requestTermination to drone socket with logging', async () => {
describe("onRequestTermination", () => {
it("should forward requestTermination to drone socket with logging", async () => {
const mockCallback = vi.fn();
const mockDroneCallback = vi.fn();
mockSocket.emit.mockImplementation((event: string, ...args: any[]) => {
if (event === 'requestTermination' && args.length > 0) {
if (event === "requestTermination" && args.length > 0) {
const cb = args[args.length - 1];
if (typeof cb === 'function') {
if (typeof cb === "function") {
cb(true);
}
}
@ -232,16 +300,19 @@ describe('DroneSession', () => {
await droneSession.onRequestTermination(mockCallback);
expect(mockSocket.emit).toHaveBeenCalledWith('requestTermination', expect.any(Function));
expect(mockSocket.emit).toHaveBeenCalledWith(
"requestTermination",
expect.any(Function),
);
expect(mockCallback).toHaveBeenCalledWith(true);
});
it('should pass through failure response from drone', async () => {
it("should pass through failure response from drone", async () => {
const mockCallback = vi.fn();
mockSocket.emit.mockImplementation((event: string, ...args: any[]) => {
if (event === 'requestTermination' && args.length > 0) {
if (event === "requestTermination" && args.length > 0) {
const cb = args[args.length - 1];
if (typeof cb === 'function') {
if (typeof cb === "function") {
cb(false);
}
}
@ -249,7 +320,10 @@ describe('DroneSession', () => {
await droneSession.onRequestTermination(mockCallback);
expect(mockSocket.emit).toHaveBeenCalledWith('requestTermination', expect.any(Function));
expect(mockSocket.emit).toHaveBeenCalledWith(
"requestTermination",
expect.any(Function),
);
expect(mockCallback).toHaveBeenCalledWith(false);
});
});

View File

@ -2,8 +2,8 @@
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
// All Rights Reserved
import { vi } from 'vitest';
import { Types } from 'mongoose';
import { vi } from "vitest";
import { Types } from "mongoose";
import {
IUser,
IDroneRegistration,
@ -11,20 +11,22 @@ import {
IProject,
DroneStatus,
ChatSessionMode,
} from '@gadget/api';
} from "@gadget/api";
import { nanoid } from "nanoid";
/**
* Creates a mock socket object with common methods stubbed.
*/
export function createMockSocket(id?: string) {
return {
id: id || `socket-${new Types.ObjectId().toHexString()}`,
id: id || `socket-${nanoid()}`,
on: vi.fn(),
emit: vi.fn(),
disconnect: vi.fn(),
data: {},
handshake: {
auth: { token: '' },
auth: { token: "" },
},
};
}
@ -34,9 +36,9 @@ export function createMockSocket(id?: string) {
*/
export function createMockUser(overrides?: Partial<IUser>): IUser {
return {
_id: new Types.ObjectId(),
email: `user-${new Types.ObjectId().toHexString()}@example.com`,
displayName: 'Test User',
_id: nanoid(),
email: `user-${nanoid()}@example.com`,
displayName: "Test User",
banned: false,
admin: false,
createdAt: new Date(),
@ -50,16 +52,16 @@ export function createMockUser(overrides?: Partial<IUser>): IUser {
*/
export function createMockDroneRegistration(
user?: IUser,
overrides?: Partial<IDroneRegistration>
overrides?: Partial<IDroneRegistration>,
): IDroneRegistration {
const testUser = user || createMockUser();
return {
_id: new Types.ObjectId(),
_id: nanoid(),
createdAt: new Date(),
updatedAt: new Date(),
user: testUser,
hostname: `drone-${new Types.ObjectId().toHexString()}`,
workspaceDir: '/test/workspace',
hostname: `drone-${nanoid()}`,
workspaceDir: "/test/workspace",
status: DroneStatus.Available,
...overrides,
} as IDroneRegistration;
@ -71,18 +73,18 @@ export function createMockDroneRegistration(
export function createMockChatSession(
user?: IUser,
project?: IProject,
overrides?: Partial<IChatSession>
overrides?: Partial<IChatSession>,
): IChatSession {
return {
_id: new Types.ObjectId(),
_id: nanoid(),
createdAt: new Date(),
updatedAt: new Date(),
user: user?._id || new Types.ObjectId(),
project: project?._id || new Types.ObjectId(),
name: 'Test Chat Session',
user: user?._id || nanoid(),
project: project?._id || nanoid(),
name: "Test Chat Session",
mode: ChatSessionMode.Build,
provider: new Types.ObjectId(),
selectedModel: 'llama3.2',
provider: nanoid(),
selectedModel: "llama3.2",
stats: {
toolCallCount: 0,
fileOpCount: 0,
@ -97,16 +99,16 @@ export function createMockChatSession(
*/
export function createMockProject(
user?: IUser,
overrides?: Partial<IProject>
overrides?: Partial<IProject>,
): IProject {
return {
_id: new Types.ObjectId(),
_id: nanoid(),
createdAt: new Date(),
updatedAt: new Date(),
user: user?._id || new Types.ObjectId(),
slug: `project-${new Types.ObjectId().toHexString()}`,
name: 'Test Project',
gitUrl: 'https://github.com/test/test.git',
user: user?._id || nanoid(),
slug: `project-${nanoid()}`,
name: "Test Project",
gitUrl: "https://github.com/test/test.git",
...overrides,
} as IProject;
}
@ -132,5 +134,5 @@ export function captureSocketEmits(socket: any): EmitCall[] {
*/
export function extractCallback(emitCall: EmitCall): Function | null {
const lastArg = emitCall.args[emitCall.args.length - 1];
return typeof lastArg === 'function' ? lastArg : null;
return typeof lastArg === "function" ? lastArg : null;
}

View File

@ -2,19 +2,20 @@
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
// All Rights Reserved
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { Types } from 'mongoose';
import { DroneStatus } from '@gadget/api';
import { describe, it, expect, beforeEach, vi } from "vitest";
import { Types } from "mongoose";
import { DroneStatus } from "@gadget/api";
import {
createMockSocket,
createMockUser,
createMockDroneRegistration,
createMockChatSession,
createMockProject,
} from './fixtures';
} from "./fixtures";
import { nanoid } from "nanoid";
// Mock the entire socket service module
vi.mock('../src/services/socket', async () => {
vi.mock("../src/services/socket", async () => {
const chatSessionIndex = new Map();
return {
default: {
@ -36,7 +37,7 @@ vi.mock('../src/services/socket', async () => {
};
});
describe('SocketService Session Indexing', () => {
describe("SocketService Session Indexing", () => {
let SocketService: any;
let mockUser: any;
let mockDrone: any;
@ -45,64 +46,64 @@ describe('SocketService Session Indexing', () => {
beforeEach(async () => {
vi.clearAllMocks();
const mod = await import('../src/services/socket');
const mod = await import("../src/services/socket");
SocketService = mod.default;
// Clear all session maps
SocketService.codeSessions.clear();
SocketService.droneSessions.clear();
SocketService.chatSessionIndex.clear();
SocketService.droneRegistrationIndex.clear();
SocketService.codeSessionUserIndex.clear();
mockUser = createMockUser();
mockDrone = createMockDroneRegistration(mockUser);
mockChatSession = createMockChatSession(mockUser);
mockProject = createMockProject(mockUser);
});
describe('Drone Session Indexing', () => {
it('should store drone session by socket.id and registration._id', () => {
const mockSocket = createMockSocket('drone-socket-123');
describe("Drone Session Indexing", () => {
it("should store drone session by socket.id and registration._id", () => {
const mockSocket = createMockSocket("drone-socket-123");
const mockDroneSession = {
socket: mockSocket,
registration: mockDrone,
type: 'drone',
type: "drone",
};
// Store in both indexes
SocketService.droneSessions.set(mockSocket.id, mockDroneSession);
SocketService.droneRegistrationIndex.set(
mockDrone._id.toHexString(),
mockDroneSession
);
SocketService.droneRegistrationIndex.set(mockDrone._id, mockDroneSession);
// Verify both lookups work
expect(SocketService.droneSessions.get(mockSocket.id)).toBe(mockDroneSession);
expect(SocketService.droneRegistrationIndex.get(mockDrone._id.toHexString())).toBe(mockDroneSession);
expect(SocketService.droneSessions.get(mockSocket.id)).toBe(
mockDroneSession,
);
expect(SocketService.droneRegistrationIndex.get(mockDrone._id)).toBe(
mockDroneSession,
);
});
it('should find drone session by registration._id', () => {
const mockSocket = createMockSocket('drone-socket-456');
it("should find drone session by registration._id", () => {
const mockSocket = createMockSocket("drone-socket-456");
const mockDroneSession = {
socket: mockSocket,
registration: mockDrone,
type: 'drone',
type: "drone",
};
// Store in both indexes
SocketService.droneSessions.set(mockSocket.id, mockDroneSession);
SocketService.droneRegistrationIndex.set(
mockDrone._id.toHexString(),
mockDroneSession
);
SocketService.droneRegistrationIndex.set(mockDrone._id, mockDroneSession);
// Mock getDroneSession to use the registration index
SocketService.getDroneSession.mockImplementation((registration: any) => {
const session = SocketService.droneRegistrationIndex.get(registration._id.toHexString());
const session = SocketService.droneRegistrationIndex.get(
registration._id,
);
if (!session) {
const error = new Error('drone session not found');
const error = new Error("drone session not found");
(error as any).statusCode = 404;
throw error;
}
@ -113,87 +114,90 @@ describe('SocketService Session Indexing', () => {
expect(found).toBe(mockDroneSession);
});
it('should throw 404 when drone session not found', () => {
it("should throw 404 when drone session not found", () => {
const nonExistentDrone = createMockDroneRegistration(mockUser);
SocketService.getDroneSession.mockImplementation(() => {
const error = new Error('drone session not found');
const error = new Error("drone session not found");
(error as any).statusCode = 404;
throw error;
});
expect(() => SocketService.getDroneSession(nonExistentDrone)).toThrow('drone session not found');
expect(() => SocketService.getDroneSession(nonExistentDrone)).toThrowError(expect.objectContaining({
statusCode: 404,
}));
expect(() => SocketService.getDroneSession(nonExistentDrone)).toThrow(
"drone session not found",
);
expect(() =>
SocketService.getDroneSession(nonExistentDrone),
).toThrowError(
expect.objectContaining({
statusCode: 404,
}),
);
});
it('should remove drone session from all indexes on disconnect', () => {
const mockSocket = createMockSocket('drone-socket-789');
it("should remove drone session from all indexes on disconnect", () => {
const mockSocket = createMockSocket("drone-socket-789");
const mockDroneSession = {
socket: mockSocket,
registration: mockDrone,
type: 'drone',
type: "drone",
};
// Store in indexes
SocketService.droneSessions.set(mockSocket.id, mockDroneSession);
SocketService.droneRegistrationIndex.set(
mockDrone._id.toHexString(),
mockDroneSession
);
SocketService.droneRegistrationIndex.set(mockDrone._id, mockDroneSession);
// Simulate disconnect
SocketService.droneSessions.delete(mockSocket.id);
SocketService.droneRegistrationIndex.delete(mockDrone._id.toHexString());
SocketService.droneRegistrationIndex.delete(mockDrone._id);
// Verify removal from all indexes
expect(SocketService.droneSessions.get(mockSocket.id)).toBeUndefined();
expect(SocketService.droneRegistrationIndex.get(mockDrone._id.toHexString())).toBeUndefined();
expect(
SocketService.droneRegistrationIndex.get(mockDrone._id),
).toBeUndefined();
});
});
describe('Code Session Indexing', () => {
it('should store code session by socket.id and user._id', () => {
const mockSocket = createMockSocket('code-socket-123');
describe("Code Session Indexing", () => {
it("should store code session by socket.id and user._id", () => {
const mockSocket = createMockSocket("code-socket-123");
const mockCodeSession = {
socket: mockSocket,
user: mockUser,
type: 'code',
type: "code",
};
// Store in both indexes
SocketService.codeSessions.set(mockSocket.id, mockCodeSession);
SocketService.codeSessionUserIndex.set(
mockUser._id.toHexString(),
mockCodeSession
);
SocketService.codeSessionUserIndex.set(mockUser._id, mockCodeSession);
// Verify both lookups work
expect(SocketService.codeSessions.get(mockSocket.id)).toBe(mockCodeSession);
expect(SocketService.codeSessionUserIndex.get(mockUser._id.toHexString())).toBe(mockCodeSession);
expect(SocketService.codeSessions.get(mockSocket.id)).toBe(
mockCodeSession,
);
expect(SocketService.codeSessionUserIndex.get(mockUser._id)).toBe(
mockCodeSession,
);
});
it('should find code session by user._id', () => {
const mockSocket = createMockSocket('code-socket-456');
it("should find code session by user._id", () => {
const mockSocket = createMockSocket("code-socket-456");
const mockCodeSession = {
socket: mockSocket,
user: mockUser,
type: 'code',
type: "code",
};
// Store in both indexes
SocketService.codeSessions.set(mockSocket.id, mockCodeSession);
SocketService.codeSessionUserIndex.set(
mockUser._id.toHexString(),
mockCodeSession
);
SocketService.codeSessionUserIndex.set(mockUser._id, mockCodeSession);
// Mock getCodeSession to use the user index
SocketService.getCodeSession.mockImplementation((user: any) => {
const session = SocketService.codeSessionUserIndex.get(user._id.toHexString());
const session = SocketService.codeSessionUserIndex.get(user._id);
if (!session) {
const error = new Error('code session not found');
const error = new Error("code session not found");
(error as any).statusCode = 404;
throw error;
}
@ -204,122 +208,148 @@ describe('SocketService Session Indexing', () => {
expect(found).toBe(mockCodeSession);
});
it('should throw 404 when code session not found', () => {
it("should throw 404 when code session not found", () => {
const nonExistentUser = createMockUser();
SocketService.getCodeSession.mockImplementation(() => {
const error = new Error('code session not found');
const error = new Error("code session not found");
(error as any).statusCode = 404;
throw error;
});
expect(() => SocketService.getCodeSession(nonExistentUser)).toThrow('code session not found');
expect(() => SocketService.getCodeSession(nonExistentUser)).toThrowError(expect.objectContaining({
statusCode: 404,
}));
expect(() => SocketService.getCodeSession(nonExistentUser)).toThrow(
"code session not found",
);
expect(() => SocketService.getCodeSession(nonExistentUser)).toThrowError(
expect.objectContaining({
statusCode: 404,
}),
);
});
it('should remove code session from all indexes on disconnect', () => {
const mockSocket = createMockSocket('code-socket-789');
it("should remove code session from all indexes on disconnect", () => {
const mockSocket = createMockSocket("code-socket-789");
const mockCodeSession = {
socket: mockSocket,
user: mockUser,
type: 'code',
type: "code",
};
// Store in indexes
SocketService.codeSessions.set(mockSocket.id, mockCodeSession);
SocketService.codeSessionUserIndex.set(
mockUser._id.toHexString(),
mockCodeSession
);
SocketService.codeSessionUserIndex.set(mockUser._id, mockCodeSession);
// Simulate disconnect
SocketService.codeSessions.delete(mockSocket.id);
SocketService.codeSessionUserIndex.delete(mockUser._id.toHexString());
SocketService.codeSessionUserIndex.delete(mockUser._id);
// Verify removal from all indexes
expect(SocketService.codeSessions.get(mockSocket.id)).toBeUndefined();
expect(SocketService.codeSessionUserIndex.get(mockUser._id.toHexString())).toBeUndefined();
expect(
SocketService.codeSessionUserIndex.get(mockUser._id),
).toBeUndefined();
});
});
describe('Chat Session Index', () => {
it('should register and retrieve code session by chatSessionId', () => {
const mockSocket = createMockSocket('code-socket-chat');
describe("Chat Session Index", () => {
it("should register and retrieve code session by chatSessionId", () => {
const mockSocket = createMockSocket("code-socket-chat");
const mockCodeSession = {
socket: mockSocket,
user: mockUser,
type: 'code',
type: "code",
};
const chatSessionId = mockChatSession._id.toHexString();
const chatSessionId = mockChatSession._id;
// Mock the retrieval to return our session after registration
SocketService.registerChatSession(chatSessionId, mockCodeSession);
SocketService.getCodeSessionByChatSessionId.mockReturnValue(mockCodeSession);
SocketService.getCodeSessionByChatSessionId.mockReturnValue(
mockCodeSession,
);
const found = SocketService.getCodeSessionByChatSessionId(mockChatSession._id);
const found = SocketService.getCodeSessionByChatSessionId(
mockChatSession._id,
);
expect(found).toBe(mockCodeSession);
expect(SocketService.registerChatSession).toHaveBeenCalledWith(chatSessionId, mockCodeSession);
expect(SocketService.getCodeSessionByChatSessionId).toHaveBeenCalledWith(mockChatSession._id);
expect(SocketService.registerChatSession).toHaveBeenCalledWith(
chatSessionId,
mockCodeSession,
);
expect(SocketService.getCodeSessionByChatSessionId).toHaveBeenCalledWith(
mockChatSession._id,
);
});
it('should handle chatSessionId as string or ObjectId', () => {
const mockSocket = createMockSocket('code-socket-chat2');
it("should handle chatSessionId as string or ObjectId", () => {
const mockSocket = createMockSocket("code-socket-chat2");
const mockCodeSession = {
socket: mockSocket,
user: mockUser,
type: 'code',
type: "code",
};
const chatSessionId = mockChatSession._id.toHexString();
const chatSessionId = mockChatSession._id;
SocketService.registerChatSession(chatSessionId, mockCodeSession);
SocketService.getCodeSessionByChatSessionId.mockReturnValue(mockCodeSession);
SocketService.getCodeSessionByChatSessionId.mockReturnValue(
mockCodeSession,
);
// Test with string
const found1 = SocketService.getCodeSessionByChatSessionId(chatSessionId);
expect(found1).toBe(mockCodeSession);
// Test with ObjectId
const found2 = SocketService.getCodeSessionByChatSessionId(mockChatSession._id);
const found2 = SocketService.getCodeSessionByChatSessionId(
mockChatSession._id,
);
expect(found2).toBe(mockCodeSession);
});
it('should throw 404 when chat session not found', () => {
const nonExistentChatSessionId = new Types.ObjectId();
it("should throw 404 when chat session not found", () => {
const nonExistentChatSessionId = nanoid();
SocketService.getCodeSessionByChatSessionId.mockImplementation(() => {
const error = new Error('code session not found for chat session');
const error = new Error("code session not found for chat session");
(error as any).statusCode = 404;
throw error;
});
expect(() => SocketService.getCodeSessionByChatSessionId(nonExistentChatSessionId)).toThrow('code session not found for chat session');
expect(() => SocketService.getCodeSessionByChatSessionId(nonExistentChatSessionId)).toThrowError(expect.objectContaining({
statusCode: 404,
}));
expect(() =>
SocketService.getCodeSessionByChatSessionId(nonExistentChatSessionId),
).toThrow("code session not found for chat session");
expect(() =>
SocketService.getCodeSessionByChatSessionId(nonExistentChatSessionId),
).toThrowError(
expect.objectContaining({
statusCode: 404,
}),
);
});
it('should unregister chat session', () => {
const mockSocket = createMockSocket('code-socket-chat3');
it("should unregister chat session", () => {
const mockSocket = createMockSocket("code-socket-chat3");
const mockCodeSession = {
socket: mockSocket,
user: mockUser,
type: 'code',
type: "code",
};
const chatSessionId = mockChatSession._id.toHexString();
const chatSessionId = mockChatSession._id;
SocketService.registerChatSession(chatSessionId, mockCodeSession);
SocketService.getCodeSessionByChatSessionId.mockReturnValue(mockCodeSession);
SocketService.getCodeSessionByChatSessionId.mockReturnValue(
mockCodeSession,
);
// Verify it's registered
const found = SocketService.getCodeSessionByChatSessionId(chatSessionId);
expect(found).toBe(mockCodeSession);
// Unregister
SocketService.unregisterChatSession(chatSessionId);
expect(SocketService.unregisterChatSession).toHaveBeenCalledWith(chatSessionId);
expect(SocketService.unregisterChatSession).toHaveBeenCalledWith(
chatSessionId,
);
});
});
});

View File

@ -25,7 +25,6 @@ import {
RequestSessionLockCallback,
RequestWorkspaceModeCallback,
ServerToClientEvents,
Types,
WorkspaceMode,
} from "@gadget/api";
@ -96,7 +95,7 @@ class GadgetDrone extends GadgetProcess {
// Update workspace with registration
WorkspaceService.updateRegistration({
_id: this.registration._id.toHexString(),
_id: this.registration._id,
status: DroneStatus.Starting,
});
await WorkspaceService.writeWorkspaceData();
@ -118,7 +117,7 @@ class GadgetDrone extends GadgetProcess {
await PlatformService.setStatus(DroneStatus.Available);
WorkspaceService.updateRegistration({
_id: this.registration._id.toHexString(),
_id: this.registration._id,
status: DroneStatus.Available,
});
await WorkspaceService.writeWorkspaceData();
@ -219,25 +218,6 @@ class GadgetDrone extends GadgetProcess {
chatSession: IChatSession,
cb: RequestSessionLockCallback,
) {
/*
* Convert the IDs we'll actually use into ObjectId instances as expected in
* the interfaces.
*/
registration._id = Types.ObjectId.createFromHexString(
registration._id as unknown as string,
);
project._id = Types.ObjectId.createFromHexString(
project._id as unknown as string,
);
chatSession._id = Types.ObjectId.createFromHexString(
chatSession._id as unknown as string,
);
/*
* Process the request
*/
this.log.info("requestSessionLock received", {
registration,
project,
@ -249,19 +229,19 @@ class GadgetDrone extends GadgetProcess {
);
return cb(false, "not registered");
}
if (!registration._id.equals(this.registration._id)) {
if (registration._id !== this.registration._id) {
this.log.warn(
"received session lock request for a different drone registration",
{
myId: this.registration._id.toHexString(),
requestId: registration._id.toHexString(),
myId: this.registration._id,
requestId: registration._id,
},
);
return cb(false, "invalid registration");
}
this.workspaceMode = WorkspaceMode.User;
cb(true, chatSession._id.toHexString());
cb(true, chatSession._id);
}
async onRequestWorkspaceMode(
@ -311,9 +291,9 @@ class GadgetDrone extends GadgetProcess {
// Write work order cache BEFORE processing (for crash recovery)
try {
await WorkspaceService.writeWorkOrderCache(
turn._id.toHexString(),
chatSession._id.toHexString(),
project._id.toHexString(),
turn._id,
chatSession._id,
project._id,
turn.prompts.user,
);
} catch (error) {
@ -372,16 +352,16 @@ class GadgetDrone extends GadgetProcess {
async getUserCredentials(): Promise<UserCredentials> {
const args = process.argv.slice(2);
const userArg = args.find(a => a.startsWith('--user='));
const passArg = args.find(a => a.startsWith('--password='));
const userArg = args.find((a) => a.startsWith("--user="));
const passArg = args.find((a) => a.startsWith("--password="));
if (userArg && passArg) {
return {
email: userArg.split('=')[1],
password: passArg.split('=')[1],
email: userArg.split("=")[1],
password: passArg.split("=")[1],
};
}
return {
email: await inqInput({ message: "📧 Enter Drone Email: " }),
password: await inqPassword({ message: "🔑 Enter Password: " }),

View File

@ -4,10 +4,7 @@
import { Types } from "@gadget/api";
import { Socket } from "socket.io-client";
import {
IAiChatOptions,
type IContextChatMessage,
} from "@gadget/ai";
import { IAiChatOptions, type IContextChatMessage } from "@gadget/ai";
import {
IChatSession,
IChatTurn,
@ -121,15 +118,13 @@ class AgentService extends GadgetService {
} while (keepProcessing);
// Emit work order complete
socket.emit("workOrderComplete", turn._id.toHexString(), true);
socket.emit("workOrderComplete", turn._id, true);
}
buildSessionContext(workOrder: IAgentWorkOrder): IContextChatMessage[] {
const session = workOrder.turn.session;
if (session instanceof Types.ObjectId || !session.user) {
throw new Error(
"ChatSession must be populated with user data",
);
const session = workOrder.turn.session as IChatSession;
if (!session.user) {
throw new Error("ChatSession must be populated with user data");
}
const user: IUser = session.user as IUser;
const messages: IContextChatMessage[] = [];
@ -143,7 +138,7 @@ class AgentService extends GadgetService {
role: "user",
content: turn.prompts.user,
user: {
_id: user._id.toHexString(),
_id: user._id,
username: user.email,
displayName: user.displayName,
},

View File

@ -2,7 +2,7 @@
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0
import { Types, IAiProvider as DbAiProvider } from "@gadget/api";
import { IAiProvider as DbAiProvider } from "@gadget/api";
import { GadgetService } from "../lib/service.ts";
import {
type IAiChatOptions,
@ -14,12 +14,13 @@ import {
type IAiResponseStreamFn,
createAiApi,
} from "@gadget/ai";
import { GadgetId } from "../../../packages/api/dist/lib/gadget-id.js";
/**
* Drone-specific model config that accepts the database provider type.
*/
export interface IDroneModelConfig {
provider: DbAiProvider | Types.ObjectId;
provider: DbAiProvider | GadgetId;
modelId: string;
params: {
reasoning: boolean;
@ -60,16 +61,12 @@ class AiService extends GadgetService {
* The DB model uses `apiType` and extends Mongoose Document, while the runtime
* config uses `sdk` and is a plain object.
*/
mapDbProviderToConfig(
provider: DbAiProvider | Types.ObjectId,
): AiProviderConfig {
if (provider instanceof Types.ObjectId) {
throw new Error(
"Provider must be populated, not an ObjectId reference",
);
mapDbProviderToConfig(provider: DbAiProvider | GadgetId): AiProviderConfig {
if (typeof provider === "string") {
throw new Error("Provider must be populated, not a GadgetId reference");
}
return {
_id: provider._id.toHexString(),
_id: provider._id,
name: provider.name,
sdk: provider.apiType, // map apiType → sdk
baseUrl: provider.baseUrl,
@ -81,7 +78,7 @@ class AiService extends GadgetService {
* Query the list of models available from the provider, then queries the
* models for their individual capabilities. The results are cached in the Gadget
*/
async discovery(provider: DbAiProvider | Types.ObjectId): Promise<void> {
async discovery(provider: DbAiProvider | GadgetId): Promise<void> {
const config = this.mapDbProviderToConfig(provider);
this.log.info("discovering provider model list", {
name: config.name,
@ -93,7 +90,7 @@ class AiService extends GadgetService {
}
async generate(
provider: DbAiProvider | Types.ObjectId,
provider: DbAiProvider | GadgetId,
model: Omit<IAiModelConfig, "provider">,
options: IAiGenerateOptions,
streamCallback?: IAiResponseStreamFn,
@ -111,7 +108,7 @@ class AiService extends GadgetService {
}
async chat(
provider: DbAiProvider | Types.ObjectId,
provider: DbAiProvider | GadgetId,
model: Omit<IAiModelConfig, "provider">,
options: IAiChatOptions,
streamCallback?: IAiResponseStreamFn,

View File

@ -9,7 +9,7 @@ import path from "node:path";
import os from "node:os";
import { GadgetService } from "../lib/service.ts";
import { DroneStatus, IDroneRegistration, Types } from "@gadget/api";
import { DroneStatus, IDroneRegistration, IUser, Types } from "@gadget/api";
interface PlatformApiResponse {
success: boolean;
@ -76,21 +76,6 @@ class PlatformService extends GadgetService {
throw error;
}
/*
* Convert the _id's into ObjectId instances as expected.
*/
if (json.data._id) {
json.data._id = Types.ObjectId.createFromHexString(
json.data._id as unknown as string, // it's intentional
);
}
if (json.data.user && json.data.user._id) {
json.data.user._id = Types.ObjectId.createFromHexString(
json.data.user._id as unknown as string, // it's intentional
);
}
if (!json.data || !json.data._id) {
const error = new Error(
"registration response did not contain required data",
@ -99,7 +84,7 @@ class PlatformService extends GadgetService {
throw error;
}
if (!json.data.user || !json.data.user._id) {
if (!json.data.user || !(json.data.user as IUser)._id) {
const error = new Error(
"registration response did not contain required user account data",
);

View File

@ -2,6 +2,8 @@
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
// All Rights Reserved
export * from "./lib/gadget-id.ts";
/*
* Data Model Interfaces
*/

View File

@ -2,6 +2,9 @@
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0
import { HydratedDocument } from "mongoose";
import { GadgetId } from "../lib/gadget-id.ts";
export type AiApiType = "ollama" | "openai";
/**
@ -46,9 +49,8 @@ export interface IAiModel {
settings?: IAiModelSettings;
}
import { Document } from "mongoose";
export interface IAiProvider extends Document {
export interface IAiProvider {
_id: GadgetId;
name: string;
apiType: AiApiType;
baseUrl: string;
@ -57,3 +59,5 @@ export interface IAiProvider extends Document {
models: IAiModel[];
lastModelRefresh: Date;
}
export type AiProviderDocument = HydratedDocument<IAiProvider>;

View File

@ -2,10 +2,13 @@
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0
import { Document, Types } from "mongoose";
import type { IUser } from "./user.js";
import type { IProject } from "./project.js";
import { GadgetId } from "../lib/gadget-id.ts";
import { IAiProvider } from "./ai-provider.ts";
import { HydratedDocument } from "mongoose";
export enum ChatSessionMode {
Plan = "plan", // for planning and brainstorming
Build = "build", // for building and coding
@ -15,18 +18,19 @@ export enum ChatSessionMode {
}
export interface IChatSessionPin {
_id?: Types.ObjectId;
_id: string;
content: string;
}
export interface IChatSession extends Document {
export interface IChatSession {
_id: GadgetId;
createdAt: Date;
lastMessageAt?: Date;
user: IUser | Types.ObjectId;
project: IProject | Types.ObjectId;
user: IUser | GadgetId;
project: IProject | GadgetId;
name: string;
mode: ChatSessionMode;
provider: Types.ObjectId;
provider: IAiProvider | GadgetId;
selectedModel: string;
stats: {
turnCount: number;
@ -35,4 +39,6 @@ export interface IChatSession extends Document {
outputTokens: number;
};
pins: IChatSessionPin[];
}
}
export type ChatSessionDocument = HydratedDocument<IChatSession>;

View File

@ -2,12 +2,15 @@
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0
import { Document, Types } from "mongoose";
import { HydratedDocument } from "mongoose";
import type { IUser } from "./user.js";
import type { IProject } from "./project.js";
import type { IChatSession } from "./chat-session.js";
import type { IAiProvider } from "./ai-provider.js";
import { ChatSessionMode } from "./chat-session.js";
import { GadgetId } from "../lib/gadget-id.ts";
export enum ChatTurnStatus {
Processing = "processing",
@ -49,13 +52,13 @@ export interface IChatSubagentProcess {
* stores all data generated by one run of the Agentic Workflow Loop by a Gadget
* Drone process.
*/
export interface IChatTurn extends Document {
_id: Types.ObjectId;
export interface IChatTurn {
_id: GadgetId;
createdAt: Date;
user: IUser | Types.ObjectId;
project: IProject | Types.ObjectId;
session: IChatSession | Types.ObjectId;
provider: IAiProvider | Types.ObjectId;
user: IUser | GadgetId;
project: IProject | GadgetId;
session: IChatSession | GadgetId;
provider: IAiProvider | GadgetId;
llm: string; // id/name of the model used to process the prompt
mode: ChatSessionMode; // session mode for this turn/prompt
status: ChatTurnStatus;
@ -66,3 +69,5 @@ export interface IChatTurn extends Document {
subagents: IChatSubagentProcess[]; // subagents used while processing this turn
stats: IChatTurnStats;
}
export type ChatTurnDocument = HydratedDocument<IChatTurn>;

View File

@ -2,7 +2,8 @@
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0
import { Types, Document } from "mongoose";
import { HydratedDocument } from "mongoose";
import { GadgetId } from "../lib/gadget-id.ts";
import { IDroneRegistration } from "./drone-registration.ts";
export interface IMemoryMonitor {
@ -10,9 +11,9 @@ export interface IMemoryMonitor {
bytes: number;
}
export interface IDroneMonitor extends Document {
_id: Types.ObjectId;
registration: IDroneRegistration | Types.ObjectId;
export interface IDroneMonitor {
_id: GadgetId;
registration: IDroneRegistration | GadgetId;
timestamp: Date;
memory: {
rss: number;
@ -33,3 +34,5 @@ export interface IDroneMonitor extends Document {
logs: IMemoryMonitor;
};
}
export type DroneMonitorDocument = HydratedDocument<IDroneMonitor>;

View File

@ -2,8 +2,10 @@
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0
import { Types, Document } from "mongoose";
import { HydratedDocument } from "mongoose";
import { IUser } from "./user.ts";
import { GadgetId } from "../lib/gadget-id.ts";
export enum DroneStatus {
Starting = "starting",
@ -12,11 +14,11 @@ export enum DroneStatus {
Offline = "offline",
}
export interface IDroneRegistration extends Document {
_id: Types.ObjectId;
export interface IDroneRegistration {
_id: GadgetId;
createdAt: Date;
updatedAt: Date;
user: IUser | Types.ObjectId;
user: IUser | GadgetId;
hostname: string;
workspaceDir: string;
workspaceId: string;
@ -24,3 +26,5 @@ export interface IDroneRegistration extends Document {
chatSessionId?: string;
currentJobId?: string;
}
export type DroneRegistrationDocument = HydratedDocument<IDroneRegistration>;

View File

@ -2,18 +2,22 @@
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0
import { Document, Types } from "mongoose";
import type { IUser } from "./user.js";
import type { IProject } from "./project.js";
import { GadgetId } from "../lib/gadget-id.ts";
import { HydratedDocument } from "mongoose";
/**
* When the User logs into the IDE it creates a session against which Socket.IO
* events are scoped.
*/
export interface IIdeSession extends Document {
_id: Types.ObjectId;
export interface IIdeSession {
_id: GadgetId;
createdAt: Date;
user: IUser | Types.ObjectId;
project: IProject | Types.ObjectId;
user: IUser | GadgetId;
project: IProject | GadgetId;
name: string;
}
export type IdeSessionDocument = HydratedDocument<IIdeSession>;

View File

@ -2,8 +2,9 @@
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0
import { Document, Types } from "mongoose";
import type { IUser } from "./user.js";
import { GadgetId } from "../lib/gadget-id.ts";
import { HydratedDocument } from "mongoose";
export enum ProjectStatus {
Active = "active",
@ -11,11 +12,14 @@ export enum ProjectStatus {
Archived = "archived",
}
export interface IProject extends Document {
export interface IProject {
_id: GadgetId;
createdAt: Date;
user: IUser | Types.ObjectId;
user: IUser | GadgetId;
status: ProjectStatus;
name: string;
slug: string;
gitUrl?: string;
}
export type ProjectDocument = HydratedDocument<IProject>;

View File

@ -2,6 +2,9 @@
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0
import { HydratedDocument } from "mongoose";
import { GadgetId } from "../lib/gadget-id.ts";
export interface IUserFlags {
isEmailVerified: boolean;
isAdmin: boolean;
@ -9,10 +12,8 @@ export interface IUserFlags {
isBanned: boolean;
}
import { Document, Types } from "mongoose";
export interface IUser extends Document {
_id: Types.ObjectId;
export interface IUser {
_id: GadgetId;
email: string;
email_lc: string;
passwordSalt?: string;
@ -20,3 +21,5 @@ export interface IUser extends Document {
displayName: string;
flags: IUserFlags;
}
export type UserDocument = HydratedDocument<IUser>;

View File

@ -0,0 +1,10 @@
// src/interfaces/gadget-id.ts
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0
/*
* Probably the most breakthrough piece of technology in the entire monorepo.
* /s
*/
export type GadgetId = string;

View File

@ -1,76 +0,0 @@
// src/lib/objectid.ts
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
// All Rights Reserved
import { randomBytes } from "node:crypto";
/**
* A lightweight ObjectId implementation compatible with MongoDB's ObjectId format.
* This allows packages to work with ObjectId values without requiring mongoose.
*/
export class ObjectId {
private readonly id: Buffer;
constructor(hexString?: string) {
if (hexString) {
if (!ObjectId.isValid(hexString)) {
throw new Error(`Invalid ObjectId hex string: ${hexString}`);
}
this.id = Buffer.from(hexString, "hex");
} else {
this.id = ObjectId.generate();
}
}
private static generate(): Buffer {
const timestamp = Buffer.alloc(4);
const timestampSec = Math.floor(Date.now() / 1000);
timestamp.writeUInt32BE(timestampSec, 0);
const random = randomBytes(9);
return Buffer.concat([timestamp, random]);
}
static isValid(hexString: string): boolean {
return /^[0-9a-fA-F]{24}$/.test(hexString);
}
static createFromHexString(hexString: string): ObjectId {
return new ObjectId(hexString);
}
static create(): ObjectId {
return new ObjectId();
}
toHexString(): string {
return this.id.toString("hex");
}
equals(other: ObjectId): boolean {
if (!other) {
return false;
}
return this.id.equals(other.id);
}
toString(): string {
return this.toHexString();
}
toJSON(): string {
return this.toHexString();
}
valueOf(): string {
return this.toHexString();
}
}
/**
* @deprecated Use ObjectId directly instead
*/
export const Types = {
ObjectId,
};

View File

@ -88,6 +88,9 @@ importers:
multer:
specifier: ^2.0.1
version: 2.1.1
nanoid:
specifier: ^5.1.11
version: 5.1.11
nodemailer:
specifier: ^7.0.3
version: 7.0.13
@ -2535,6 +2538,11 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
nanoid@5.1.11:
resolution: {integrity: sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==}
engines: {node: ^18 || >=20}
hasBin: true
needle@3.5.0:
resolution: {integrity: sha512-jaQyPKKk2YokHrEg+vFDYxXIHTCBgiZwSHOoVx/8V3GIBS8/VN6NdVRmg8q1ERtPkMvmOvebsgga4sAj5hls/w==}
engines: {node: '>= 4.4.x'}
@ -5467,6 +5475,8 @@ snapshots:
nanoid@3.3.11: {}
nanoid@5.1.11: {}
needle@3.5.0:
dependencies:
iconv-lite: 0.6.3