Implement execution workspaces and work products

This commit is contained in:
Dotta
2026-03-13 17:12:25 -05:00
parent 9da5358bb3
commit 920bc4c70f
45 changed files with 9157 additions and 140 deletions

View File

@@ -12,36 +12,36 @@ describe("execution workspace policy helpers", () => {
expect(
defaultIssueExecutionWorkspaceSettingsForProject({
enabled: true,
defaultMode: "isolated",
defaultMode: "isolated_workspace",
}),
).toEqual({ mode: "isolated" });
).toEqual({ mode: "isolated_workspace" });
expect(
defaultIssueExecutionWorkspaceSettingsForProject({
enabled: true,
defaultMode: "project_primary",
defaultMode: "shared_workspace",
}),
).toEqual({ mode: "project_primary" });
).toEqual({ mode: "shared_workspace" });
expect(defaultIssueExecutionWorkspaceSettingsForProject(null)).toBeNull();
});
it("prefers explicit issue mode over project policy and legacy overrides", () => {
expect(
resolveExecutionWorkspaceMode({
projectPolicy: { enabled: true, defaultMode: "project_primary" },
issueSettings: { mode: "isolated" },
projectPolicy: { enabled: true, defaultMode: "shared_workspace" },
issueSettings: { mode: "isolated_workspace" },
legacyUseProjectWorkspace: false,
}),
).toBe("isolated");
).toBe("isolated_workspace");
});
it("falls back to project policy before legacy project-workspace compatibility flag", () => {
expect(
resolveExecutionWorkspaceMode({
projectPolicy: { enabled: true, defaultMode: "isolated" },
projectPolicy: { enabled: true, defaultMode: "isolated_workspace" },
issueSettings: null,
legacyUseProjectWorkspace: false,
}),
).toBe("isolated");
).toBe("isolated_workspace");
expect(
resolveExecutionWorkspaceMode({
projectPolicy: null,
@@ -58,7 +58,7 @@ describe("execution workspace policy helpers", () => {
},
projectPolicy: {
enabled: true,
defaultMode: "isolated",
defaultMode: "isolated_workspace",
workspaceStrategy: {
type: "git_worktree",
baseRef: "origin/main",
@@ -69,7 +69,7 @@ describe("execution workspace policy helpers", () => {
},
},
issueSettings: null,
mode: "isolated",
mode: "isolated_workspace",
legacyUseProjectWorkspace: null,
});
@@ -92,9 +92,9 @@ describe("execution workspace policy helpers", () => {
expect(
buildExecutionWorkspaceAdapterConfig({
agentConfig: baseConfig,
projectPolicy: { enabled: true, defaultMode: "isolated" },
issueSettings: { mode: "project_primary" },
mode: "project_primary",
projectPolicy: { enabled: true, defaultMode: "isolated_workspace" },
issueSettings: { mode: "shared_workspace" },
mode: "shared_workspace",
legacyUseProjectWorkspace: null,
}).workspaceStrategy,
).toBeUndefined();
@@ -124,7 +124,7 @@ describe("execution workspace policy helpers", () => {
}),
).toEqual({
enabled: true,
defaultMode: "isolated",
defaultMode: "isolated_workspace",
workspaceStrategy: {
type: "git_worktree",
worktreeParentDir: ".paperclip/worktrees",
@@ -137,7 +137,7 @@ describe("execution workspace policy helpers", () => {
mode: "project_primary",
}),
).toEqual({
mode: "project_primary",
mode: "shared_workspace",
});
});
});

View File

@@ -14,6 +14,7 @@ import { companyRoutes } from "./routes/companies.js";
import { agentRoutes } from "./routes/agents.js";
import { projectRoutes } from "./routes/projects.js";
import { issueRoutes } from "./routes/issues.js";
import { executionWorkspaceRoutes } from "./routes/execution-workspaces.js";
import { goalRoutes } from "./routes/goals.js";
import { approvalRoutes } from "./routes/approvals.js";
import { secretRoutes } from "./routes/secrets.js";
@@ -107,6 +108,7 @@ export async function createApp(
api.use(assetRoutes(db, opts.storageService));
api.use(projectRoutes(db));
api.use(issueRoutes(db, opts.storageService));
api.use(executionWorkspaceRoutes(db));
api.use(goalRoutes(db));
api.use(approvalRoutes(db));
api.use(secretRoutes(db));

View File

@@ -0,0 +1,68 @@
import { Router } from "express";
import type { Db } from "@paperclipai/db";
import { updateExecutionWorkspaceSchema } from "@paperclipai/shared";
import { validate } from "../middleware/validate.js";
import { executionWorkspaceService, logActivity } from "../services/index.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
export function executionWorkspaceRoutes(db: Db) {
const router = Router();
const svc = executionWorkspaceService(db);
router.get("/companies/:companyId/execution-workspaces", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const workspaces = await svc.list(companyId, {
projectId: req.query.projectId as string | undefined,
projectWorkspaceId: req.query.projectWorkspaceId as string | undefined,
issueId: req.query.issueId as string | undefined,
status: req.query.status as string | undefined,
reuseEligible: req.query.reuseEligible === "true",
});
res.json(workspaces);
});
router.get("/execution-workspaces/:id", async (req, res) => {
const id = req.params.id as string;
const workspace = await svc.getById(id);
if (!workspace) {
res.status(404).json({ error: "Execution workspace not found" });
return;
}
assertCompanyAccess(req, workspace.companyId);
res.json(workspace);
});
router.patch("/execution-workspaces/:id", validate(updateExecutionWorkspaceSchema), async (req, res) => {
const id = req.params.id as string;
const existing = await svc.getById(id);
if (!existing) {
res.status(404).json({ error: "Execution workspace not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const workspace = await svc.update(id, {
...req.body,
...(req.body.cleanupEligibleAt ? { cleanupEligibleAt: new Date(req.body.cleanupEligibleAt) } : {}),
});
if (!workspace) {
res.status(404).json({ error: "Execution workspace not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: existing.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "execution_workspace.updated",
entityType: "execution_workspace",
entityId: workspace.id,
details: { changedKeys: Object.keys(req.body).sort() },
});
res.json(workspace);
});
return router;
}

View File

@@ -4,10 +4,12 @@ import type { Db } from "@paperclipai/db";
import {
addIssueCommentSchema,
createIssueAttachmentMetadataSchema,
createIssueWorkProductSchema,
createIssueLabelSchema,
checkoutIssueSchema,
createIssueSchema,
linkIssueApprovalSchema,
updateIssueWorkProductSchema,
updateIssueSchema,
} from "@paperclipai/shared";
import type { StorageService } from "../storage/types.js";
@@ -15,12 +17,14 @@ import { validate } from "../middleware/validate.js";
import {
accessService,
agentService,
executionWorkspaceService,
goalService,
heartbeatService,
issueApprovalService,
issueService,
logActivity,
projectService,
workProductService,
} from "../services/index.js";
import { logger } from "../middleware/logger.js";
import { forbidden, HttpError, unauthorized } from "../errors.js";
@@ -37,6 +41,8 @@ export function issueRoutes(db: Db, storage: StorageService) {
const projectsSvc = projectService(db);
const goalsSvc = goalService(db);
const issueApprovalsSvc = issueApprovalService(db);
const executionWorkspacesSvc = executionWorkspaceService(db);
const workProductsSvc = workProductService(db);
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: MAX_ATTACHMENT_BYTES, files: 1 },
@@ -304,6 +310,10 @@ export function issueRoutes(db: Db, storage: StorageService) {
const mentionedProjects = mentionedProjectIds.length > 0
? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds)
: [];
const currentExecutionWorkspace = issue.executionWorkspaceId
? await executionWorkspacesSvc.getById(issue.executionWorkspaceId)
: null;
const workProducts = await workProductsSvc.listForIssue(issue.id);
res.json({
...issue,
goalId: goal?.id ?? issue.goalId,
@@ -311,9 +321,110 @@ export function issueRoutes(db: Db, storage: StorageService) {
project: project ?? null,
goal: goal ?? null,
mentionedProjects,
currentExecutionWorkspace,
workProducts,
});
});
router.get("/issues/:id/work-products", async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
const workProducts = await workProductsSvc.listForIssue(issue.id);
res.json(workProducts);
});
router.post("/issues/:id/work-products", validate(createIssueWorkProductSchema), async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
const product = await workProductsSvc.createForIssue(issue.id, issue.companyId, {
...req.body,
projectId: req.body.projectId ?? issue.projectId ?? null,
});
if (!product) {
res.status(422).json({ error: "Invalid work product payload" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.work_product_created",
entityType: "issue",
entityId: issue.id,
details: { workProductId: product.id, type: product.type, provider: product.provider },
});
res.status(201).json(product);
});
router.patch("/work-products/:id", validate(updateIssueWorkProductSchema), async (req, res) => {
const id = req.params.id as string;
const existing = await workProductsSvc.getById(id);
if (!existing) {
res.status(404).json({ error: "Work product not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const product = await workProductsSvc.update(id, req.body);
if (!product) {
res.status(404).json({ error: "Work product not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: existing.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.work_product_updated",
entityType: "issue",
entityId: existing.issueId,
details: { workProductId: product.id, changedKeys: Object.keys(req.body).sort() },
});
res.json(product);
});
router.delete("/work-products/:id", async (req, res) => {
const id = req.params.id as string;
const existing = await workProductsSvc.getById(id);
if (!existing) {
res.status(404).json({ error: "Work product not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const removed = await workProductsSvc.remove(id);
if (!removed) {
res.status(404).json({ error: "Work product not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: existing.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.work_product_deleted",
entityType: "issue",
entityId: existing.issueId,
details: { workProductId: removed.id, type: removed.type },
});
res.json(removed);
});
router.post("/issues/:id/read", async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);

View File

@@ -2,11 +2,12 @@ import type {
ExecutionWorkspaceMode,
ExecutionWorkspaceStrategy,
IssueExecutionWorkspaceSettings,
ProjectExecutionWorkspaceDefaultMode,
ProjectExecutionWorkspacePolicy,
} from "@paperclipai/shared";
import { asString, parseObject } from "../adapters/utils.js";
type ParsedExecutionWorkspaceMode = Exclude<ExecutionWorkspaceMode, "inherit">;
type ParsedExecutionWorkspaceMode = Exclude<ExecutionWorkspaceMode, "inherit" | "reuse_existing">;
function cloneRecord(value: Record<string, unknown> | null | undefined): Record<string, unknown> | null {
if (!value) return null;
@@ -16,7 +17,7 @@ function cloneRecord(value: Record<string, unknown> | null | undefined): Record<
function parseExecutionWorkspaceStrategy(raw: unknown): ExecutionWorkspaceStrategy | null {
const parsed = parseObject(raw);
const type = asString(parsed.type, "");
if (type !== "project_primary" && type !== "git_worktree") {
if (type !== "project_primary" && type !== "git_worktree" && type !== "adapter_managed" && type !== "cloud_sandbox") {
return null;
}
return {
@@ -34,12 +35,28 @@ export function parseProjectExecutionWorkspacePolicy(raw: unknown): ProjectExecu
if (Object.keys(parsed).length === 0) return null;
const enabled = typeof parsed.enabled === "boolean" ? parsed.enabled : false;
const defaultMode = asString(parsed.defaultMode, "");
const defaultProjectWorkspaceId =
typeof parsed.defaultProjectWorkspaceId === "string" ? parsed.defaultProjectWorkspaceId : undefined;
const allowIssueOverride =
typeof parsed.allowIssueOverride === "boolean" ? parsed.allowIssueOverride : undefined;
const normalizedDefaultMode = (() => {
if (
defaultMode === "shared_workspace" ||
defaultMode === "isolated_workspace" ||
defaultMode === "operator_branch" ||
defaultMode === "adapter_default"
) {
return defaultMode as ProjectExecutionWorkspaceDefaultMode;
}
if (defaultMode === "project_primary") return "shared_workspace";
if (defaultMode === "isolated") return "isolated_workspace";
return undefined;
})();
return {
enabled,
...(defaultMode === "project_primary" || defaultMode === "isolated" ? { defaultMode } : {}),
...(normalizedDefaultMode ? { defaultMode: normalizedDefaultMode } : {}),
...(allowIssueOverride !== undefined ? { allowIssueOverride } : {}),
...(defaultProjectWorkspaceId ? { defaultProjectWorkspaceId } : {}),
...(parseExecutionWorkspaceStrategy(parsed.workspaceStrategy)
? { workspaceStrategy: parseExecutionWorkspaceStrategy(parsed.workspaceStrategy) }
: {}),
@@ -52,6 +69,9 @@ export function parseProjectExecutionWorkspacePolicy(raw: unknown): ProjectExecu
...(parsed.pullRequestPolicy && typeof parsed.pullRequestPolicy === "object" && !Array.isArray(parsed.pullRequestPolicy)
? { pullRequestPolicy: { ...(parsed.pullRequestPolicy as Record<string, unknown>) } }
: {}),
...(parsed.runtimePolicy && typeof parsed.runtimePolicy === "object" && !Array.isArray(parsed.runtimePolicy)
? { runtimePolicy: { ...(parsed.runtimePolicy as Record<string, unknown>) } }
: {}),
...(parsed.cleanupPolicy && typeof parsed.cleanupPolicy === "object" && !Array.isArray(parsed.cleanupPolicy)
? { cleanupPolicy: { ...(parsed.cleanupPolicy as Record<string, unknown>) } }
: {}),
@@ -62,9 +82,24 @@ export function parseIssueExecutionWorkspaceSettings(raw: unknown): IssueExecuti
const parsed = parseObject(raw);
if (Object.keys(parsed).length === 0) return null;
const mode = asString(parsed.mode, "");
const normalizedMode = (() => {
if (
mode === "inherit" ||
mode === "shared_workspace" ||
mode === "isolated_workspace" ||
mode === "operator_branch" ||
mode === "reuse_existing" ||
mode === "agent_default"
) {
return mode;
}
if (mode === "project_primary") return "shared_workspace";
if (mode === "isolated") return "isolated_workspace";
return "";
})();
return {
...(mode === "inherit" || mode === "project_primary" || mode === "isolated" || mode === "agent_default"
? { mode }
...(normalizedMode
? { mode: normalizedMode as IssueExecutionWorkspaceSettings["mode"] }
: {}),
...(parseExecutionWorkspaceStrategy(parsed.workspaceStrategy)
? { workspaceStrategy: parseExecutionWorkspaceStrategy(parsed.workspaceStrategy) }
@@ -80,7 +115,14 @@ export function defaultIssueExecutionWorkspaceSettingsForProject(
): IssueExecutionWorkspaceSettings | null {
if (!projectPolicy?.enabled) return null;
return {
mode: projectPolicy.defaultMode === "isolated" ? "isolated" : "project_primary",
mode:
projectPolicy.defaultMode === "isolated_workspace"
? "isolated_workspace"
: projectPolicy.defaultMode === "operator_branch"
? "operator_branch"
: projectPolicy.defaultMode === "adapter_default"
? "agent_default"
: "shared_workspace",
};
}
@@ -90,16 +132,19 @@ export function resolveExecutionWorkspaceMode(input: {
legacyUseProjectWorkspace: boolean | null;
}): ParsedExecutionWorkspaceMode {
const issueMode = input.issueSettings?.mode;
if (issueMode && issueMode !== "inherit") {
if (issueMode && issueMode !== "inherit" && issueMode !== "reuse_existing") {
return issueMode;
}
if (input.projectPolicy?.enabled) {
return input.projectPolicy.defaultMode === "isolated" ? "isolated" : "project_primary";
if (input.projectPolicy.defaultMode === "isolated_workspace") return "isolated_workspace";
if (input.projectPolicy.defaultMode === "operator_branch") return "operator_branch";
if (input.projectPolicy.defaultMode === "adapter_default") return "agent_default";
return "shared_workspace";
}
if (input.legacyUseProjectWorkspace === false) {
return "agent_default";
}
return "project_primary";
return "shared_workspace";
}
export function buildExecutionWorkspaceAdapterConfig(input: {
@@ -119,7 +164,7 @@ export function buildExecutionWorkspaceAdapterConfig(input: {
const hasWorkspaceControl = projectHasPolicy || issueHasWorkspaceOverrides || input.legacyUseProjectWorkspace === false;
if (hasWorkspaceControl) {
if (input.mode === "isolated") {
if (input.mode === "isolated_workspace") {
const strategy =
input.issueSettings?.workspaceStrategy ??
input.projectPolicy?.workspaceStrategy ??

View File

@@ -0,0 +1,99 @@
import { and, desc, eq, inArray } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { executionWorkspaces } from "@paperclipai/db";
import type { ExecutionWorkspace } from "@paperclipai/shared";
type ExecutionWorkspaceRow = typeof executionWorkspaces.$inferSelect;
function toExecutionWorkspace(row: ExecutionWorkspaceRow): ExecutionWorkspace {
return {
id: row.id,
companyId: row.companyId,
projectId: row.projectId,
projectWorkspaceId: row.projectWorkspaceId ?? null,
sourceIssueId: row.sourceIssueId ?? null,
mode: row.mode as ExecutionWorkspace["mode"],
strategyType: row.strategyType as ExecutionWorkspace["strategyType"],
name: row.name,
status: row.status as ExecutionWorkspace["status"],
cwd: row.cwd ?? null,
repoUrl: row.repoUrl ?? null,
baseRef: row.baseRef ?? null,
branchName: row.branchName ?? null,
providerType: row.providerType as ExecutionWorkspace["providerType"],
providerRef: row.providerRef ?? null,
derivedFromExecutionWorkspaceId: row.derivedFromExecutionWorkspaceId ?? null,
lastUsedAt: row.lastUsedAt,
openedAt: row.openedAt,
closedAt: row.closedAt ?? null,
cleanupEligibleAt: row.cleanupEligibleAt ?? null,
cleanupReason: row.cleanupReason ?? null,
metadata: (row.metadata as Record<string, unknown> | null) ?? null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
export function executionWorkspaceService(db: Db) {
return {
list: async (companyId: string, filters?: {
projectId?: string;
projectWorkspaceId?: string;
issueId?: string;
status?: string;
reuseEligible?: boolean;
}) => {
const conditions = [eq(executionWorkspaces.companyId, companyId)];
if (filters?.projectId) conditions.push(eq(executionWorkspaces.projectId, filters.projectId));
if (filters?.projectWorkspaceId) {
conditions.push(eq(executionWorkspaces.projectWorkspaceId, filters.projectWorkspaceId));
}
if (filters?.issueId) conditions.push(eq(executionWorkspaces.sourceIssueId, filters.issueId));
if (filters?.status) {
const statuses = filters.status.split(",").map((value) => value.trim()).filter(Boolean);
if (statuses.length === 1) conditions.push(eq(executionWorkspaces.status, statuses[0]!));
else if (statuses.length > 1) conditions.push(inArray(executionWorkspaces.status, statuses));
}
if (filters?.reuseEligible) {
conditions.push(inArray(executionWorkspaces.status, ["active", "idle", "in_review"]));
}
const rows = await db
.select()
.from(executionWorkspaces)
.where(and(...conditions))
.orderBy(desc(executionWorkspaces.lastUsedAt), desc(executionWorkspaces.createdAt));
return rows.map(toExecutionWorkspace);
},
getById: async (id: string) => {
const row = await db
.select()
.from(executionWorkspaces)
.where(eq(executionWorkspaces.id, id))
.then((rows) => rows[0] ?? null);
return row ? toExecutionWorkspace(row) : null;
},
create: async (data: typeof executionWorkspaces.$inferInsert) => {
const row = await db
.insert(executionWorkspaces)
.values(data)
.returning()
.then((rows) => rows[0] ?? null);
return row ? toExecutionWorkspace(row) : null;
},
update: async (id: string, patch: Partial<typeof executionWorkspaces.$inferInsert>) => {
const row = await db
.update(executionWorkspaces)
.set({ ...patch, updatedAt: new Date() })
.where(eq(executionWorkspaces.id, id))
.returning()
.then((rows) => rows[0] ?? null);
return row ? toExecutionWorkspace(row) : null;
},
};
}
export { toExecutionWorkspace };

View File

@@ -33,6 +33,7 @@ import {
releaseRuntimeServicesForRun,
} from "./workspace-runtime.js";
import { issueService } from "./issues.js";
import { executionWorkspaceService } from "./execution-workspaces.js";
import {
buildExecutionWorkspaceAdapterConfig,
parseIssueExecutionWorkspaceSettings,
@@ -455,6 +456,7 @@ export function heartbeatService(db: Db) {
const runLogStore = getRunLogStore();
const secretsSvc = secretService(db);
const issuesSvc = issueService(db);
const executionWorkspacesSvc = executionWorkspaceService(db);
const activeRunExecutions = new Set<string>();
async function getAgent(agentId: string) {
@@ -1130,6 +1132,9 @@ export function heartbeatService(db: Db) {
? await db
.select({
projectId: issues.projectId,
projectWorkspaceId: issues.projectWorkspaceId,
executionWorkspaceId: issues.executionWorkspaceId,
executionWorkspacePreference: issues.executionWorkspacePreference,
assigneeAgentId: issues.assigneeAgentId,
assigneeAdapterOverrides: issues.assigneeAdapterOverrides,
executionWorkspaceSettings: issues.executionWorkspaceSettings,
@@ -1197,6 +1202,10 @@ export function heartbeatService(db: Db) {
id: issues.id,
identifier: issues.identifier,
title: issues.title,
projectId: issues.projectId,
projectWorkspaceId: issues.projectWorkspaceId,
executionWorkspaceId: issues.executionWorkspaceId,
executionWorkspacePreference: issues.executionWorkspacePreference,
})
.from(issues)
.where(and(eq(issues.id, issueId), eq(issues.companyId, agent.companyId)))
@@ -1219,6 +1228,67 @@ export function heartbeatService(db: Db) {
companyId: agent.companyId,
},
});
const existingExecutionWorkspace =
issueRef?.executionWorkspaceId ? await executionWorkspacesSvc.getById(issueRef.executionWorkspaceId) : null;
const resolvedProjectId = executionWorkspace.projectId ?? issueRef?.projectId ?? executionProjectId ?? null;
const resolvedProjectWorkspaceId = issueRef?.projectWorkspaceId ?? resolvedWorkspace.workspaceId ?? null;
const shouldReuseExisting =
issueRef?.executionWorkspacePreference === "reuse_existing" &&
existingExecutionWorkspace &&
existingExecutionWorkspace.status !== "archived";
const persistedExecutionWorkspace = shouldReuseExisting && existingExecutionWorkspace
? await executionWorkspacesSvc.update(existingExecutionWorkspace.id, {
cwd: executionWorkspace.cwd,
repoUrl: executionWorkspace.repoUrl,
baseRef: executionWorkspace.repoRef,
branchName: executionWorkspace.branchName,
providerType: executionWorkspace.strategy === "git_worktree" ? "git_worktree" : "local_fs",
providerRef: executionWorkspace.worktreePath,
status: "active",
lastUsedAt: new Date(),
metadata: {
...(existingExecutionWorkspace.metadata ?? {}),
source: executionWorkspace.source,
createdByRuntime: executionWorkspace.created,
},
})
: resolvedProjectId
? await executionWorkspacesSvc.create({
companyId: agent.companyId,
projectId: resolvedProjectId,
projectWorkspaceId: resolvedProjectWorkspaceId,
sourceIssueId: issueRef?.id ?? null,
mode:
executionWorkspaceMode === "isolated_workspace"
? "isolated_workspace"
: executionWorkspaceMode === "operator_branch"
? "operator_branch"
: executionWorkspaceMode === "agent_default"
? "adapter_managed"
: "shared_workspace",
strategyType: executionWorkspace.strategy === "git_worktree" ? "git_worktree" : "project_primary",
name: executionWorkspace.branchName ?? issueRef?.identifier ?? `workspace-${agent.id.slice(0, 8)}`,
status: "active",
cwd: executionWorkspace.cwd,
repoUrl: executionWorkspace.repoUrl,
baseRef: executionWorkspace.repoRef,
branchName: executionWorkspace.branchName,
providerType: executionWorkspace.strategy === "git_worktree" ? "git_worktree" : "local_fs",
providerRef: executionWorkspace.worktreePath,
lastUsedAt: new Date(),
openedAt: new Date(),
metadata: {
source: executionWorkspace.source,
createdByRuntime: executionWorkspace.created,
},
})
: null;
if (issueId && persistedExecutionWorkspace && issueRef?.executionWorkspaceId !== persistedExecutionWorkspace.id) {
await issuesSvc.update(issueId, {
executionWorkspaceId: persistedExecutionWorkspace.id,
...(resolvedProjectWorkspaceId ? { projectWorkspaceId: resolvedProjectWorkspaceId } : {}),
});
}
const runtimeSessionResolution = resolveRuntimeSessionParamsForWorkspace({
agentId: agent.id,
previousSessionParams,

View File

@@ -14,6 +14,8 @@ export { dashboardService } from "./dashboard.js";
export { sidebarBadgeService } from "./sidebar-badges.js";
export { accessService } from "./access.js";
export { companyPortabilityService } from "./company-portability.js";
export { executionWorkspaceService } from "./execution-workspaces.js";
export { workProductService } from "./work-products.js";
export { logActivity, type LogActivityInput } from "./activity-log.js";
export { notifyHireApproved, type NotifyHireApprovedInput } from "./hire-hook.js";
export { publishLiveEvent, subscribeCompanyLiveEvents } from "./live-events.js";

View File

@@ -7,6 +7,7 @@ import {
companyMemberships,
goals,
heartbeatRuns,
executionWorkspaces,
issueAttachments,
issueLabels,
issueComments,
@@ -353,6 +354,40 @@ export function issueService(db: Db) {
}
}
async function assertValidProjectWorkspace(companyId: string, projectId: string | null | undefined, projectWorkspaceId: string) {
const workspace = await db
.select({
id: projectWorkspaces.id,
companyId: projectWorkspaces.companyId,
projectId: projectWorkspaces.projectId,
})
.from(projectWorkspaces)
.where(eq(projectWorkspaces.id, projectWorkspaceId))
.then((rows) => rows[0] ?? null);
if (!workspace) throw notFound("Project workspace not found");
if (workspace.companyId !== companyId) throw unprocessable("Project workspace must belong to same company");
if (projectId && workspace.projectId !== projectId) {
throw unprocessable("Project workspace must belong to the selected project");
}
}
async function assertValidExecutionWorkspace(companyId: string, projectId: string | null | undefined, executionWorkspaceId: string) {
const workspace = await db
.select({
id: executionWorkspaces.id,
companyId: executionWorkspaces.companyId,
projectId: executionWorkspaces.projectId,
})
.from(executionWorkspaces)
.where(eq(executionWorkspaces.id, executionWorkspaceId))
.then((rows) => rows[0] ?? null);
if (!workspace) throw notFound("Execution workspace not found");
if (workspace.companyId !== companyId) throw unprocessable("Execution workspace must belong to same company");
if (projectId && workspace.projectId !== projectId) {
throw unprocessable("Execution workspace must belong to the selected project");
}
}
async function assertValidLabelIds(companyId: string, labelIds: string[], dbOrTx: any = db) {
if (labelIds.length === 0) return;
const existing = await dbOrTx
@@ -647,6 +682,12 @@ export function issueService(db: Db) {
if (data.assigneeUserId) {
await assertAssignableUser(companyId, data.assigneeUserId);
}
if (data.projectWorkspaceId) {
await assertValidProjectWorkspace(companyId, data.projectId, data.projectWorkspaceId);
}
if (data.executionWorkspaceId) {
await assertValidExecutionWorkspace(companyId, data.projectId, data.executionWorkspaceId);
}
if (data.status === "in_progress" && !data.assigneeAgentId && !data.assigneeUserId) {
throw unprocessable("in_progress issues require an assignee");
}
@@ -665,6 +706,26 @@ export function issueService(db: Db) {
parseProjectExecutionWorkspacePolicy(project?.executionWorkspacePolicy),
) as Record<string, unknown> | null;
}
let projectWorkspaceId = issueData.projectWorkspaceId ?? null;
if (!projectWorkspaceId && issueData.projectId) {
const project = await tx
.select({
executionWorkspacePolicy: projects.executionWorkspacePolicy,
})
.from(projects)
.where(and(eq(projects.id, issueData.projectId), eq(projects.companyId, companyId)))
.then((rows) => rows[0] ?? null);
const projectPolicy = parseProjectExecutionWorkspacePolicy(project?.executionWorkspacePolicy);
projectWorkspaceId = projectPolicy?.defaultProjectWorkspaceId ?? null;
if (!projectWorkspaceId) {
projectWorkspaceId = await tx
.select({ id: projectWorkspaces.id })
.from(projectWorkspaces)
.where(and(eq(projectWorkspaces.projectId, issueData.projectId), eq(projectWorkspaces.companyId, companyId)))
.orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id))
.then((rows) => rows[0]?.id ?? null);
}
}
const [company] = await tx
.update(companies)
.set({ issueCounter: sql`${companies.issueCounter} + 1` })
@@ -681,6 +742,7 @@ export function issueService(db: Db) {
goalId: issueData.goalId,
defaultGoalId: defaultCompanyGoal?.id ?? null,
}),
...(projectWorkspaceId ? { projectWorkspaceId } : {}),
...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}),
companyId,
issueNumber,
@@ -741,6 +803,17 @@ export function issueService(db: Db) {
if (issueData.assigneeUserId) {
await assertAssignableUser(existing.companyId, issueData.assigneeUserId);
}
const nextProjectId = issueData.projectId !== undefined ? issueData.projectId : existing.projectId;
const nextProjectWorkspaceId =
issueData.projectWorkspaceId !== undefined ? issueData.projectWorkspaceId : existing.projectWorkspaceId;
const nextExecutionWorkspaceId =
issueData.executionWorkspaceId !== undefined ? issueData.executionWorkspaceId : existing.executionWorkspaceId;
if (nextProjectWorkspaceId) {
await assertValidProjectWorkspace(existing.companyId, nextProjectId, nextProjectWorkspaceId);
}
if (nextExecutionWorkspaceId) {
await assertValidExecutionWorkspace(existing.companyId, nextProjectId, nextExecutionWorkspaceId);
}
applyStatusSideEffects(issueData.status, patch);
if (issueData.status && issueData.status !== "done") {

View File

@@ -20,9 +20,17 @@ type WorkspaceRuntimeServiceRow = typeof workspaceRuntimeServices.$inferSelect;
const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__";
type CreateWorkspaceInput = {
name?: string | null;
sourceType?: string | null;
cwd?: string | null;
repoUrl?: string | null;
repoRef?: string | null;
defaultRef?: string | null;
visibility?: string | null;
setupCommand?: string | null;
cleanupCommand?: string | null;
remoteProvider?: string | null;
remoteWorkspaceRef?: string | null;
sharedWorkspaceKey?: string | null;
metadata?: Record<string, unknown> | null;
isPrimary?: boolean;
};
@@ -91,6 +99,7 @@ function toRuntimeService(row: WorkspaceRuntimeServiceRow): WorkspaceRuntimeServ
companyId: row.companyId,
projectId: row.projectId ?? null,
projectWorkspaceId: row.projectWorkspaceId ?? null,
executionWorkspaceId: row.executionWorkspaceId ?? null,
issueId: row.issueId ?? null,
scopeType: row.scopeType as WorkspaceRuntimeService["scopeType"],
scopeId: row.scopeId ?? null,
@@ -125,9 +134,17 @@ function toWorkspace(
companyId: row.companyId,
projectId: row.projectId,
name: row.name,
sourceType: row.sourceType as ProjectWorkspace["sourceType"],
cwd: row.cwd,
repoUrl: row.repoUrl ?? null,
repoRef: row.repoRef ?? null,
defaultRef: row.defaultRef ?? row.repoRef ?? null,
visibility: row.visibility as ProjectWorkspace["visibility"],
setupCommand: row.setupCommand ?? null,
cleanupCommand: row.cleanupCommand ?? null,
remoteProvider: row.remoteProvider ?? null,
remoteWorkspaceRef: row.remoteWorkspaceRef ?? null,
sharedWorkspaceKey: row.sharedWorkspaceKey ?? null,
metadata: (row.metadata as Record<string, unknown> | null) ?? null,
isPrimary: row.isPrimary,
runtimeServices,
@@ -491,7 +508,13 @@ export function projectService(db: Db) {
const cwd = normalizeWorkspaceCwd(data.cwd);
const repoUrl = readNonEmptyString(data.repoUrl);
if (!cwd && !repoUrl) return null;
const sourceType = readNonEmptyString(data.sourceType) ?? (repoUrl ? "git_repo" : cwd ? "local_path" : "remote_managed");
const remoteWorkspaceRef = readNonEmptyString(data.remoteWorkspaceRef);
if (sourceType === "remote_managed") {
if (!remoteWorkspaceRef && !repoUrl) return null;
} else if (!cwd && !repoUrl) {
return null;
}
const name = deriveWorkspaceName({
name: data.name,
cwd,
@@ -525,9 +548,17 @@ export function projectService(db: Db) {
companyId: project.companyId,
projectId,
name,
sourceType,
cwd: cwd ?? null,
repoUrl: repoUrl ?? null,
repoRef: readNonEmptyString(data.repoRef),
defaultRef: readNonEmptyString(data.defaultRef) ?? readNonEmptyString(data.repoRef),
visibility: readNonEmptyString(data.visibility) ?? "default",
setupCommand: readNonEmptyString(data.setupCommand),
cleanupCommand: readNonEmptyString(data.cleanupCommand),
remoteProvider: readNonEmptyString(data.remoteProvider),
remoteWorkspaceRef,
sharedWorkspaceKey: readNonEmptyString(data.sharedWorkspaceKey),
metadata: (data.metadata as Record<string, unknown> | null | undefined) ?? null,
isPrimary: shouldBePrimary,
})
@@ -564,7 +595,19 @@ export function projectService(db: Db) {
data.repoUrl !== undefined
? readNonEmptyString(data.repoUrl)
: readNonEmptyString(existing.repoUrl);
if (!nextCwd && !nextRepoUrl) return null;
const nextSourceType =
data.sourceType !== undefined
? readNonEmptyString(data.sourceType)
: readNonEmptyString(existing.sourceType);
const nextRemoteWorkspaceRef =
data.remoteWorkspaceRef !== undefined
? readNonEmptyString(data.remoteWorkspaceRef)
: readNonEmptyString(existing.remoteWorkspaceRef);
if (nextSourceType === "remote_managed") {
if (!nextRemoteWorkspaceRef && !nextRepoUrl) return null;
} else if (!nextCwd && !nextRepoUrl) {
return null;
}
const patch: Partial<typeof projectWorkspaces.$inferInsert> = {
updatedAt: new Date(),
@@ -576,6 +619,16 @@ export function projectService(db: Db) {
if (data.cwd !== undefined) patch.cwd = nextCwd ?? null;
if (data.repoUrl !== undefined) patch.repoUrl = nextRepoUrl ?? null;
if (data.repoRef !== undefined) patch.repoRef = readNonEmptyString(data.repoRef);
if (data.sourceType !== undefined && nextSourceType) patch.sourceType = nextSourceType;
if (data.defaultRef !== undefined) patch.defaultRef = readNonEmptyString(data.defaultRef);
if (data.visibility !== undefined && readNonEmptyString(data.visibility)) {
patch.visibility = readNonEmptyString(data.visibility)!;
}
if (data.setupCommand !== undefined) patch.setupCommand = readNonEmptyString(data.setupCommand);
if (data.cleanupCommand !== undefined) patch.cleanupCommand = readNonEmptyString(data.cleanupCommand);
if (data.remoteProvider !== undefined) patch.remoteProvider = readNonEmptyString(data.remoteProvider);
if (data.remoteWorkspaceRef !== undefined) patch.remoteWorkspaceRef = nextRemoteWorkspaceRef;
if (data.sharedWorkspaceKey !== undefined) patch.sharedWorkspaceKey = readNonEmptyString(data.sharedWorkspaceKey);
if (data.metadata !== undefined) patch.metadata = data.metadata;
const updated = await db.transaction(async (tx) => {

View File

@@ -0,0 +1,113 @@
import { and, desc, eq } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { issueWorkProducts } from "@paperclipai/db";
import type { IssueWorkProduct } from "@paperclipai/shared";
type IssueWorkProductRow = typeof issueWorkProducts.$inferSelect;
function toIssueWorkProduct(row: IssueWorkProductRow): IssueWorkProduct {
return {
id: row.id,
companyId: row.companyId,
projectId: row.projectId ?? null,
issueId: row.issueId,
executionWorkspaceId: row.executionWorkspaceId ?? null,
runtimeServiceId: row.runtimeServiceId ?? null,
type: row.type as IssueWorkProduct["type"],
provider: row.provider,
externalId: row.externalId ?? null,
title: row.title,
url: row.url ?? null,
status: row.status,
reviewState: row.reviewState as IssueWorkProduct["reviewState"],
isPrimary: row.isPrimary,
healthStatus: row.healthStatus as IssueWorkProduct["healthStatus"],
summary: row.summary ?? null,
metadata: (row.metadata as Record<string, unknown> | null) ?? null,
createdByRunId: row.createdByRunId ?? null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
export function workProductService(db: Db) {
return {
listForIssue: async (issueId: string) => {
const rows = await db
.select()
.from(issueWorkProducts)
.where(eq(issueWorkProducts.issueId, issueId))
.orderBy(desc(issueWorkProducts.isPrimary), desc(issueWorkProducts.updatedAt));
return rows.map(toIssueWorkProduct);
},
getById: async (id: string) => {
const row = await db
.select()
.from(issueWorkProducts)
.where(eq(issueWorkProducts.id, id))
.then((rows) => rows[0] ?? null);
return row ? toIssueWorkProduct(row) : null;
},
createForIssue: async (issueId: string, companyId: string, data: Omit<typeof issueWorkProducts.$inferInsert, "issueId" | "companyId">) => {
if (data.isPrimary) {
await db
.update(issueWorkProducts)
.set({ isPrimary: false, updatedAt: new Date() })
.where(and(eq(issueWorkProducts.companyId, companyId), eq(issueWorkProducts.issueId, issueId), eq(issueWorkProducts.type, data.type)));
}
const row = await db
.insert(issueWorkProducts)
.values({
...data,
companyId,
issueId,
})
.returning()
.then((rows) => rows[0] ?? null);
return row ? toIssueWorkProduct(row) : null;
},
update: async (id: string, patch: Partial<typeof issueWorkProducts.$inferInsert>) => {
const existing = await db
.select()
.from(issueWorkProducts)
.where(eq(issueWorkProducts.id, id))
.then((rows) => rows[0] ?? null);
if (!existing) return null;
if (patch.isPrimary === true) {
await db
.update(issueWorkProducts)
.set({ isPrimary: false, updatedAt: new Date() })
.where(
and(
eq(issueWorkProducts.companyId, existing.companyId),
eq(issueWorkProducts.issueId, existing.issueId),
eq(issueWorkProducts.type, existing.type),
),
);
}
const row = await db
.update(issueWorkProducts)
.set({ ...patch, updatedAt: new Date() })
.where(eq(issueWorkProducts.id, id))
.returning()
.then((rows) => rows[0] ?? null);
return row ? toIssueWorkProduct(row) : null;
},
remove: async (id: string) => {
const row = await db
.delete(issueWorkProducts)
.where(eq(issueWorkProducts.id, id))
.returning()
.then((rows) => rows[0] ?? null);
return row ? toIssueWorkProduct(row) : null;
},
};
}
export { toIssueWorkProduct };