nexus/server/src/routes/projects.ts
dotta 1f1fe9c989 Add workspace runtime controls
Expose project and execution workspace runtime defaults, control endpoints, startup recovery, and operator UI for start/stop/restart flows.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:55:26 -05:00

435 lines
14 KiB
TypeScript

import { Router, type Request } from "express";
import type { Db } from "@paperclipai/db";
import {
createProjectSchema,
createProjectWorkspaceSchema,
isUuidLike,
updateProjectSchema,
updateProjectWorkspaceSchema,
} from "@paperclipai/shared";
import { validate } from "../middleware/validate.js";
import { projectService, logActivity, workspaceOperationService } from "../services/index.js";
import { conflict } from "../errors.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
import { startRuntimeServicesForWorkspaceControl, stopRuntimeServicesForProjectWorkspace } from "../services/workspace-runtime.js";
export function projectRoutes(db: Db) {
const router = Router();
const svc = projectService(db);
const workspaceOperations = workspaceOperationService(db);
async function resolveCompanyIdForProjectReference(req: Request) {
const companyIdQuery = req.query.companyId;
const requestedCompanyId =
typeof companyIdQuery === "string" && companyIdQuery.trim().length > 0
? companyIdQuery.trim()
: null;
if (requestedCompanyId) {
assertCompanyAccess(req, requestedCompanyId);
return requestedCompanyId;
}
if (req.actor.type === "agent" && req.actor.companyId) {
return req.actor.companyId;
}
return null;
}
async function normalizeProjectReference(req: Request, rawId: string) {
if (isUuidLike(rawId)) return rawId;
const companyId = await resolveCompanyIdForProjectReference(req);
if (!companyId) return rawId;
const resolved = await svc.resolveByReference(companyId, rawId);
if (resolved.ambiguous) {
throw conflict("Project shortname is ambiguous in this company. Use the project ID.");
}
return resolved.project?.id ?? rawId;
}
router.param("id", async (req, _res, next, rawId) => {
try {
req.params.id = await normalizeProjectReference(req, rawId);
next();
} catch (err) {
next(err);
}
});
router.get("/companies/:companyId/projects", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const result = await svc.list(companyId);
res.json(result);
});
router.get("/projects/:id", async (req, res) => {
const id = req.params.id as string;
const project = await svc.getById(id);
if (!project) {
res.status(404).json({ error: "Project not found" });
return;
}
assertCompanyAccess(req, project.companyId);
res.json(project);
});
router.post("/companies/:companyId/projects", validate(createProjectSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
type CreateProjectPayload = Parameters<typeof svc.create>[1] & {
workspace?: Parameters<typeof svc.createWorkspace>[1];
};
const { workspace, ...projectData } = req.body as CreateProjectPayload;
const project = await svc.create(companyId, projectData);
let createdWorkspaceId: string | null = null;
if (workspace) {
const createdWorkspace = await svc.createWorkspace(project.id, workspace);
if (!createdWorkspace) {
await svc.remove(project.id);
res.status(422).json({ error: "Invalid project workspace payload" });
return;
}
createdWorkspaceId = createdWorkspace.id;
}
const hydratedProject = workspace ? await svc.getById(project.id) : project;
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "project.created",
entityType: "project",
entityId: project.id,
details: {
name: project.name,
workspaceId: createdWorkspaceId,
},
});
res.status(201).json(hydratedProject ?? project);
});
router.patch("/projects/:id", validate(updateProjectSchema), async (req, res) => {
const id = req.params.id as string;
const existing = await svc.getById(id);
if (!existing) {
res.status(404).json({ error: "Project not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const body = { ...req.body };
if (typeof body.archivedAt === "string") {
body.archivedAt = new Date(body.archivedAt);
}
const project = await svc.update(id, body);
if (!project) {
res.status(404).json({ error: "Project not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: project.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "project.updated",
entityType: "project",
entityId: project.id,
details: req.body,
});
res.json(project);
});
router.get("/projects/:id/workspaces", async (req, res) => {
const id = req.params.id as string;
const existing = await svc.getById(id);
if (!existing) {
res.status(404).json({ error: "Project not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const workspaces = await svc.listWorkspaces(id);
res.json(workspaces);
});
router.post("/projects/:id/workspaces", validate(createProjectWorkspaceSchema), async (req, res) => {
const id = req.params.id as string;
const existing = await svc.getById(id);
if (!existing) {
res.status(404).json({ error: "Project not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const workspace = await svc.createWorkspace(id, req.body);
if (!workspace) {
res.status(422).json({ error: "Invalid project workspace payload" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: existing.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "project.workspace_created",
entityType: "project",
entityId: id,
details: {
workspaceId: workspace.id,
name: workspace.name,
cwd: workspace.cwd,
isPrimary: workspace.isPrimary,
},
});
res.status(201).json(workspace);
});
router.patch(
"/projects/:id/workspaces/:workspaceId",
validate(updateProjectWorkspaceSchema),
async (req, res) => {
const id = req.params.id as string;
const workspaceId = req.params.workspaceId as string;
const existing = await svc.getById(id);
if (!existing) {
res.status(404).json({ error: "Project not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const workspaceExists = (await svc.listWorkspaces(id)).some((workspace) => workspace.id === workspaceId);
if (!workspaceExists) {
res.status(404).json({ error: "Project workspace not found" });
return;
}
const workspace = await svc.updateWorkspace(id, workspaceId, req.body);
if (!workspace) {
res.status(422).json({ error: "Invalid project workspace payload" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: existing.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "project.workspace_updated",
entityType: "project",
entityId: id,
details: {
workspaceId: workspace.id,
changedKeys: Object.keys(req.body).sort(),
},
});
res.json(workspace);
},
);
router.post("/projects/:id/workspaces/:workspaceId/runtime-services/:action", async (req, res) => {
const id = req.params.id as string;
const workspaceId = req.params.workspaceId as string;
const action = String(req.params.action ?? "").trim().toLowerCase();
if (action !== "start" && action !== "stop" && action !== "restart") {
res.status(404).json({ error: "Runtime service action not found" });
return;
}
const project = await svc.getById(id);
if (!project) {
res.status(404).json({ error: "Project not found" });
return;
}
assertCompanyAccess(req, project.companyId);
const workspace = project.workspaces.find((entry) => entry.id === workspaceId) ?? null;
if (!workspace) {
res.status(404).json({ error: "Project workspace not found" });
return;
}
const workspaceCwd = workspace.cwd;
if (!workspaceCwd) {
res.status(422).json({ error: "Project workspace needs a local path before Paperclip can manage local runtime services" });
return;
}
const runtimeConfig = workspace.runtimeConfig?.workspaceRuntime ?? null;
if ((action === "start" || action === "restart") && !runtimeConfig) {
res.status(422).json({ error: "Project workspace has no runtime service configuration" });
return;
}
const actor = getActorInfo(req);
const recorder = workspaceOperations.createRecorder({ companyId: project.companyId });
let runtimeServiceCount = workspace.runtimeServices?.length ?? 0;
const stdout: string[] = [];
const stderr: string[] = [];
const operation = await recorder.recordOperation({
phase: action === "stop" ? "workspace_teardown" : "workspace_provision",
command: `workspace runtime ${action}`,
cwd: workspace.cwd,
metadata: {
action,
projectId: project.id,
projectWorkspaceId: workspace.id,
},
run: async () => {
const onLog = async (stream: "stdout" | "stderr", chunk: string) => {
if (stream === "stdout") stdout.push(chunk);
else stderr.push(chunk);
};
if (action === "stop" || action === "restart") {
await stopRuntimeServicesForProjectWorkspace({
db,
projectWorkspaceId: workspace.id,
});
}
if (action === "start" || action === "restart") {
const startedServices = await startRuntimeServicesForWorkspaceControl({
db,
actor: {
id: actor.agentId ?? null,
name: actor.actorType === "user" ? "Board" : "Agent",
companyId: project.companyId,
},
issue: null,
workspace: {
baseCwd: workspaceCwd,
source: "project_primary",
projectId: project.id,
workspaceId: workspace.id,
repoUrl: workspace.repoUrl,
repoRef: workspace.repoRef,
strategy: "project_primary",
cwd: workspaceCwd,
branchName: workspace.defaultRef ?? workspace.repoRef ?? null,
worktreePath: null,
warnings: [],
created: false,
},
config: { workspaceRuntime: runtimeConfig },
adapterEnv: {},
onLog,
});
runtimeServiceCount = startedServices.length;
} else {
runtimeServiceCount = 0;
}
await svc.updateWorkspace(project.id, workspace.id, {
runtimeConfig: {
desiredState: action === "stop" ? "stopped" : "running",
},
});
return {
status: "succeeded",
stdout: stdout.join(""),
stderr: stderr.join(""),
system:
action === "stop"
? "Stopped project workspace runtime services.\n"
: action === "restart"
? "Restarted project workspace runtime services.\n"
: "Started project workspace runtime services.\n",
metadata: {
runtimeServiceCount,
},
};
},
});
const updatedWorkspace = (await svc.listWorkspaces(project.id)).find((entry) => entry.id === workspace.id) ?? workspace;
await logActivity(db, {
companyId: project.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: `project.workspace_runtime_${action}`,
entityType: "project",
entityId: project.id,
details: {
projectWorkspaceId: workspace.id,
runtimeServiceCount,
},
});
res.json({
workspace: updatedWorkspace,
operation,
});
});
router.delete("/projects/:id/workspaces/:workspaceId", async (req, res) => {
const id = req.params.id as string;
const workspaceId = req.params.workspaceId as string;
const existing = await svc.getById(id);
if (!existing) {
res.status(404).json({ error: "Project not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const workspace = await svc.removeWorkspace(id, workspaceId);
if (!workspace) {
res.status(404).json({ error: "Project workspace not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: existing.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "project.workspace_deleted",
entityType: "project",
entityId: id,
details: {
workspaceId: workspace.id,
name: workspace.name,
},
});
res.json(workspace);
});
router.delete("/projects/:id", async (req, res) => {
const id = req.params.id as string;
const existing = await svc.getById(id);
if (!existing) {
res.status(404).json({ error: "Project not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const project = await svc.remove(id);
if (!project) {
res.status(404).json({ error: "Project not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: project.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "project.deleted",
entityType: "project",
entityId: project.id,
});
res.json(project);
});
return router;
}