181 lines
6.6 KiB
TypeScript
181 lines
6.6 KiB
TypeScript
import { and, eq } from "drizzle-orm";
|
|
import { Router } from "express";
|
|
import type { Db } from "@paperclipai/db";
|
|
import { issues, projects, projectWorkspaces } from "@paperclipai/db";
|
|
import { updateExecutionWorkspaceSchema } from "@paperclipai/shared";
|
|
import { validate } from "../middleware/validate.js";
|
|
import { executionWorkspaceService, logActivity, workspaceOperationService } from "../services/index.js";
|
|
import { parseProjectExecutionWorkspacePolicy } from "../services/execution-workspace-policy.js";
|
|
import {
|
|
cleanupExecutionWorkspaceArtifacts,
|
|
stopRuntimeServicesForExecutionWorkspace,
|
|
} from "../services/workspace-runtime.js";
|
|
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
|
|
|
const TERMINAL_ISSUE_STATUSES = new Set(["done", "cancelled"]);
|
|
|
|
export function executionWorkspaceRoutes(db: Db) {
|
|
const router = Router();
|
|
const svc = executionWorkspaceService(db);
|
|
const workspaceOperationsSvc = workspaceOperationService(db);
|
|
|
|
router.get("/companies/:companyId/execution-workspaces", async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
const workspaces = await svc.list(companyId, {
|
|
projectId: req.query.projectId as string | undefined,
|
|
projectWorkspaceId: req.query.projectWorkspaceId as string | undefined,
|
|
issueId: req.query.issueId as string | undefined,
|
|
status: req.query.status as string | undefined,
|
|
reuseEligible: req.query.reuseEligible === "true",
|
|
});
|
|
res.json(workspaces);
|
|
});
|
|
|
|
router.get("/execution-workspaces/:id", async (req, res) => {
|
|
const id = req.params.id as string;
|
|
const workspace = await svc.getById(id);
|
|
if (!workspace) {
|
|
res.status(404).json({ error: "Execution workspace not found" });
|
|
return;
|
|
}
|
|
assertCompanyAccess(req, workspace.companyId);
|
|
res.json(workspace);
|
|
});
|
|
|
|
router.patch("/execution-workspaces/:id", validate(updateExecutionWorkspaceSchema), async (req, res) => {
|
|
const id = req.params.id as string;
|
|
const existing = await svc.getById(id);
|
|
if (!existing) {
|
|
res.status(404).json({ error: "Execution workspace not found" });
|
|
return;
|
|
}
|
|
assertCompanyAccess(req, existing.companyId);
|
|
const patch: Record<string, unknown> = {
|
|
...req.body,
|
|
...(req.body.cleanupEligibleAt ? { cleanupEligibleAt: new Date(req.body.cleanupEligibleAt) } : {}),
|
|
};
|
|
let workspace = existing;
|
|
let cleanupWarnings: string[] = [];
|
|
|
|
if (req.body.status === "archived" && existing.status !== "archived") {
|
|
const linkedIssues = await db
|
|
.select({
|
|
id: issues.id,
|
|
status: issues.status,
|
|
})
|
|
.from(issues)
|
|
.where(and(eq(issues.companyId, existing.companyId), eq(issues.executionWorkspaceId, existing.id)));
|
|
const activeLinkedIssues = linkedIssues.filter((issue) => !TERMINAL_ISSUE_STATUSES.has(issue.status));
|
|
|
|
if (activeLinkedIssues.length > 0) {
|
|
res.status(409).json({
|
|
error: `Cannot archive execution workspace while ${activeLinkedIssues.length} linked issue(s) are still open`,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const closedAt = new Date();
|
|
const archivedWorkspace = await svc.update(id, {
|
|
...patch,
|
|
status: "archived",
|
|
closedAt,
|
|
cleanupReason: null,
|
|
});
|
|
if (!archivedWorkspace) {
|
|
res.status(404).json({ error: "Execution workspace not found" });
|
|
return;
|
|
}
|
|
workspace = archivedWorkspace;
|
|
|
|
try {
|
|
await stopRuntimeServicesForExecutionWorkspace({
|
|
db,
|
|
executionWorkspaceId: existing.id,
|
|
workspaceCwd: existing.cwd,
|
|
});
|
|
const projectWorkspace = existing.projectWorkspaceId
|
|
? await db
|
|
.select({
|
|
cwd: projectWorkspaces.cwd,
|
|
cleanupCommand: projectWorkspaces.cleanupCommand,
|
|
})
|
|
.from(projectWorkspaces)
|
|
.where(
|
|
and(
|
|
eq(projectWorkspaces.id, existing.projectWorkspaceId),
|
|
eq(projectWorkspaces.companyId, existing.companyId),
|
|
),
|
|
)
|
|
.then((rows) => rows[0] ?? null)
|
|
: null;
|
|
const projectPolicy = existing.projectId
|
|
? await db
|
|
.select({
|
|
executionWorkspacePolicy: projects.executionWorkspacePolicy,
|
|
})
|
|
.from(projects)
|
|
.where(and(eq(projects.id, existing.projectId), eq(projects.companyId, existing.companyId)))
|
|
.then((rows) => parseProjectExecutionWorkspacePolicy(rows[0]?.executionWorkspacePolicy))
|
|
: null;
|
|
const cleanupResult = await cleanupExecutionWorkspaceArtifacts({
|
|
workspace: existing,
|
|
projectWorkspace,
|
|
teardownCommand: projectPolicy?.workspaceStrategy?.teardownCommand ?? null,
|
|
recorder: workspaceOperationsSvc.createRecorder({
|
|
companyId: existing.companyId,
|
|
executionWorkspaceId: existing.id,
|
|
}),
|
|
});
|
|
cleanupWarnings = cleanupResult.warnings;
|
|
const cleanupPatch: Record<string, unknown> = {
|
|
closedAt,
|
|
cleanupReason: cleanupWarnings.length > 0 ? cleanupWarnings.join(" | ") : null,
|
|
};
|
|
if (!cleanupResult.cleaned) {
|
|
cleanupPatch.status = "cleanup_failed";
|
|
}
|
|
if (cleanupResult.warnings.length > 0 || !cleanupResult.cleaned) {
|
|
workspace = (await svc.update(id, cleanupPatch)) ?? workspace;
|
|
}
|
|
} catch (error) {
|
|
const failureReason = error instanceof Error ? error.message : String(error);
|
|
workspace =
|
|
(await svc.update(id, {
|
|
status: "cleanup_failed",
|
|
closedAt,
|
|
cleanupReason: failureReason,
|
|
})) ?? workspace;
|
|
res.status(500).json({
|
|
error: `Failed to archive execution workspace: ${failureReason}`,
|
|
});
|
|
return;
|
|
}
|
|
} else {
|
|
const updatedWorkspace = await svc.update(id, patch);
|
|
if (!updatedWorkspace) {
|
|
res.status(404).json({ error: "Execution workspace not found" });
|
|
return;
|
|
}
|
|
workspace = updatedWorkspace;
|
|
}
|
|
const actor = getActorInfo(req);
|
|
await logActivity(db, {
|
|
companyId: existing.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
action: "execution_workspace.updated",
|
|
entityType: "execution_workspace",
|
|
entityId: workspace.id,
|
|
details: {
|
|
changedKeys: Object.keys(req.body).sort(),
|
|
...(cleanupWarnings.length > 0 ? { cleanupWarnings } : {}),
|
|
},
|
|
});
|
|
res.json(workspace);
|
|
});
|
|
|
|
return router;
|
|
}
|