mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Conversations must use the same tasks, controls, and execution history. > - Users need an ongoing chat with an agent without managing task properties. > - Agents should clarify and plan work, then hand execution to assigned project tasks. > - This pull request combines the reviewed Agent Chat stack for one squash merge. > - The benefit is persistent conversation with normal task governance and shared UI. ## Linked Issues or Issue Description **Subsystem affected** Task lifecycle, agent runtime tools, shared task UI, and browser/paid runner tests. **Problem or motivation** Users need one persistent conversation with each agent. A separate chat store or renderer would duplicate task behavior and bypass existing controls. **Proposed solution** Use a task-backed chat per company, user, and agent. Reuse the task composer and transcript. Clarify and plan in chat, then create assigned project tasks with the relevant plan. Keep Agent Chat behind its own disabled-by-default experimental setting. **Roadmap alignment** This implements the task-backed direction in [CEO Chat](https://github.com/paperclipai/paperclip/blob/master/ROADMAP.md#-ceo-chat). Related proposals: #2504 and #9693. Related request: #7981. The maintainer requested one squash merge of the complete stack. Consolidates the reviewed runtime [#13281](https://github.com/paperclipai/paperclip/pull/13281), backend [#13282](https://github.com/paperclipai/paperclip/pull/13282), and UI [#13283](https://github.com/paperclipai/paperclip/pull/13283) layers with this PR's E2E coverage. All four layers passed CI and received Greptile 5/5 before consolidation. This PR targets master and includes the complete feature. ## What Changed - Add personal canonical chat tasks with ordinary company visibility, immutable identity, idempotent first sends, and an idle waiting state. - Process `/new` in queue order. Preserve history, release a chat pause, and fence old provider context and delayed writes. - Keep chat lifecycle rules across recovery, finalization, assignment, task lists, and rollups. - Support research and plan revision in chat. Hand plans to ordinary assigned project tasks before execution starts. Reject new chat subtasks. - Add repository-aware project creation and discovery tools, including multiple repository IDs and GitHub URLs, authorization, idempotency, and durable project-created cards. - Reuse task UI components for chat, with starred/recent agent navigation and a separate `enableAgentChat` experimental flag. - Add deterministic browser tests and 24 paid chat cells across four Codex/Claude profiles, with validated reports and screenshots. - Integrate current master recovery, controller lease, queued-message, and task UI changes. Gate chat interruption and deferred promotion on ownership/feature policy. Guarantee lease renewal and active controls are stopped even if teardown fails. - Preserve master's migration 0273 and generate chat migration 0274 with idempotent replay for development databases. ## Verification - Prior exact heads of all four PRs passed Linux CI, including build, typecheck, general/serialized tests, and browser E2E. Each had Greptile 5/5 and no unresolved findings. - Integrated local verification passed: full repository typecheck and production build, Storybook build, token gates, 340 focused UI tests, all 20 deterministic chat browser tests, two migration replay tests, 88 focused chat/queue/native/controller tests, and provider/session regressions including real lease expiry. These include the three lifecycle regressions for the final admission/teardown fixes; server typecheck also passes. Current head `1268eda16cc2af892055917e7292f068820be135` has Greptile 5/5 with no unresolved findings and passing security scans. All final-head CI gates passed: build, full Runner verification, typecheck/release registry, canary, all general/serialized test shards, and all browser E2E shards ([CI run](https://github.com/paperclipai/paperclip/actions/runs/34696739927)). Local PostgreSQL startup contention required serialized retries; skipped fixtures do not count as passing coverage. - The earlier paid campaign passed all 24 chat cells and retained 32 screenshots: [report](https://d1p6rlowie26tp.cloudfront.net/runner-e2e/campaigns/gha-34648511170-1/index.html?report=agent-chat#suite-agent-chat). It tested `abacbdfd2f660709ec37312cdb758284c8399d04`; it is prior evidence, not a paid run of this integrated head. - Manual check: enable Agent Chat in Experimental settings, open an agent, clarify and revise a plan, then hand off to an assigned project task. Stop a reply, send `/new`, and verify fresh context with retained history. Disable the setting and verify agent shortcuts/new chat turns are blocked. ## Risks - Queue/session integration can affect retries and delayed writes. Tests cover ownership, cancellation, reset boundaries, idle recovery, and ordinary task behavior. - Migration 0274 adds conversation fields and constraints. Replay is idempotent and preserves existing development chat history. - This combines the previously reviewed stack at the maintainer's request. Agent Chat remains off by default and is separate from Conference Room. ## Model Used OpenAI Codex, GPT-6 Astra (`gpt-6-astra`), with reasoning, code execution, browser tools, and parallel review. The exact context-window size is not exposed in this session. Codex and Claude also ran as test subjects in the linked paid campaign. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
801 lines
34 KiB
TypeScript
801 lines
34 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { and, eq, sql } from "drizzle-orm";
|
|
import { activityLog } from "@paperclipai/db";
|
|
import { projectToolContext } from "../services/project-tool-context.js";
|
|
import { persistActivity, publishActivity } from "../services/activity-log.js";
|
|
import { z } from "zod";
|
|
import { normalizeProjectRepositoryUrl, resolveProjectRepositorySelection } from "../services/project-repositories.js";
|
|
import { toolAccessService } from "../services/tool-access.js";
|
|
import { Router, type Request, type Response } from "express";
|
|
import type { Db } from "@paperclipai/db";
|
|
import {
|
|
createProjectSchema,
|
|
createProjectWorkspaceSchema,
|
|
findWorkspaceCommandDefinition,
|
|
isUuidLike,
|
|
matchWorkspaceRuntimeServiceToCommand,
|
|
updateProjectSchema,
|
|
updateProjectWorkspaceSchema,
|
|
workspaceRuntimeControlTargetSchema,
|
|
} from "@paperclipai/shared";
|
|
import type { WorkspaceRuntimeDesiredState, WorkspaceRuntimeServiceStateMap } from "@paperclipai/shared";
|
|
import { trackProjectCreated } from "@paperclipai/shared/telemetry";
|
|
import { validate } from "../middleware/validate.js";
|
|
import { accessService, projectService, logActivity, workspaceOperationService } from "../services/index.js";
|
|
import { conflict, forbidden, unprocessable } from "../errors.js";
|
|
import { externalObjectService } from "../services/external-objects.js";
|
|
import { instanceSettingsService } from "../services/instance-settings.js";
|
|
import { assertBoard, assertCompanyAccess, getAccessibleResource, getActorInfo } from "./authz.js";
|
|
import {
|
|
buildWorkspaceRuntimeDesiredStatePatch,
|
|
listConfiguredRuntimeServiceEntries,
|
|
runWorkspaceJobForControl,
|
|
startRuntimeServicesForWorkspaceControl,
|
|
stopRuntimeServicesForProjectWorkspace,
|
|
} from "../services/workspace-runtime.js";
|
|
import {
|
|
assertNoAgentHostWorkspaceCommandMutation,
|
|
collectProjectExecutionWorkspaceCommandPaths,
|
|
collectProjectWorkspaceCommandPaths,
|
|
} from "./workspace-command-authz.js";
|
|
import { assertCanManageProjectWorkspaceRuntimeServices } from "./workspace-runtime-service-authz.js";
|
|
import { getTelemetryClient } from "../telemetry.js";
|
|
import { appendWithCap } from "../adapters/utils.js";
|
|
import { assertEnvironmentSelectionForCompany } from "./environment-selection.js";
|
|
import { environmentService } from "../services/environments.js";
|
|
import { secretService } from "../services/secrets.js";
|
|
|
|
const WORKSPACE_CONTROL_OUTPUT_MAX_CHARS = 256 * 1024;
|
|
const SHARED_WORKSPACE_STOP_AND_RESTART_ACTIONS = new Set(["stop", "restart"]);
|
|
|
|
export function projectRoutes(db: Db) {
|
|
const router = Router();
|
|
const svc = projectService(db);
|
|
|
|
async function repositoryViewer(req: Request) {
|
|
if (req.actor.type === "board") return { userId: req.actor.userId ?? null, localTrusted: req.actor.source === "local_implicit" };
|
|
const context = await projectToolContext(db, req.actor);
|
|
if (!context.userId) throw forbidden("Repository access requires a responsible user");
|
|
return context;
|
|
}
|
|
|
|
async function selectedRepositories(req: Request, companyId: string, ids: string[], existing: import("@paperclipai/shared").ProjectWorkspace[] = []) {
|
|
const viewer = await repositoryViewer(req);
|
|
if (!ids.length) return [];
|
|
const available = await toolAccessService(db).listProjectRepositories(companyId, viewer.userId, viewer.localTrusted);
|
|
return resolveProjectRepositorySelection(ids, available.repositories, existing);
|
|
}
|
|
const access = accessService(db);
|
|
const secretsSvc = secretService(db);
|
|
const workspaceOperations = workspaceOperationService(db);
|
|
const instanceSettings = instanceSettingsService(db);
|
|
const externalObjectsSvc = externalObjectService(db, {
|
|
enabled: async () => (await instanceSettings.getExperimental()).enableExternalObjects === true,
|
|
});
|
|
const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true";
|
|
const environmentsSvc = environmentService(db);
|
|
|
|
/**
|
|
* Managed-sandbox-only policy (`enableManagedSandboxOnly`): a project
|
|
* workspace `cwd` is an absolute path on the execution host. When the policy
|
|
* is on every agent runs in the platform-managed environment, so there is no
|
|
* host for a user to point at and a write that carries a path is refused
|
|
* rather than stored and silently ignored. This is the floor behind the
|
|
* hidden UI field, and it applies to every actor, mirroring how
|
|
* `assertNoAgentHostWorkspaceCommandMutation` floors host-executed commands
|
|
* on these same routes.
|
|
*
|
|
* `cwd: null` still passes: clearing a stale path is exactly what an instance
|
|
* that just turned the policy on needs to do. The settings read only happens
|
|
* when the payload actually carries a path.
|
|
*/
|
|
async function assertNoManagedSandboxWorkspacePath(workspacePatch: unknown) {
|
|
if (typeof workspacePatch !== "object" || workspacePatch === null || Array.isArray(workspacePatch)) return;
|
|
const patch = workspacePatch as Record<string, unknown>;
|
|
if (!Object.prototype.hasOwnProperty.call(patch, "cwd")) return;
|
|
if (patch.cwd === null || patch.cwd === undefined) return;
|
|
if ((await instanceSettings.getExperimental()).enableManagedSandboxOnly !== true) return;
|
|
throw unprocessable(
|
|
"This instance runs agents only in the platform-managed environment; local folders are not configurable.",
|
|
);
|
|
}
|
|
|
|
async function assertProjectEnvironmentSelection(companyId: string, environmentId: string | null | undefined) {
|
|
if (environmentId === undefined || environmentId === null) return;
|
|
await assertEnvironmentSelectionForCompany(environmentsSvc, companyId, environmentId, {
|
|
allowedDrivers: ["local", "ssh", "sandbox"],
|
|
});
|
|
}
|
|
|
|
function readProjectPolicyEnvironmentId(policy: unknown): string | null | undefined {
|
|
if (!policy || typeof policy !== "object" || !("environmentId" in policy)) {
|
|
return undefined;
|
|
}
|
|
const environmentId = (policy as { environmentId?: unknown }).environmentId;
|
|
return typeof environmentId === "string" || environmentId === null ? environmentId : undefined;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
async function assertProjectReadAllowed(req: Request, res: Response, project: { id: string; companyId: string }) {
|
|
const decision = await access.decide({
|
|
actor: req.actor,
|
|
action: "project:read",
|
|
resource: { type: "project", companyId: project.companyId, projectId: project.id },
|
|
});
|
|
if (decision.allowed) return true;
|
|
res.status(403).json({ error: "Project is outside this actor's authorization boundary" });
|
|
return false;
|
|
}
|
|
|
|
async function assertRuntimeManageAllowed(req: Request, res: Response, companyId: string) {
|
|
const decision = await access.decide({
|
|
actor: req.actor,
|
|
action: "runtime:manage",
|
|
resource: { type: "company", companyId },
|
|
});
|
|
if (decision.allowed) return true;
|
|
res.status(403).json({ error: "Runtime service control is outside this actor's authorization boundary" });
|
|
return false;
|
|
}
|
|
|
|
async function filterProjectsForActor<T extends { id: string; companyId: string }>(req: Request, rows: T[]) {
|
|
const decisions = await Promise.all(rows.map((project) =>
|
|
access.decide({
|
|
actor: req.actor,
|
|
action: "project:read",
|
|
resource: { type: "project", companyId: project.companyId, projectId: project.id },
|
|
})
|
|
));
|
|
return rows.filter((_, index) => decisions[index]?.allowed);
|
|
}
|
|
|
|
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/project-repositories", async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
const viewer = await repositoryViewer(req);
|
|
res.json(await toolAccessService(db).listProjectRepositories(companyId, viewer.userId, viewer.localTrusted));
|
|
});
|
|
|
|
router.put("/projects/:id/repositories", validate(z.object({ repositoryIds: z.array(z.string().regex(/^\d+$/)) })), async (req, res) => {
|
|
assertBoard(req);
|
|
const project = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Project not found");
|
|
if (!project) return;
|
|
const repositories = await selectedRepositories(req, project.companyId, req.body.repositoryIds, project.workspaces);
|
|
const updated = await svc.replaceRepositories(project.id, repositories);
|
|
const actor = getActorInfo(req);
|
|
await logActivity(db, {
|
|
companyId: project.companyId, actorType: actor.actorType, actorId: actor.actorId,
|
|
action: "project.repositories_updated", entityType: "project", entityId: project.id,
|
|
details: { repositoryIds: repositories.map((repo) => repo.id) },
|
|
});
|
|
res.json(updated);
|
|
});
|
|
|
|
router.get("/companies/:companyId/projects", async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
const includeArchived = req.query.includeArchived === "true";
|
|
const result = await svc.list(companyId, { includeArchived });
|
|
res.json(await filterProjectsForActor(req, result));
|
|
});
|
|
|
|
router.get("/projects/:id", async (req, res) => {
|
|
const id = req.params.id as string;
|
|
const project = await getAccessibleResource(req, res, svc.getById(id), "Project not found");
|
|
if (!project) return;
|
|
if (!(await assertProjectReadAllowed(req, res, project))) return;
|
|
res.json(project);
|
|
});
|
|
|
|
router.get("/projects/:id/external-object-summary", async (req, res) => {
|
|
const id = req.params.id as string;
|
|
const project = await getAccessibleResource(req, res, svc.getById(id), "Project not found");
|
|
if (!project) return;
|
|
const summary = await externalObjectsSvc.getProjectSummary(project.id);
|
|
res.json(summary);
|
|
});
|
|
|
|
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];
|
|
repositoryIds?: string[];
|
|
};
|
|
|
|
const { workspace, repositoryIds, repositoryUrls, idempotencyKey, ...projectData } = req.body as CreateProjectPayload & { idempotencyKey?: string; repositoryUrls?: string[] };
|
|
const runContext = req.actor.type === "agent" && req.actor.source === "agent_jwt" && req.actor.runId
|
|
? await projectToolContext(db, req.actor, true) : null;
|
|
await assertProjectEnvironmentSelection(
|
|
companyId,
|
|
readProjectPolicyEnvironmentId(projectData.executionWorkspacePolicy),
|
|
);
|
|
assertNoAgentHostWorkspaceCommandMutation(
|
|
req,
|
|
[
|
|
...collectProjectExecutionWorkspaceCommandPaths(projectData.executionWorkspacePolicy),
|
|
...collectProjectWorkspaceCommandPaths(workspace, "workspace"),
|
|
],
|
|
);
|
|
await assertNoManagedSandboxWorkspacePath(workspace);
|
|
if (projectData.env !== undefined) {
|
|
projectData.env = await secretsSvc.normalizeEnvBindingsForPersistence(
|
|
companyId,
|
|
projectData.env,
|
|
{ strictMode: strictSecretsMode, fieldPath: "env" },
|
|
);
|
|
}
|
|
if (workspace && (repositoryIds || repositoryUrls)) throw unprocessable("Use either workspace or repositoryIds/repositoryUrls when creating a project");
|
|
const urlRepositories = (repositoryUrls ?? []).map(normalizeProjectRepositoryUrl);
|
|
const repositories = repositoryIds ? await selectedRepositories(req, companyId, repositoryIds) : null;
|
|
const actor = getActorInfo(req);
|
|
const fingerprint = createHash("sha256").update(JSON.stringify({ projectData, workspace, repositoryIds, repositoryUrls })).digest("hex");
|
|
const receiptKey = idempotencyKey ? `project:${companyId}:${actor.actorId}:${runContext?.issue.id ?? "board"}:${idempotencyKey}` : null;
|
|
const result = await db.transaction(async (tx) => {
|
|
if (receiptKey) {
|
|
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${receiptKey}, 0))`);
|
|
const [prior] = await tx.select().from(activityLog).where(and(
|
|
eq(activityLog.companyId, companyId), eq(activityLog.action, "project.created"),
|
|
sql`${activityLog.details}->>'idempotencyKey' = ${receiptKey}`,
|
|
));
|
|
if (prior) {
|
|
if (prior.details?.fingerprint !== fingerprint) throw conflict("Project idempotency key was used with different inputs");
|
|
const project = await projectService(tx as unknown as Db).getById(prior.entityId);
|
|
if (!project) throw conflict("Previously created project is no longer available");
|
|
return { project, publication: null, duplicate: true };
|
|
}
|
|
}
|
|
if (runContext) await projectToolContext(tx as unknown as Db, req.actor, true);
|
|
const service = projectService(tx as unknown as Db);
|
|
const project = repositories ? await service.createWithRepositories(companyId, projectData, repositories) : await service.create(companyId, projectData);
|
|
const attachedUrls = new Set((repositories ?? []).map(repo => repo.url.toLowerCase()));
|
|
const registeredUrls: typeof urlRepositories = [];
|
|
for (const repo of urlRepositories) {
|
|
if (attachedUrls.has(repo.url.toLowerCase())) continue;
|
|
attachedUrls.add(repo.url.toLowerCase());
|
|
await service.createWorkspace(project.id, { name: repo.fullName, repoUrl: repo.url });
|
|
registeredUrls.push(repo);
|
|
}
|
|
const createdWorkspace = workspace ? await service.createWorkspace(project.id, workspace) : null;
|
|
if (workspace && !createdWorkspace) throw unprocessable("Invalid project workspace payload");
|
|
const hydrated = await service.getById(project.id);
|
|
const activity = await persistActivity(tx as unknown as Db, {
|
|
companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId,
|
|
runId: actor.runId, issueId: runContext?.issue.id,
|
|
action: "project.created", entityType: "project", entityId: project.id,
|
|
details: {
|
|
name: project.name, description: project.description, icon: project.icon,
|
|
sourceIssueId: runContext?.issue.id ?? null,
|
|
repositories: [...(repositories ?? []).map(repo => ({ id: repo.id, name: repo.fullName, url: repo.url })), ...registeredUrls.map(repo => ({ id: repo.url, name: repo.fullName, url: repo.url })),
|
|
...(createdWorkspace?.repoUrl ? [{ id: createdWorkspace.id, name: createdWorkspace.name, url: createdWorkspace.repoUrl }] : []),
|
|
],
|
|
workspaceId: createdWorkspace?.id ?? null,
|
|
envKeys: project.env ? Object.keys(project.env).sort() : [],
|
|
...(receiptKey ? { idempotencyKey: receiptKey, fingerprint } : {}),
|
|
},
|
|
});
|
|
return { project: hydrated ?? project, publication: activity.publication, duplicate: false };
|
|
});
|
|
if (result.publication) publishActivity(result.publication);
|
|
if (result.project.env) await secretsSvc.syncEnvBindingsForTarget?.(companyId, { targetType: "project", targetId: result.project.id }, result.project.env);
|
|
if (result.duplicate) { res.status(200).json(result.project); return; }
|
|
const telemetryClient = getTelemetryClient();
|
|
if (telemetryClient) {
|
|
trackProjectCreated(telemetryClient);
|
|
}
|
|
res.status(result.duplicate ? 200 : 201).json(result.project);
|
|
});
|
|
|
|
router.patch("/projects/:id", validate(updateProjectSchema), async (req, res) => {
|
|
const id = req.params.id as string;
|
|
const existing = await getAccessibleResource(req, res, svc.getById(id), "Project not found");
|
|
if (!existing) return;
|
|
const body = { ...req.body };
|
|
assertNoAgentHostWorkspaceCommandMutation(
|
|
req,
|
|
collectProjectExecutionWorkspaceCommandPaths(body.executionWorkspacePolicy),
|
|
);
|
|
await assertProjectEnvironmentSelection(
|
|
existing.companyId,
|
|
readProjectPolicyEnvironmentId(body.executionWorkspacePolicy),
|
|
);
|
|
if (typeof body.archivedAt === "string") {
|
|
body.archivedAt = new Date(body.archivedAt);
|
|
}
|
|
if (body.env !== undefined) {
|
|
body.env = await secretsSvc.normalizeEnvBindingsForPersistence(existing.companyId, body.env, {
|
|
strictMode: strictSecretsMode,
|
|
fieldPath: "env",
|
|
});
|
|
}
|
|
const project = await svc.update(id, body);
|
|
if (!project) {
|
|
res.status(404).json({ error: "Project not found" });
|
|
return;
|
|
}
|
|
if (body.env !== undefined) {
|
|
await secretsSvc.syncEnvBindingsForTarget?.(
|
|
project.companyId,
|
|
{ targetType: "project", targetId: project.id },
|
|
project.env,
|
|
);
|
|
}
|
|
|
|
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: {
|
|
changedKeys: Object.keys(req.body).sort(),
|
|
envKeys:
|
|
body.env && typeof body.env === "object" && !Array.isArray(body.env)
|
|
? Object.keys(body.env as Record<string, unknown>).sort()
|
|
: undefined,
|
|
},
|
|
});
|
|
|
|
res.json(project);
|
|
});
|
|
|
|
router.get("/projects/:id/workspaces", async (req, res) => {
|
|
const id = req.params.id as string;
|
|
const existing = await getAccessibleResource(req, res, svc.getById(id), "Project not found");
|
|
if (!existing) return;
|
|
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 getAccessibleResource(req, res, svc.getById(id), "Project not found");
|
|
if (!existing) return;
|
|
assertNoAgentHostWorkspaceCommandMutation(
|
|
req,
|
|
collectProjectWorkspaceCommandPaths(req.body),
|
|
);
|
|
await assertNoManagedSandboxWorkspacePath(req.body);
|
|
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 getAccessibleResource(req, res, svc.getById(id), "Project not found");
|
|
if (!existing) return;
|
|
assertNoAgentHostWorkspaceCommandMutation(
|
|
req,
|
|
collectProjectWorkspaceCommandPaths(req.body),
|
|
);
|
|
await assertNoManagedSandboxWorkspacePath(req.body);
|
|
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);
|
|
},
|
|
);
|
|
|
|
async function handleProjectWorkspaceRuntimeCommand(req: Request, res: Response) {
|
|
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" && action !== "run") {
|
|
res.status(404).json({ error: "Workspace command action not found" });
|
|
return;
|
|
}
|
|
|
|
const project = await getAccessibleResource(req, res, svc.getById(id), "Project not found");
|
|
if (!project) return;
|
|
|
|
const workspace = project.workspaces.find((entry) => entry.id === workspaceId) ?? null;
|
|
if (!workspace) {
|
|
res.status(404).json({ error: "Project workspace not found" });
|
|
return;
|
|
}
|
|
if (!(await assertRuntimeManageAllowed(req, res, project.companyId))) return;
|
|
|
|
const isSharedWorkspace = Boolean(workspace.sharedWorkspaceKey);
|
|
if (
|
|
req.actor.type === "agent"
|
|
&& isSharedWorkspace
|
|
&& SHARED_WORKSPACE_STOP_AND_RESTART_ACTIONS.has(action)
|
|
) {
|
|
throw forbidden("Missing permission to manage workspace runtime services");
|
|
}
|
|
|
|
await assertCanManageProjectWorkspaceRuntimeServices(db, req, {
|
|
companyId: project.companyId,
|
|
projectWorkspaceId: workspace.id,
|
|
});
|
|
|
|
const workspaceCwd = workspace.cwd;
|
|
if (!workspaceCwd) {
|
|
res.status(422).json({ error: "Project workspace needs a local path before Paperclip can run workspace commands" });
|
|
return;
|
|
}
|
|
|
|
const runtimeConfig = workspace.runtimeConfig?.workspaceRuntime ?? null;
|
|
const target = req.body as { workspaceCommandId?: string | null; runtimeServiceId?: string | null; serviceIndex?: number | null };
|
|
const configuredServices = runtimeConfig ? listConfiguredRuntimeServiceEntries({ workspaceRuntime: runtimeConfig }) : [];
|
|
const workspaceCommand = runtimeConfig
|
|
? findWorkspaceCommandDefinition(runtimeConfig, target.workspaceCommandId ?? null)
|
|
: null;
|
|
if (target.workspaceCommandId && !workspaceCommand) {
|
|
res.status(404).json({ error: "Workspace command not found for this project workspace" });
|
|
return;
|
|
}
|
|
if (target.runtimeServiceId && !(workspace.runtimeServices ?? []).some((service) => service.id === target.runtimeServiceId)) {
|
|
res.status(404).json({ error: "Runtime service not found for this project workspace" });
|
|
return;
|
|
}
|
|
const matchedRuntimeService =
|
|
workspaceCommand?.kind === "service" && !target.runtimeServiceId
|
|
? matchWorkspaceRuntimeServiceToCommand(workspaceCommand, workspace.runtimeServices ?? [])
|
|
: null;
|
|
const selectedRuntimeServiceId = target.runtimeServiceId ?? matchedRuntimeService?.id ?? null;
|
|
const selectedServiceIndex =
|
|
workspaceCommand?.kind === "service"
|
|
? workspaceCommand.serviceIndex
|
|
: target.serviceIndex ?? null;
|
|
if (
|
|
selectedServiceIndex !== undefined
|
|
&& selectedServiceIndex !== null
|
|
&& (selectedServiceIndex < 0 || selectedServiceIndex >= configuredServices.length)
|
|
) {
|
|
res.status(422).json({ error: "Selected runtime service is not defined in this project workspace runtime config" });
|
|
return;
|
|
}
|
|
if (workspaceCommand?.kind === "job" && action !== "run") {
|
|
res.status(422).json({ error: `Workspace job "${workspaceCommand.name}" can only be run` });
|
|
return;
|
|
}
|
|
if (workspaceCommand?.kind === "service" && action === "run") {
|
|
res.status(422).json({ error: `Workspace service "${workspaceCommand.name}" should be started or restarted, not run` });
|
|
return;
|
|
}
|
|
if (action === "run" && !workspaceCommand) {
|
|
res.status(422).json({ error: "Select a workspace job to run" });
|
|
return;
|
|
}
|
|
if ((action === "start" || action === "restart") && !runtimeConfig) {
|
|
res.status(422).json({ error: "Project workspace has no workspace command configuration" });
|
|
return;
|
|
}
|
|
|
|
const actor = getActorInfo(req);
|
|
const recorder = workspaceOperations.createRecorder({ companyId: project.companyId });
|
|
let runtimeServiceCount = workspace.runtimeServices?.length ?? 0;
|
|
let stdout = "";
|
|
let stderr = "";
|
|
|
|
const operation = await recorder.recordOperation({
|
|
phase: action === "stop" ? "workspace_teardown" : "workspace_provision",
|
|
command: workspaceCommand?.command ?? `workspace command ${action}`,
|
|
cwd: workspace.cwd,
|
|
metadata: {
|
|
action,
|
|
projectId: project.id,
|
|
projectWorkspaceId: workspace.id,
|
|
workspaceCommandId: workspaceCommand?.id ?? target.workspaceCommandId ?? null,
|
|
workspaceCommandKind: workspaceCommand?.kind ?? null,
|
|
workspaceCommandName: workspaceCommand?.name ?? null,
|
|
runtimeServiceId: selectedRuntimeServiceId,
|
|
serviceIndex: selectedServiceIndex,
|
|
},
|
|
run: async () => {
|
|
if (action === "run") {
|
|
if (!workspaceCommand || workspaceCommand.kind !== "job") {
|
|
throw new Error("Workspace job selection is required");
|
|
}
|
|
return await runWorkspaceJobForControl({
|
|
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,
|
|
branchCreatedByRuntime: false,
|
|
},
|
|
command: workspaceCommand.rawConfig,
|
|
adapterEnv: {},
|
|
recorder,
|
|
metadata: {
|
|
action,
|
|
projectId: project.id,
|
|
projectWorkspaceId: workspace.id,
|
|
workspaceCommandId: workspaceCommand.id,
|
|
},
|
|
}).then((nestedOperation) => ({
|
|
status: "succeeded" as const,
|
|
exitCode: 0,
|
|
metadata: {
|
|
nestedOperationId: nestedOperation?.id ?? null,
|
|
runtimeServiceCount,
|
|
},
|
|
}));
|
|
}
|
|
|
|
const onLog = async (stream: "stdout" | "stderr", chunk: string) => {
|
|
if (stream === "stdout") stdout = appendWithCap(stdout, chunk, WORKSPACE_CONTROL_OUTPUT_MAX_CHARS);
|
|
else stderr = appendWithCap(stderr, chunk, WORKSPACE_CONTROL_OUTPUT_MAX_CHARS);
|
|
};
|
|
|
|
if (action === "stop" || action === "restart") {
|
|
await stopRuntimeServicesForProjectWorkspace({
|
|
db,
|
|
projectWorkspaceId: workspace.id,
|
|
runtimeServiceId: selectedRuntimeServiceId,
|
|
});
|
|
}
|
|
|
|
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,
|
|
branchCreatedByRuntime: false,
|
|
},
|
|
config: { workspaceRuntime: runtimeConfig },
|
|
adapterEnv: {},
|
|
onLog,
|
|
serviceIndex: selectedServiceIndex,
|
|
runtimeServiceId: selectedRuntimeServiceId,
|
|
});
|
|
runtimeServiceCount = startedServices.length;
|
|
} else {
|
|
runtimeServiceCount = selectedRuntimeServiceId ? Math.max(0, (workspace.runtimeServices?.length ?? 1) - 1) : 0;
|
|
}
|
|
|
|
const currentDesiredState: WorkspaceRuntimeDesiredState =
|
|
workspace.runtimeConfig?.desiredState
|
|
?? ((workspace.runtimeServices ?? []).some((service) =>
|
|
service.status === "provisioning" || service.status === "starting" || service.status === "running"
|
|
)
|
|
? "running"
|
|
: "stopped");
|
|
const nextRuntimeState: {
|
|
desiredState: WorkspaceRuntimeDesiredState;
|
|
serviceStates: WorkspaceRuntimeServiceStateMap | null | undefined;
|
|
} = selectedRuntimeServiceId && (selectedServiceIndex === undefined || selectedServiceIndex === null)
|
|
? {
|
|
desiredState: currentDesiredState,
|
|
serviceStates: workspace.runtimeConfig?.serviceStates ?? null,
|
|
}
|
|
: buildWorkspaceRuntimeDesiredStatePatch({
|
|
config: { workspaceRuntime: runtimeConfig },
|
|
currentDesiredState,
|
|
currentServiceStates: workspace.runtimeConfig?.serviceStates ?? null,
|
|
action,
|
|
serviceIndex: selectedServiceIndex,
|
|
});
|
|
await svc.updateWorkspace(project.id, workspace.id, {
|
|
runtimeConfig: {
|
|
desiredState: nextRuntimeState.desiredState,
|
|
serviceStates: nextRuntimeState.serviceStates,
|
|
},
|
|
});
|
|
|
|
return {
|
|
status: "succeeded",
|
|
stdout,
|
|
stderr,
|
|
system:
|
|
action === "stop"
|
|
? "Stopped project workspace runtime services.\nThis does not pause issue work or held wake scheduling."
|
|
: action === "restart"
|
|
? "Restarted project workspace runtime services.\nThis does not pause issue work or held wake scheduling."
|
|
: "Started project workspace runtime services.\n",
|
|
metadata: {
|
|
runtimeServiceCount,
|
|
workspaceCommandId: workspaceCommand?.id ?? target.workspaceCommandId ?? null,
|
|
runtimeServiceId: selectedRuntimeServiceId,
|
|
serviceIndex: selectedServiceIndex,
|
|
},
|
|
};
|
|
},
|
|
});
|
|
|
|
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,
|
|
workspaceCommandId: workspaceCommand?.id ?? target.workspaceCommandId ?? null,
|
|
workspaceCommandKind: workspaceCommand?.kind ?? null,
|
|
workspaceCommandName: workspaceCommand?.name ?? null,
|
|
runtimeServiceId: selectedRuntimeServiceId,
|
|
serviceIndex: selectedServiceIndex,
|
|
},
|
|
});
|
|
|
|
res.json({
|
|
workspace: updatedWorkspace,
|
|
operation,
|
|
});
|
|
}
|
|
|
|
router.post("/projects/:id/workspaces/:workspaceId/runtime-services/:action", validate(workspaceRuntimeControlTargetSchema), handleProjectWorkspaceRuntimeCommand);
|
|
router.post("/projects/:id/workspaces/:workspaceId/runtime-commands/:action", validate(workspaceRuntimeControlTargetSchema), handleProjectWorkspaceRuntimeCommand);
|
|
|
|
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 getAccessibleResource(req, res, svc.getById(id), "Project not found");
|
|
if (!existing) return;
|
|
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 getAccessibleResource(req, res, svc.getById(id), "Project not found");
|
|
if (!existing) return;
|
|
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;
|
|
}
|