Files
PaperClipAI/server/src/routes/projects.ts
T
Nicky LeachandPaperclip f30ac226d6 Keep project environment mappings independent of shared defaults (#20651)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - A project supplies work context and repository storage to task runs.
> - Creation selected a storage owner but did not retain the selected
environment on the project.
> - Configuration stored an environment inside checkout policy, but
policy reads and run selection dropped it.
> - Projects therefore continued to follow shared agent and instance
defaults.
> - This pull request saves placement on each project and uses it for
new runs.
> - The benefit is an independent Project to Environment association
with the existing storage, trust, and recovery safeguards.

## Linked Issues or Issue Description

Refs #20459 and #11644. Follow-up to #20587.

The reported journey is Project > Configuration: changing one project's
environment must not change another project's mapping or shared
defaults.

Related open PR #18176 repairs placement through the legacy
workspace-policy field. This follow-up introduces a separate project
association, saves creation defaults, and migrates saved legacy
references. It overlaps that PR's placement, permission, and recovery
safeguards. The changes should be reviewed together. #9588 concerns
environment-variable response privacy and does not address placement.
ROADMAP.md has no separate project-association work planned.

## What Changed

- Add nullable `projects.environment_id` and expose
`Project.environmentId`. Save the effective creation default once on the
project. Keep environment placement separate from checkout/isolation
policy.
- Read and write Environment directly in Project Configuration. Keep the
control available with isolated workspaces disabled. Each form reads its
project's value, even when environment choices share a query cache.
- Offer environments compatible with the current storage owner. Change
the target and storage together when the existing storage selector
attaches a core project to a plugin. Preserve retained-data restrictions
and validate the observed storage owner again on save.
- Resolve new task runs from their organizational project's target
before agent and instance defaults. File sources from another project do
not change placement. Explicit low-trust sandbox choices and enforced
sandbox policies retain precedence. External provider placement remains
separate.
- Restore admitted native runs from their owned environment lease. Later
project edits apply to new admissions. Require workspace-write
permission for environment changes and clears. Check company bindings
before runtime admission and during lease creation.
- Migrate valid saved policy references into the project column.
Normalize legacy API writes. Count and clear matching project references
when an environment is deleted. Update API and behavior documentation.
- Refresh the maintained SDK source registry for the two changed shared
files. Runtime pins are unchanged.

## Verification

- [Hosted
CI](https://github.com/paperclipai/paperclip/actions/runs/42908045146)
is green on `8b3b7d5b5aed51fc4c8e9c2e3506508bbaf9e00c`: **60 successful
checks**, three skipped gates, no failures, and `ci / verify` passed.
GitHub reports no merge conflicts. Standard Greptile scored the same
commit **5/5** with both review threads resolved.
- Source checkout on macOS arm64, Node 26.12.0, with fresh isolated
PostgreSQL. No production data or live provider calls.
- The two-project component test changes the first form with isolation
disabled and a shared query cache. The second form keeps its target.
Storage changes update only the selected project. A saved target remains
selected while the list loads, and an explicit clear remains possible.
- API integration creates two projects from the same default, patches
one, reads both, and checks that agent and instance defaults remain
unchanged. A later instance-default change does not rewrite either saved
project target. Tests cover unavailable/incompatible choices, anonymous
and cross-company denials, and storage preconditions.
- The migration test runs the actual SQL against an existing project row
and verifies its environment and disabled checkout policy survive.
Deletion accounting and clearing affect only matching project
references.
- The final guard set passes **53 tests across four files**:
`project-environments.test.ts`,
`environment-selection-route-guards.test.ts`,
`heartbeat/environment-selection.test.ts`, and
`ProjectProperties.environment.test.tsx`. All **64
environment-run-orchestrator tests** pass, including direct lease
acquisition without an extra caller argument. The broader affected set
passed **109 tests across eight files** before the additional admission
guards. These sets overlap.
- UI typecheck, migration numbering/safety, token gates, module
boundaries, query-error rendering, and diff checks pass. `pnpm verify
--explain` returns exit 2 and requires broad CI because shared
schema/runtime paths and behavior documentation changed. Broad coverage
is delegated to current-head CI.
- Early iterations failed due to a legacy parser that drops the
environment field, a fixture without real company membership, and
incorrect expectations for privacy-preserving 404 denials. The service
now reads legacy references before normalization, the API fixture has
real membership, and denial assertions match the existing privacy
contract. The orchestrator mock also needed the reused company-binding
guard in its service contract. The focused suites above pass after those
corrections.
- Local server typecheck remains blocked by installed ACPX, Slack, AI
SDK, and Runner type mismatches in unchanged dependency-facing code. It
reports no errors in the changed production paths. This is not a full
local typecheck or suite pass. Hosted CI supplies full current-head
coverage.
- SDK source-proof and privacy qualification passes **56 tests across
three files**, including the real built SDK. The maintained source
registry matches the committed revision; runtime pins are unchanged.
- Review reproduced two failures: an incoming policy without a storage
owner could be validated before the existing owner was restored, and
uppercase legacy UUIDs missed migration matching. Preserve the owner
before validation and compare normalized UUID text. Both regressions now
pass. The post-fix project/storage/orchestrator batch passes **107
tests**: `node node_modules/vitest/vitest.mjs run
server/src/__tests__/project-environments.test.ts
server/src/__tests__/project-workspace-provider.integration.test.ts
server/src/__tests__/environment-run-orchestrator.test.ts --silent`.
- The first hosted run failed build, typecheck, and canary at the same
`run-completion.test.ts` call because lease acquisition required a new
argument. The lease method now enforces binding internally. Its existing
caller compiles without a new field, and the orchestrator tests prove
the binding flag reaches the runtime. The full server check no longer
reports that error; the local dependency errors above remain.

## Risks

- Apply the normal database migration before serving the new revision.
The migration adds a nullable foreign key and copies valid legacy
project selections. It does not move repository data or resize provider
storage. The five newest schema snapshots are retained.
- New projects retain their creation target when shared defaults change.
Existing projects without a saved selection continue to inherit
defaults. A null target explicitly opts into inheritance.
- Existing saved project selections now affect placement. Missing,
archived, incompatible, and foreign-company targets fail validation;
they do not silently choose another storage owner. A running admitted
process keeps its original owned lease.
- Local dependency limitations are listed above. No live-provider,
deployed-browser, or production rollout qualification is claimed.

## Model Used

OpenAI Codex, based on GPT-6, with reasoning, tool use, and code
execution. The exact serving snapshot and context-window size are not
exposed in this session.

## 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>
2026-11-10 14:31:48 -08:00

1104 lines
50 KiB
TypeScript

import { projectEnvironmentOptions } from "../services/project-environments.js";
import { projectGitAuthorizationService } from "../services/project-git-authorization.js";
import { skillAccessRequestContext } from "../services/skill-access.js";
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 { museExternalAgents, withExternalExecutionAdmission } from "../modules/external-agents/index.js";
import { parseNativeExecutionInput } from "../vendor/paperclip-runner/index.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 { agents, authUsers, companyMemberships, instanceUserRoles, projectAccessMembers } from "@paperclipai/db";
import { inArray } from "drizzle-orm";
import {
addProjectAccessMemberSchema,
createProjectSchema,
projectDiscoverySchema,
type ProjectDiscoveryPage,
createProjectWorkspaceSchema,
deriveProjectUrlKey,
findWorkspaceCommandDefinition,
isUuidLike,
matchWorkspaceRuntimeServiceToCommand,
normalizeProjectUrlKey,
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 { ensureProjectAccessMember } from "../services/projects.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();
router.use(skillAccessRequestContext);
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 urlKey = normalizeProjectUrlKey(rawId);
if (!urlKey) return rawId;
const visibleProjects = await filterProjectsForActor(
req,
await svc.list(companyId, { includeArchived: true }),
);
const matches = visibleProjects.filter((project) => deriveProjectUrlKey(project.name, project.id) === urlKey);
if (matches.length > 1) {
throw conflict("Project shortname is ambiguous in this company. Use the project ID.");
}
return matches[0]?.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(404).json({ error: "Project not found" });
return false;
}
async function assertProjectWorkspaceWriteAllowed(req: Request, res: Response, project: { id?: string; companyId: string }) {
const assigneeAgentId = req.actor.type === "agent" ? req.actor.agentId ?? null : null;
const decision = await access.decide({ actor: req.actor, action: project.id ? "project:write_workspace" : "tasks:assign",
resource: { type: "issue", companyId: project.companyId, projectId: project.id, assigneeAgentId },
scope: { projectId: project.id, assigneeAgentId } });
if (decision.allowed) return true;
res.status(403).json({ error: decision.explanation });
return false;
}
async function assertCanManageProjectPrivacy(req: Request, res: Response, project: {
companyId: string; privacyOwnerUserId?: string | null; personalOwnerUserId?: string | null;
}) {
const actor = req.actor;
if (actor.type === "board" && actor.userId) {
if (actor.source === "local_implicit" || actor.isInstanceAdmin) return true;
if (actor.userId === project.privacyOwnerUserId || actor.userId === project.personalOwnerUserId) return true;
const instanceAdmin = actor.source !== "cloud_tenant" && await db.select({ id: instanceUserRoles.id }).from(instanceUserRoles)
.where(and(eq(instanceUserRoles.userId, actor.userId), eq(instanceUserRoles.role, "instance_admin"))).limit(1).then(rows => rows.length > 0);
if (instanceAdmin) return true;
const membership = await db.select({ role: companyMemberships.membershipRole }).from(companyMemberships)
.where(and(eq(companyMemberships.companyId, project.companyId), eq(companyMemberships.principalType, "user"),
eq(companyMemberships.principalId, actor.userId), eq(companyMemberships.status, "active"))).limit(1);
if (["owner", "admin"].includes(membership[0]?.role ?? "")) return true;
}
res.status(403).json({ error: "Only the project privacy owner or an administrator can change project access" });
return false;
}
async function addActorAsPrivateProjectPrincipal(req: Request, project: { id: string; companyId: string; privacyOwnerUserId?: string | null; personalOwnerUserId?: string | null }, dbOrTx: Db = db) {
const ownerUserId = project.privacyOwnerUserId ?? project.personalOwnerUserId;
if (ownerUserId) await ensureProjectAccessMember(dbOrTx, {
companyId: project.companyId, projectId: project.id, subjectType: "user", subjectId: ownerUserId,
});
if (req.actor.type === "board" && req.actor.userId) {
await ensureProjectAccessMember(dbOrTx, {
companyId: project.companyId,
projectId: project.id,
subjectType: "user",
subjectId: req.actor.userId,
});
return;
}
if (req.actor.type === "agent" && req.actor.agentId) {
if (req.actor.onBehalfOfUserId) {
await ensureProjectAccessMember(dbOrTx, {
companyId: project.companyId,
projectId: project.id,
subjectType: "user",
subjectId: req.actor.onBehalfOfUserId,
});
}
await ensureProjectAccessMember(dbOrTx, {
companyId: project.companyId,
projectId: project.id,
subjectType: "agent",
subjectId: req.actor.agentId,
});
}
}
async function enrichProjectAccessMembers(rows: Array<typeof projectAccessMembers.$inferSelect>) {
const agentIds = rows.filter((row) => row.subjectType === "agent").map((row) => row.subjectId);
const userIds = rows.filter((row) => row.subjectType === "user").map((row) => row.subjectId);
const [agentRows, userRows] = await Promise.all([
agentIds.length === 0
? Promise.resolve([])
: db.select({ id: agents.id, name: agents.name }).from(agents).where(inArray(agents.id, agentIds)),
userIds.length === 0
? Promise.resolve([])
: db.select({ id: authUsers.id, name: authUsers.name, email: authUsers.email, image: authUsers.image })
.from(authUsers)
.where(inArray(authUsers.id, userIds)),
]);
const agentsById = new Map(agentRows.map((row) => [row.id, row]));
const usersById = new Map(userRows.map((row) => [row.id, row]));
return rows.map((row) => {
const agent = row.subjectType === "agent" ? agentsById.get(row.subjectId) : null;
const user = row.subjectType === "user" ? usersById.get(row.subjectId) : null;
return {
...row,
subjectDisplayName: agent?.name ?? user?.name ?? user?.email ?? null,
subjectAvatarUrl: user?.image ?? null,
};
});
}
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.get("/projects/:id/git-authorization", async (req, res) => {
assertBoard(req);
const project = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Project not found");
if (!project || !(await assertProjectReadAllowed(req, res, project))) return;
if (!req.actor.userId) throw forbidden("A signed-in user is required");
res.json(await projectGitAuthorizationService(db).read(project.companyId, project.id, req.actor.userId));
});
router.put("/projects/:id/git-authorization", validate(z.object({ grantId: z.string().uuid().nullable() }).strict()), async (req, res) => {
assertBoard(req);
const project = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Project not found");
if (!project || !(await assertProjectReadAllowed(req, res, project)) || !(await assertProjectWorkspaceWriteAllowed(req, res, project))) return;
if (!req.actor.userId) throw forbidden("A signed-in user is required");
res.json(await projectGitAuthorizationService(db).set(project.companyId, project.id, req.actor.userId, req.body.grantId));
});
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;
if (!(await assertProjectReadAllowed(req, res, project))) return;
if (!(await assertProjectWorkspaceWriteAllowed(req, res, 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";
if (req.query.view === "summary") {
const page = projectDiscoverySchema.extend({ limit: z.coerce.number().int().min(1).max(50).default(50) }).parse({
limit: req.query.limit, cursor: req.query.cursor,
});
const candidateIds = await access.projectDiscoveryCandidateIds(req.actor, companyId);
const visible: ProjectDiscoveryPage["projects"] = [];
let cursor = page.cursor;
// Scan bounded projections; authorization runs before selecting the public
// page/cursor so denied projects neither fill pages nor leak their IDs.
while (visible.length <= page.limit) {
const batch = await svc.listSummaries(companyId, { limit: 51, cursor, includeArchived, candidateIds });
const allowed = await filterProjectsForActor(req, batch.map(project => ({ ...project, companyId })));
visible.push(...allowed.map(({ companyId: _companyId, ...project }) => project));
if (batch.length < 51) break;
cursor = batch.at(-1)!.id;
}
const selected = visible.slice(0, page.limit);
res.json({ projects: selected, nextCursor: visible.length > page.limit ? selected.at(-1)!.id : null } satisfies ProjectDiscoveryPage);
return;
}
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;
if (!(await assertProjectReadAllowed(req, res, project))) return;
const summary = await externalObjectsSvc.getProjectSummary(project.id);
res.json(summary);
});
router.get("/companies/:companyId/project-environments", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
res.json(await projectEnvironmentOptions(db, companyId, {
userId: req.actor.type === "board" ? req.actor.userId : req.actor.onBehalfOfUserId,
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
}));
});
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[];
environmentId?: string;
expectedStorageProviderPluginId?: string | null;
};
const { workspace, repositoryIds, repositoryUrls, idempotencyKey, environmentId, expectedStorageProviderPluginId, ...projectData } = req.body as CreateProjectPayload & { idempotencyKey?: string; repositoryUrls?: string[] };
const receiptOnly = req.get("X-Paperclip-Receipt-Only") === "true";
if (receiptOnly && !idempotencyKey) throw unprocessable("Project receipt recovery requires an idempotency key");
projectData.privacyOwnerUserId = req.actor.type === "board" ? req.actor.userId ?? null : req.actor.type === "agent" ? req.actor.onBehalfOfUserId ?? null : null;
const runContext = req.actor.type === "agent" && req.actor.source === "agent_jwt" && req.actor.runId
? await projectToolContext(db, req.actor, true) : null;
if ((environmentId || readProjectPolicyEnvironmentId(projectData.executionWorkspacePolicy))
&& !(await assertProjectWorkspaceWriteAllowed(req, res, { companyId }))) return;
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, environmentId, expectedStorageProviderPluginId })).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 museActor = runContext && req.actor.type === "agent" && req.actor.authorizingUserId ? req.actor : null;
if (museActor) {
const { companyId: actorCompanyId, agentId, runId, authorizingUserId } = museActor;
if (!actorCompanyId || !agentId || !runId) throw forbidden("Muse native tool authority is unavailable");
// The receipt lock can wait too. Keep current authority behind every
// admission wait and ahead of both receipt replay and project creation.
await withExternalExecutionAdmission(tx, actorCompanyId, agentId, runId, async () => {
const current = await projectToolContext(tx as unknown as Db, museActor, true, "Project", { includeExecutionInput: true });
try {
const execution = parseNativeExecutionInput(current.run.runnerProfileJson?.nativeExecutionInput);
const broker = museExternalAgents(db);
if (current.run.runtimeMode !== "native" || execution.schema !== "paperclip.native-execution-input.v8"
|| execution.binding.companyId !== actorCompanyId || execution.binding.agentId !== agentId
|| execution.binding.runId !== runId || execution.binding.issueId !== current.issue.id
|| await broker.authorizingUserIdForBinding(execution.provider.binding) !== authorizingUserId) {
throw new Error("Muse internal tool authority mismatch");
}
await broker.assertRunAuthority(execution, tx as unknown as Db);
} catch {
throw forbidden("Muse native tool authority is unavailable");
}
});
}
if (receiptOnly && runContext && !museActor) await projectToolContext(tx as unknown as Db, req.actor, true);
if (receiptKey) {
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 (receiptOnly) return null;
if (runContext && !museActor) await projectToolContext(tx as unknown as Db, req.actor, true);
const service = projectService(tx as unknown as Db);
const environment = {
environmentId,
expectedStorageProviderPluginId,
userId: req.actor.type === "board" ? req.actor.userId : req.actor.onBehalfOfUserId,
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
};
const project = repositories ? await service.createWithRepositories(companyId, projectData, repositories, environment) : await service.create(companyId, projectData, environment);
if (project.visibility === "private") await addActorAsPrivateProjectPrincipal(req, project, tx as unknown as Db);
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) { res.status(204).end(); return; }
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;
if (!(await assertProjectReadAllowed(req, res, existing))) return;
const body = { ...req.body };
const nextEnvironmentId = body.environmentId !== undefined ? body.environmentId : readProjectPolicyEnvironmentId(body.executionWorkspacePolicy);
const environmentChanged = nextEnvironmentId !== undefined && nextEnvironmentId !== (existing.environmentId ?? null);
if (environmentChanged && !(await assertProjectWorkspaceWriteAllowed(req, res, existing))) return;
if (body.visibility !== undefined) {
if (!(await assertCanManageProjectPrivacy(req, res, existing))) return;
}
if (body.visibility === "private") await addActorAsPrivateProjectPrincipal(req, existing);
assertNoAgentHostWorkspaceCommandMutation(
req,
collectProjectExecutionWorkspaceCommandPaths(body.executionWorkspacePolicy),
);
if (environmentChanged && body.environmentId === undefined) {
await assertProjectEnvironmentSelection(existing.companyId, nextEnvironmentId);
}
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/access-members", async (req, res) => {
const id = req.params.id as string;
const project = await getAccessibleResource(req, res, svc.getById(id), "Project not found");
if (!project || !(await assertProjectReadAllowed(req, res, project))) return;
const rows = await db
.select()
.from(projectAccessMembers)
.where(and(eq(projectAccessMembers.companyId, project.companyId), eq(projectAccessMembers.projectId, project.id)));
res.json(await enrichProjectAccessMembers(rows));
});
router.post("/projects/:id/access-members", validate(addProjectAccessMemberSchema), async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const project = await getAccessibleResource(req, res, svc.getById(id), "Project not found");
if (!project || !(await assertProjectReadAllowed(req, res, project))) return;
if (!(await assertCanManageProjectPrivacy(req, res, project))) return;
const subjectType = req.body.subjectType as "user" | "agent";
const subjectId = req.body.subjectId as string;
const subjectExists = subjectType === "agent"
? await db.select({ id: agents.id }).from(agents)
.where(and(eq(agents.id, subjectId), eq(agents.companyId, project.companyId)))
.then((rows) => rows.length > 0)
: await db.select({ id: companyMemberships.id }).from(companyMemberships)
.where(and(
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.principalId, subjectId),
eq(companyMemberships.companyId, project.companyId),
eq(companyMemberships.status, "active"),
))
.then((rows) => rows.length > 0);
if (!subjectExists) {
res.status(422).json({ error: "Project access member must belong to this company" });
return;
}
await ensureProjectAccessMember(db, { companyId: project.companyId, projectId: project.id, subjectType, subjectId });
const rows = await db.select().from(projectAccessMembers).where(and(
eq(projectAccessMembers.projectId, project.id),
eq(projectAccessMembers.subjectType, subjectType),
eq(projectAccessMembers.subjectId, subjectId),
));
const [member] = await enrichProjectAccessMembers(rows);
const actor = getActorInfo(req);
await logActivity(db, {
companyId: project.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "project.access_member_added",
entityType: "project",
entityId: project.id,
details: { subjectType, subjectId },
});
res.status(201).json(member);
});
router.delete("/projects/:id/access-members/:memberId", async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const project = await getAccessibleResource(req, res, svc.getById(id), "Project not found");
if (!project || !(await assertProjectReadAllowed(req, res, project))) return;
if (!(await assertCanManageProjectPrivacy(req, res, project))) return;
const member = await db.select().from(projectAccessMembers).where(and(
eq(projectAccessMembers.id, req.params.memberId as string),
eq(projectAccessMembers.companyId, project.companyId),
eq(projectAccessMembers.projectId, project.id),
)).then((rows) => rows[0] ?? null);
if (!member) {
res.status(404).json({ error: "Project access member not found" });
return;
}
if (member.subjectType === "user" && [project.personalOwnerUserId, project.privacyOwnerUserId].includes(member.subjectId)) {
res.status(422).json({ error: "The project privacy owner cannot be removed" });
return;
}
await db.delete(projectAccessMembers).where(eq(projectAccessMembers.id, member.id));
const actor = getActorInfo(req);
await logActivity(db, {
companyId: project.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "project.access_member_removed",
entityType: "project",
entityId: project.id,
details: { subjectType: member.subjectType, subjectId: member.subjectId },
});
res.json(member);
});
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;
if (!(await assertProjectReadAllowed(req, res, 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;
if (!(await assertProjectReadAllowed(req, res, existing))) return;
if (!(await assertProjectWorkspaceWriteAllowed(req, res, 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;
if (!(await assertProjectReadAllowed(req, res, existing))) return;
if (!(await assertProjectWorkspaceWriteAllowed(req, res, 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;
if (!(await assertProjectReadAllowed(req, res, 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;
if (!(await assertProjectReadAllowed(req, res, existing))) return;
if (!(await assertProjectWorkspaceWriteAllowed(req, res, 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;
if (!(await assertProjectReadAllowed(req, res, 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;
}