Spaces:
Paused
Paused
| import { Router, type Request } from "express"; | |
| import type { Db } from "@paperclipai/db"; | |
| import { | |
| createProjectSchema, | |
| createProjectWorkspaceSchema, | |
| isUuidLike, | |
| updateProjectSchema, | |
| updateProjectWorkspaceSchema, | |
| } from "@paperclipai/shared"; | |
| import { trackProjectCreated } from "@paperclipai/shared/telemetry"; | |
| 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"; | |
| import { getTelemetryClient } from "../telemetry.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, | |
| }, | |
| }); | |
| const telemetryClient = getTelemetryClient(); | |
| if (telemetryClient) { | |
| trackProjectCreated(telemetryClient); | |
| } | |
| 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; | |
| } | |