a lot of review and by-hand cleanup (wip)

This commit is contained in:
Rob Colbert 2026-05-05 05:28:09 -04:00
parent dca21cf762
commit f35a0ce921
9 changed files with 264 additions and 94 deletions

View File

@ -7,6 +7,7 @@ import { Request, Response } from "express";
import { DtpController } from "../../../lib/controller.js"; import { DtpController } from "../../../lib/controller.js";
import ChatSessionService from "../../../services/chat-session.js"; import ChatSessionService from "../../../services/chat-session.js";
import { ChatSessionMode } from "@gadget/api"; import { ChatSessionMode } from "@gadget/api";
import { populateChatSessionById } from "@/controllers/lib/populators.js";
class ChatSessionController extends DtpController { class ChatSessionController extends DtpController {
get name(): string { get name(): string {
@ -20,20 +21,19 @@ class ChatSessionController extends DtpController {
} }
async start(): Promise<void> { async start(): Promise<void> {
this.router.get("/", this.requireUser(), this.listSessions.bind(this)); this.router.use(this.requireUser());
this.router.post("/", this.requireUser(), this.createSession.bind(this));
this.router.get("/:id", this.requireUser(), this.getSession.bind(this)); this.router.param("sessionId", populateChatSessionById(this));
this.router.put("/:id", this.requireUser(), this.updateSession.bind(this));
this.router.delete( this.router.post("/", this.createSession.bind(this));
"/:id",
this.requireUser(), this.router.get("/:sessionId/turns", this.getSessionTurns.bind(this));
this.deleteSession.bind(this), this.router.get("/:sessionId", this.getSession.bind(this));
); this.router.get("/", this.listSessions.bind(this));
this.router.get(
"/:id/turns", this.router.put("/:sessionId", this.updateSession.bind(this));
this.requireUser(),
this.getSessionTurns.bind(this), this.router.delete("/:sessionId", this.deleteSession.bind(this));
);
} }
/** /**
@ -140,24 +140,11 @@ class ChatSessionController extends DtpController {
* GET /api/v1/chat-sessions/:id * GET /api/v1/chat-sessions/:id
* Get a specific chat session. * Get a specific chat session.
*/ */
private async getSession(req: Request, res: Response): Promise<void> { private async getSession(_req: Request, res: Response): Promise<void> {
try { try {
const id = Array.isArray(req.params.id)
? req.params.id[0]
: req.params.id;
if (!id) {
res.status(400).json({
success: false,
message: "Session ID is required",
});
return;
}
const session = await ChatSessionService.getById(id);
res.json({ res.json({
success: true, success: true,
data: session, data: res.locals.chatSession,
}); });
} catch (error) { } catch (error) {
const err = error as Error; const err = error as Error;
@ -183,16 +170,6 @@ class ChatSessionController extends DtpController {
*/ */
private async updateSession(req: Request, res: Response): Promise<void> { private async updateSession(req: Request, res: Response): Promise<void> {
try { try {
const id = Array.isArray(req.params.id)
? req.params.id[0]
: req.params.id;
if (!id) {
res.status(400).json({
success: false,
message: "Session ID is required",
});
return;
}
const updates = req.body; const updates = req.body;
// Validate allowed updates // Validate allowed updates
@ -217,7 +194,10 @@ class ChatSessionController extends DtpController {
ChatSessionMode[updates.mode as keyof typeof ChatSessionMode]; ChatSessionMode[updates.mode as keyof typeof ChatSessionMode];
} }
const session = await ChatSessionService.update(id, allowedUpdates); const session = await ChatSessionService.update(
res.locals.chatSession._id,
allowedUpdates,
);
res.json({ res.json({
success: true, success: true,
@ -245,21 +225,9 @@ class ChatSessionController extends DtpController {
* DELETE /api/v1/chat-sessions/:id * DELETE /api/v1/chat-sessions/:id
* Delete a chat session. * Delete a chat session.
*/ */
private async deleteSession(req: Request, res: Response): Promise<void> { private async deleteSession(_req: Request, res: Response): Promise<void> {
try { try {
const id = Array.isArray(req.params.id) await ChatSessionService.delete(res.locals.chatSession._id);
? req.params.id[0]
: req.params.id;
if (!id) {
res.status(400).json({
success: false,
message: "Session ID is required",
});
return;
}
await ChatSessionService.delete(id);
res.json({ res.json({
success: true, success: true,
message: "Chat session deleted", message: "Chat session deleted",
@ -286,20 +254,11 @@ class ChatSessionController extends DtpController {
* GET /api/v1/chat-sessions/:id/turns * GET /api/v1/chat-sessions/:id/turns
* Get all turns for a chat session. * Get all turns for a chat session.
*/ */
private async getSessionTurns(req: Request, res: Response): Promise<void> { private async getSessionTurns(_req: Request, res: Response): Promise<void> {
try { try {
const id = Array.isArray(req.params.id) const turns = await ChatSessionService.getTurns(
? req.params.id[0] res.locals.chatSession._id,
: req.params.id; );
if (!id) {
res.status(400).json({
success: false,
message: "Session ID is required",
});
return;
}
const turns = await ChatSessionService.getTurns(id);
res.json({ res.json({
success: true, success: true,

View File

@ -9,11 +9,42 @@ import { DtpController } from "../../lib/controller.ts";
import DroneService from "../../services/drone.ts"; import DroneService from "../../services/drone.ts";
import UserService from "../../services/user.ts"; import UserService from "../../services/user.ts";
import { ChatSessionService } from "../../services/index.js";
export interface PopulateOptions { export interface PopulateOptions {
requireObject?: boolean; requireObject?: boolean;
} }
export function populateChatSessionById(
controller: DtpController,
options?: PopulateOptions,
): RequestHandler {
options = Object.assign({ requireObject: true }, options);
return async function (
_req: Request,
res: Response,
next: NextFunction,
sessionId?: string,
): Promise<void> {
assert(sessionId, "ChatSession ID is required");
try {
res.locals.chatSession = await ChatSessionService.getById(sessionId);
if (options.requireObject && !res.locals.chatSession) {
const error = new Error("ChatSession not found");
error.statusCode = 404;
throw error;
}
return next();
} catch (error) {
controller.log.error("failed to populate ChatSession by ID", {
sessionId,
error,
});
return next(error);
}
};
}
export function populateUserById( export function populateUserById(
controller: DtpController, controller: DtpController,
options?: PopulateOptions, options?: PopulateOptions,

View File

@ -4,7 +4,7 @@
import { Schema, model } from "mongoose"; import { Schema, model } from "mongoose";
import { GadgetId } from "@gadget/api"; import { GadgetId, IUser } from "@gadget/api";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
export enum ApiClientStatus { export enum ApiClientStatus {
@ -13,6 +13,18 @@ export enum ApiClientStatus {
Archived = "archived", Archived = "archived",
} }
/**
* An API client is either machine-to-machine (no User), or a User access client
* that can call services on the User's scoped behalf. When a request presents
* an ApiClient token, we populate req.user with that User account. The
* remainder of the request proceeds with that User's authentication and
* authorization.
*
* An example of non-User communication is a gadget-drone performing an initial
* Platform registration. It presents the User's credentials to authenticate the
* request, and we verify the API key to authenticate that client before
* trusting the credentials presented.
*/
export interface IApiClient { export interface IApiClient {
_id: GadgetId; _id: GadgetId;
createdAt: Date; createdAt: Date;
@ -21,6 +33,7 @@ export interface IApiClient {
name: string; name: string;
description?: string; description?: string;
secret: string; secret: string;
user?: IUser | GadgetId;
} }
const ApiClientSchema = new Schema<IApiClient>({ const ApiClientSchema = new Schema<IApiClient>({
_id: { type: String, default: () => nanoid() }, _id: { type: String, default: () => nanoid() },
@ -36,6 +49,7 @@ const ApiClientSchema = new Schema<IApiClient>({
name: { type: String, required: true }, name: { type: String, required: true },
description: { type: String }, description: { type: String },
secret: { type: String, required: true }, secret: { type: String, required: true },
user: { type: String, ref: "User", index: 1 },
}); });
export const ApiClient = model<IApiClient>("ApiClient", ApiClientSchema); export const ApiClient = model<IApiClient>("ApiClient", ApiClientSchema);

View File

@ -5,7 +5,7 @@
// import env, { getCountryName } from "../config/env.js"; // import env, { getCountryName } from "../config/env.js";
import assert from "node:assert"; import assert from "node:assert";
import { Request } from "express"; import { NextFunction, Request, RequestHandler, Response } from "express";
import { filterText } from "dtp-cleantext"; import { filterText } from "dtp-cleantext";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
@ -18,8 +18,16 @@ import ApiClientLog, { IApiClientLog } from "../models/api-client-log.js";
import { DtpService } from "../lib/service.js"; import { DtpService } from "../lib/service.js";
import { GadgetId } from "@gadget/api"; import { GadgetId } from "@gadget/api";
import { PopulateOptions } from "mongoose";
class ApiClientService extends DtpService { class ApiClientService extends DtpService {
private populateApiClient: PopulateOptions[] = [
{
path: "user",
select: "-passwordSalt -password",
},
];
get name(): string { get name(): string {
return "ApiClientService"; return "ApiClientService";
} }
@ -35,12 +43,45 @@ class ApiClientService extends DtpService {
async stop(): Promise<void> {} async stop(): Promise<void> {}
middleware(): RequestHandler {
return async (
req: Request,
res: Response,
next: NextFunction,
): Promise<void> => {
try {
const apiClientId = req.header("X-Gadget-Key") as string;
if (!apiClientId) {
return next();
}
const apiClient = await this.getById(apiClientId);
if (!apiClient) {
return next();
}
if (apiClient.user && !req.user) {
req.user = apiClient.user;
res.locals.user = apiClient.user;
}
await this.logRequest(apiClient, req);
return next();
} catch (error) {
this.log.error("failed to process ApiClient request", { error });
return next(error);
}
};
}
async create(definition: Partial<IApiClient>): Promise<IApiClient> { async create(definition: Partial<IApiClient>): Promise<IApiClient> {
const NOW = new Date(); const NOW = new Date();
const apiClient = new ApiClient(); const apiClient = new ApiClient();
apiClient.createdAt = NOW; apiClient.createdAt = NOW;
apiClient.updatedAt = NOW; apiClient.updatedAt = NOW;
apiClient.status = ApiClientStatus.Active; apiClient.status = ApiClientStatus.Active;
apiClient.user = definition.user;
assert(definition.name, "ApiClient name is required"); assert(definition.name, "ApiClient name is required");
apiClient.name = filterText(definition.name); apiClient.name = filterText(definition.name);
@ -77,7 +118,9 @@ class ApiClientService extends DtpService {
} }
async getById(clientId: GadgetId): Promise<IApiClient | null> { async getById(clientId: GadgetId): Promise<IApiClient | null> {
const client = await ApiClient.findOne({ _id: clientId }); const client = await ApiClient.findOne({ _id: clientId })
.populate(this.populateApiClient)
.lean();
return client; return client;
} }

View File

@ -2,6 +2,11 @@
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us> // Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
// All Rights Reserved // All Rights Reserved
import env from "../config/env.js";
import path from "node:path";
import fs from "node:fs";
import { import {
IChatSession, IChatSession,
ChatSessionMode, ChatSessionMode,
@ -47,7 +52,7 @@ class ChatSessionService extends DtpService {
}, },
{ {
path: "provider", path: "provider",
select: "-models", select: "-models +apiKey",
}, },
]; ];
@ -234,6 +239,8 @@ class ChatSessionService extends DtpService {
const user: IUser = session.user as IUser; const user: IUser = session.user as IUser;
const project: IProject = session.project as IProject; const project: IProject = session.project as IProject;
const systemPrompt = await this.buildSystemPrompt(session);
let turn = new ChatTurn({ let turn = new ChatTurn({
createdAt: NOW, createdAt: NOW,
user: user._id, user: user._id,
@ -244,8 +251,8 @@ class ChatSessionService extends DtpService {
mode: session.mode, mode: session.mode,
status: ChatTurnStatus.Processing, status: ChatTurnStatus.Processing,
prompts: { prompts: {
system: systemPrompt,
user: prompt, user: prompt,
system: undefined,
}, },
toolCalls: [], toolCalls: [],
subagents: [], subagents: [],
@ -265,6 +272,40 @@ class ChatSessionService extends DtpService {
return turn; return turn;
} }
async buildSystemPrompt(session: IChatSession): Promise<string> {
const commonDir = path.join(env.installDir, "data", "prompts", "common");
const promptsDir = path.join(
env.installDir,
"data",
"prompts",
"agent",
session.mode,
);
const common = {
scopeBlock: await fs.promises.readFile(
path.join(commonDir, "scope-block.md"),
"utf-8",
),
subagentsBlock: await fs.promises.readFile(
path.join(commonDir, "subagents.md"),
"utf-8",
),
};
const templateFilename = path.join(promptsDir, "system.md");
const promptTemplate = await fs.promises.readFile(
templateFilename,
"utf-8",
);
let prompt = promptTemplate
.replace("{{scope_block}}", common.scopeBlock)
.replace("{{subagent_section}}", common.subagentsBlock);
return prompt;
}
/** /**
* Gets all turns for a chat session. * Gets all turns for a chat session.
*/ */
@ -272,10 +313,9 @@ class ChatSessionService extends DtpService {
const turns = await ChatTurn.find({ session: chatSessionId }) const turns = await ChatTurn.find({ session: chatSessionId })
.populate("user", "-passwordSalt -password") .populate("user", "-passwordSalt -password")
.populate("project") .populate("project")
.populate("provider") .populate("provider", "-models")
.sort({ createdAt: 1 }) .sort({ createdAt: 1 })
.lean(); .lean();
return turns; return turns;
} }

View File

@ -42,6 +42,7 @@ import { HomeController } from "./controllers/home.js";
import { UserController } from "./controllers/user.js"; import { UserController } from "./controllers/user.js";
import { import {
ApiClientService,
SessionService, SessionService,
SocketService, SocketService,
startServices, startServices,
@ -198,7 +199,9 @@ class DtpWebAppServer implements DtpComponent {
}, },
store, store,
}; };
this.app.use(session(sessionConfig)); this.app.use(session(sessionConfig));
this.app.use(ApiClientService.middleware());
this.app.use(this.restoreUserSession.bind(this)); this.app.use(this.restoreUserSession.bind(this));
this.app.use("/", await this.createAppRouter()); this.app.use("/", await this.createAppRouter());

View File

@ -10,7 +10,7 @@ import "./lib/db.js";
* Models * Models
*/ */
import ApiClient, { ApiClientStatus } from "./models/api-client.js"; import ApiClient, { ApiClientStatus, IApiClient } from "./models/api-client.js";
import User from "./models/user.js"; import User from "./models/user.js";
import AiProvider from "./models/ai-provider.js"; import AiProvider from "./models/ai-provider.js";
@ -33,6 +33,7 @@ import {
import { createAiApi, type IAiLogger } from "@gadget/ai"; import { createAiApi, type IAiLogger } from "@gadget/ai";
import { import {
IUser,
type IAiModel, type IAiModel,
type IAiModelCapabilities, type IAiModelCapabilities,
type IAiModelSettings, type IAiModelSettings,
@ -157,29 +158,49 @@ class DtpWebCli extends DtpProcess {
const name = argv.shift(); const name = argv.shift();
const description = argv.shift(); const description = argv.shift();
const client = await ApiClientService.create({ const email = argv.shift();
let user;
if (email) {
user = await UserService.getByEmail(email);
if (!user) {
throw new Error("user not found");
}
}
const definition: Partial<IApiClient> = {
name, name,
description, description,
}); };
this.log.info("api client added", { if (user) {
client: { definition.user = user._id;
_id: client._id, }
secret: client.secret,
name: client.name, const client = await ApiClientService.create(definition);
}, this.printApiClientList([client]);
});
} }
async onApiClientList(_argv: string[]): Promise<void> { async onApiClientList(_argv: string[]): Promise<void> {
const clients = await ApiClient.find({ status: ApiClientStatus.Active }) const clients: IApiClient[] = await ApiClient.find({
status: ApiClientStatus.Active,
})
.sort({ name: 1 }) .sort({ name: 1 })
.populate([{ path: "user", select: "-passwordSalt -password" }])
.lean(); .lean();
this.printApiClientList(clients);
}
printApiClientList(clients: IApiClient[]) {
console.log("Name".padEnd(20), "Client ID".padEnd(24), "Secret"); console.log("Name".padEnd(20), "Client ID".padEnd(24), "Secret");
console.log( console.log(
"--------------------------------------------------------------------------------", "--------------------------------------------------------------------------------",
); );
for (const client of clients) { for (const client of clients) {
console.log(client.name.padEnd(20), client._id.toString(), client.secret); let log = `${client.name.padEnd(20)} ${client._id.toString()} ${client.secret}`;
if (client.user) {
const user = client.user as IUser;
log += ` ${user._id} ${user.email}`;
}
console.log(log);
} }
} }
@ -223,6 +244,8 @@ class DtpWebCli extends DtpProcess {
switch (action) { switch (action) {
case "add": case "add":
return this.onUserAdd(argv); return this.onUserAdd(argv);
case "view":
return this.onUserView(argv);
case "password": case "password":
return this.onUserPassword(argv); return this.onUserPassword(argv);
case "remove": case "remove":
@ -253,6 +276,20 @@ class DtpWebCli extends DtpProcess {
this.log.info(`user created: id:${user._id}, email:${user.email}`); this.log.info(`user created: id:${user._id}, email:${user.email}`);
} }
async onUserView(argv: string[]): Promise<void> {
const email = argv.shift();
if (!email) {
throw new Error("must specify email address");
}
const user = await UserService.getByEmail(email);
if (!user) {
throw new Error("user not found");
}
this.log.info("user account", user);
}
async onUserRemove(argv: string[]): Promise<void> { async onUserRemove(argv: string[]): Promise<void> {
let email = argv.shift(); let email = argv.shift();
if (!email) { if (!email) {

View File

@ -442,9 +442,16 @@ class GadgetDrone extends GadgetProcess {
return cb(false, "this drone's workspace is not in Agent mode"); return cb(false, "this drone's workspace is not in Agent mode");
} }
if (!this.socket) {
this.log.error("cannot process work order: no socket connection");
cb(false, "No socket connection");
return;
}
const context = await PlatformService.getChatSessionContext(session);
const order: IAgentWorkOrder = { const order: IAgentWorkOrder = {
createdAt: turn.createdAt, createdAt: turn.createdAt,
context: [], context: context.data,
turn, turn,
}; };
this.log.info("processWorkOrder received", { this.log.info("processWorkOrder received", {
@ -454,12 +461,6 @@ class GadgetDrone extends GadgetProcess {
turn: { _id: turn._id, mode: turn.mode, userPrompt: turn.prompts.user }, turn: { _id: turn._id, mode: turn.mode, userPrompt: turn.prompts.user },
}); });
if (!this.socket) {
this.log.error("cannot process work order: no socket connection");
cb(false, "No socket connection");
return;
}
// Write work order cache BEFORE processing (for crash recovery) // Write work order cache BEFORE processing (for crash recovery)
try { try {
await WorkspaceService.writeWorkOrderCache(turn); await WorkspaceService.writeWorkOrderCache(turn);

View File

@ -9,7 +9,14 @@ import path from "node:path";
import os from "node:os"; import os from "node:os";
import { GadgetService } from "../lib/service.ts"; import { GadgetService } from "../lib/service.ts";
import { DroneStatus, IDroneRegistration, IUser, Types } from "@gadget/api"; import {
DroneStatus,
IChatSession,
IChatTurn,
IDroneRegistration,
IUser,
Types,
} from "@gadget/api";
interface PlatformApiResponse { interface PlatformApiResponse {
success: boolean; success: boolean;
@ -20,6 +27,10 @@ interface PlatformRegistrationResponse extends PlatformApiResponse {
data: IDroneRegistration; data: IDroneRegistration;
} }
interface ChatSessionContextResponse extends PlatformApiResponse {
data: IChatTurn[];
}
class PlatformService extends GadgetService { class PlatformService extends GadgetService {
registration: IDroneRegistration | undefined; registration: IDroneRegistration | undefined;
@ -157,6 +168,37 @@ class PlatformService extends GadgetService {
this.log.info("drone status updated on platform", { status }); this.log.info("drone status updated on platform", { status });
} }
async getChatSessionContext(
session: IChatSession,
): Promise<ChatSessionContextResponse> {
assert(
this.registration,
"must register with platform before setting status",
);
const url = this.getApiUrl(`/chat-sessions/${session._id}/turns`);
const response = await fetch(url, {
method: "GET",
headers: {
Accept: "application/json",
"X-Gadget-Key": env.platform.apiKey,
},
});
const json = (await response.json()) as ChatSessionContextResponse;
if (!json.success) {
const error = new Error("failed to retrieve chat session context");
error.name = "PlatformError";
error.statusCode = response.status;
throw error;
}
this.log.info("chat session context received", {
turnCount: json.data.length,
});
return json;
}
getApiUrl(url: string): string { getApiUrl(url: string): string {
return `${env.platform.baseUrl}/api/v1${url}`; return `${env.platform.baseUrl}/api/v1${url}`;
} }