diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 891011f7..d974d7f1 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -186,6 +186,7 @@ export type { ProjectGoalRef, ProjectWorkspace, ExecutionWorkspace, + ExecutionWorkspaceConfig, WorkspaceRuntimeService, WorkspaceOperation, WorkspaceOperationPhase, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index dd615c4c..b356999b 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -50,6 +50,7 @@ export type { AssetImage } from "./asset.js"; export type { Project, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectWorkspace } from "./project.js"; export type { ExecutionWorkspace, + ExecutionWorkspaceConfig, WorkspaceRuntimeService, ExecutionWorkspaceStrategyType, ExecutionWorkspaceMode, diff --git a/packages/shared/src/types/workspace-runtime.ts b/packages/shared/src/types/workspace-runtime.ts index 47ed9494..b5e22c05 100644 --- a/packages/shared/src/types/workspace-runtime.ts +++ b/packages/shared/src/types/workspace-runtime.ts @@ -40,6 +40,13 @@ export interface ExecutionWorkspaceStrategy { teardownCommand?: string | null; } +export interface ExecutionWorkspaceConfig { + provisionCommand: string | null; + teardownCommand: string | null; + cleanupCommand: string | null; + workspaceRuntime: Record | null; +} + export interface ProjectExecutionWorkspacePolicy { enabled: boolean; defaultMode?: ProjectExecutionWorkspaceDefaultMode; @@ -81,7 +88,9 @@ export interface ExecutionWorkspace { closedAt: Date | null; cleanupEligibleAt: Date | null; cleanupReason: string | null; + config: ExecutionWorkspaceConfig | null; metadata: Record | null; + runtimeServices?: WorkspaceRuntimeService[]; createdAt: Date; updatedAt: Date; } diff --git a/packages/shared/src/validators/execution-workspace.ts b/packages/shared/src/validators/execution-workspace.ts index 53a74036..d1d74b60 100644 --- a/packages/shared/src/validators/execution-workspace.ts +++ b/packages/shared/src/validators/execution-workspace.ts @@ -8,10 +8,24 @@ export const executionWorkspaceStatusSchema = z.enum([ "cleanup_failed", ]); +export const executionWorkspaceConfigSchema = z.object({ + provisionCommand: z.string().optional().nullable(), + teardownCommand: z.string().optional().nullable(), + cleanupCommand: z.string().optional().nullable(), + workspaceRuntime: z.record(z.unknown()).optional().nullable(), +}).strict(); + export const updateExecutionWorkspaceSchema = z.object({ + name: z.string().min(1).optional(), + cwd: z.string().optional().nullable(), + repoUrl: z.string().optional().nullable(), + baseRef: z.string().optional().nullable(), + branchName: z.string().optional().nullable(), + providerRef: z.string().optional().nullable(), status: executionWorkspaceStatusSchema.optional(), cleanupEligibleAt: z.string().datetime().optional().nullable(), cleanupReason: z.string().optional().nullable(), + config: executionWorkspaceConfigSchema.optional().nullable(), metadata: z.record(z.unknown()).optional().nullable(), }).strict(); diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 3f33bceb..094a5bd3 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -151,6 +151,7 @@ export { } from "./work-product.js"; export { + executionWorkspaceConfigSchema, updateExecutionWorkspaceSchema, executionWorkspaceStatusSchema, type UpdateExecutionWorkspace, diff --git a/server/src/__tests__/execution-workspaces-service.test.ts b/server/src/__tests__/execution-workspaces-service.test.ts new file mode 100644 index 00000000..86ecc104 --- /dev/null +++ b/server/src/__tests__/execution-workspaces-service.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { + mergeExecutionWorkspaceConfig, + readExecutionWorkspaceConfig, +} from "../services/execution-workspaces.ts"; + +describe("execution workspace config helpers", () => { + it("reads typed config from persisted metadata", () => { + expect(readExecutionWorkspaceConfig({ + source: "project_primary", + config: { + provisionCommand: "bash ./scripts/provision-worktree.sh", + teardownCommand: "bash ./scripts/teardown-worktree.sh", + cleanupCommand: "pkill -f vite || true", + workspaceRuntime: { + services: [{ name: "web", command: "pnpm dev", port: 3100 }], + }, + }, + })).toEqual({ + provisionCommand: "bash ./scripts/provision-worktree.sh", + teardownCommand: "bash ./scripts/teardown-worktree.sh", + cleanupCommand: "pkill -f vite || true", + workspaceRuntime: { + services: [{ name: "web", command: "pnpm dev", port: 3100 }], + }, + }); + }); + + it("merges config patches without dropping unrelated metadata", () => { + expect(mergeExecutionWorkspaceConfig( + { + source: "project_primary", + createdByRuntime: false, + config: { + provisionCommand: "bash ./scripts/provision-worktree.sh", + cleanupCommand: "pkill -f vite || true", + }, + }, + { + teardownCommand: "bash ./scripts/teardown-worktree.sh", + workspaceRuntime: { + services: [{ name: "web", command: "pnpm dev" }], + }, + }, + )).toEqual({ + source: "project_primary", + createdByRuntime: false, + config: { + provisionCommand: "bash ./scripts/provision-worktree.sh", + teardownCommand: "bash ./scripts/teardown-worktree.sh", + cleanupCommand: "pkill -f vite || true", + workspaceRuntime: { + services: [{ name: "web", command: "pnpm dev" }], + }, + }, + }); + }); + + it("clears the nested config block when requested", () => { + expect(mergeExecutionWorkspaceConfig( + { + source: "project_primary", + config: { + provisionCommand: "bash ./scripts/provision-worktree.sh", + }, + }, + null, + )).toEqual({ + source: "project_primary", + }); + }); +}); diff --git a/server/src/routes/execution-workspaces.ts b/server/src/routes/execution-workspaces.ts index a7276704..1e52263d 100644 --- a/server/src/routes/execution-workspaces.ts +++ b/server/src/routes/execution-workspaces.ts @@ -5,6 +5,7 @@ import { issues, projects, projectWorkspaces } from "@paperclipai/db"; import { updateExecutionWorkspaceSchema } from "@paperclipai/shared"; import { validate } from "../middleware/validate.js"; import { executionWorkspaceService, logActivity, workspaceOperationService } from "../services/index.js"; +import { mergeExecutionWorkspaceConfig, readExecutionWorkspaceConfig } from "../services/execution-workspaces.js"; import { parseProjectExecutionWorkspacePolicy } from "../services/execution-workspace-policy.js"; import { cleanupExecutionWorkspaceArtifacts, @@ -52,11 +53,31 @@ export function executionWorkspaceRoutes(db: Db) { } assertCompanyAccess(req, existing.companyId); const patch: Record = { - ...req.body, - ...(req.body.cleanupEligibleAt ? { cleanupEligibleAt: new Date(req.body.cleanupEligibleAt) } : {}), + ...(req.body.name === undefined ? {} : { name: req.body.name }), + ...(req.body.cwd === undefined ? {} : { cwd: req.body.cwd }), + ...(req.body.repoUrl === undefined ? {} : { repoUrl: req.body.repoUrl }), + ...(req.body.baseRef === undefined ? {} : { baseRef: req.body.baseRef }), + ...(req.body.branchName === undefined ? {} : { branchName: req.body.branchName }), + ...(req.body.providerRef === undefined ? {} : { providerRef: req.body.providerRef }), + ...(req.body.status === undefined ? {} : { status: req.body.status }), + ...(req.body.cleanupReason === undefined ? {} : { cleanupReason: req.body.cleanupReason }), + ...(req.body.cleanupEligibleAt !== undefined + ? { cleanupEligibleAt: req.body.cleanupEligibleAt ? new Date(req.body.cleanupEligibleAt) : null } + : {}), }; + if (req.body.metadata !== undefined || req.body.config !== undefined) { + const requestedMetadata = req.body.metadata === undefined + ? (existing.metadata as Record | null) + : (req.body.metadata as Record | null); + patch.metadata = req.body.config === undefined + ? requestedMetadata + : mergeExecutionWorkspaceConfig(requestedMetadata, req.body.config ?? null); + } let workspace = existing; let cleanupWarnings: string[] = []; + const configForCleanup = readExecutionWorkspaceConfig( + ((patch.metadata as Record | null | undefined) ?? (existing.metadata as Record | null)) ?? null, + ); if (req.body.status === "archived" && existing.status !== "archived") { const linkedIssues = await db @@ -101,7 +122,7 @@ export function executionWorkspaceRoutes(db: Db) { cleanupCommand: projectWorkspaces.cleanupCommand, }) .from(projectWorkspaces) - .where( + .where( and( eq(projectWorkspaces.id, existing.projectWorkspaceId), eq(projectWorkspaces.companyId, existing.companyId), @@ -121,7 +142,8 @@ export function executionWorkspaceRoutes(db: Db) { const cleanupResult = await cleanupExecutionWorkspaceArtifacts({ workspace: existing, projectWorkspace, - teardownCommand: projectPolicy?.workspaceStrategy?.teardownCommand ?? null, + teardownCommand: configForCleanup?.teardownCommand ?? projectPolicy?.workspaceStrategy?.teardownCommand ?? null, + cleanupCommand: configForCleanup?.cleanupCommand ?? null, recorder: workspaceOperationsSvc.createRecorder({ companyId: existing.companyId, executionWorkspaceId: existing.id, diff --git a/server/src/services/execution-workspaces.ts b/server/src/services/execution-workspaces.ts index ea4dd163..fa099a33 100644 --- a/server/src/services/execution-workspaces.ts +++ b/server/src/services/execution-workspaces.ts @@ -1,11 +1,126 @@ 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"; +import { executionWorkspaces, workspaceRuntimeServices } from "@paperclipai/db"; +import type { ExecutionWorkspace, ExecutionWorkspaceConfig, WorkspaceRuntimeService } from "@paperclipai/shared"; type ExecutionWorkspaceRow = typeof executionWorkspaces.$inferSelect; +type WorkspaceRuntimeServiceRow = typeof workspaceRuntimeServices.$inferSelect; -function toExecutionWorkspace(row: ExecutionWorkspaceRow): ExecutionWorkspace { +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readNullableString(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function cloneRecord(value: unknown): Record | null { + if (!isRecord(value)) return null; + return { ...value }; +} + +export function readExecutionWorkspaceConfig(metadata: Record | null | undefined): ExecutionWorkspaceConfig | null { + const raw = isRecord(metadata?.config) ? metadata.config : null; + if (!raw) return null; + + const config: ExecutionWorkspaceConfig = { + provisionCommand: readNullableString(raw.provisionCommand), + teardownCommand: readNullableString(raw.teardownCommand), + cleanupCommand: readNullableString(raw.cleanupCommand), + workspaceRuntime: cloneRecord(raw.workspaceRuntime), + }; + + const hasConfig = Object.values(config).some((value) => { + if (value === null) return false; + if (typeof value === "object") return Object.keys(value).length > 0; + return true; + }); + + return hasConfig ? config : null; +} + +export function mergeExecutionWorkspaceConfig( + metadata: Record | null | undefined, + patch: Partial | null, +): Record | null { + const nextMetadata = isRecord(metadata) ? { ...metadata } : {}; + const current = readExecutionWorkspaceConfig(metadata) ?? { + provisionCommand: null, + teardownCommand: null, + cleanupCommand: null, + workspaceRuntime: null, + }; + + if (patch === null) { + delete nextMetadata.config; + return Object.keys(nextMetadata).length > 0 ? nextMetadata : null; + } + + const nextConfig: ExecutionWorkspaceConfig = { + provisionCommand: patch.provisionCommand !== undefined ? readNullableString(patch.provisionCommand) : current.provisionCommand, + teardownCommand: patch.teardownCommand !== undefined ? readNullableString(patch.teardownCommand) : current.teardownCommand, + cleanupCommand: patch.cleanupCommand !== undefined ? readNullableString(patch.cleanupCommand) : current.cleanupCommand, + workspaceRuntime: patch.workspaceRuntime !== undefined ? cloneRecord(patch.workspaceRuntime) : current.workspaceRuntime, + }; + + const hasConfig = Object.values(nextConfig).some((value) => { + if (value === null) return false; + if (typeof value === "object") return Object.keys(value).length > 0; + return true; + }); + + if (hasConfig) { + nextMetadata.config = { + provisionCommand: nextConfig.provisionCommand, + teardownCommand: nextConfig.teardownCommand, + cleanupCommand: nextConfig.cleanupCommand, + workspaceRuntime: nextConfig.workspaceRuntime, + }; + } else { + delete nextMetadata.config; + } + + return Object.keys(nextMetadata).length > 0 ? nextMetadata : null; +} + +function toRuntimeService(row: WorkspaceRuntimeServiceRow): WorkspaceRuntimeService { + return { + id: row.id, + 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, + serviceName: row.serviceName, + status: row.status as WorkspaceRuntimeService["status"], + lifecycle: row.lifecycle as WorkspaceRuntimeService["lifecycle"], + reuseKey: row.reuseKey ?? null, + command: row.command ?? null, + cwd: row.cwd ?? null, + port: row.port ?? null, + url: row.url ?? null, + provider: row.provider as WorkspaceRuntimeService["provider"], + providerRef: row.providerRef ?? null, + ownerAgentId: row.ownerAgentId ?? null, + startedByRunId: row.startedByRunId ?? null, + lastUsedAt: row.lastUsedAt, + startedAt: row.startedAt, + stoppedAt: row.stoppedAt ?? null, + stopPolicy: (row.stopPolicy as Record | null) ?? null, + healthStatus: row.healthStatus as WorkspaceRuntimeService["healthStatus"], + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +function toExecutionWorkspace( + row: ExecutionWorkspaceRow, + runtimeServices: WorkspaceRuntimeService[] = [], +): ExecutionWorkspace { return { id: row.id, companyId: row.companyId, @@ -28,7 +143,9 @@ function toExecutionWorkspace(row: ExecutionWorkspaceRow): ExecutionWorkspace { closedAt: row.closedAt ?? null, cleanupEligibleAt: row.cleanupEligibleAt ?? null, cleanupReason: row.cleanupReason ?? null, + config: readExecutionWorkspaceConfig((row.metadata as Record | null) ?? null), metadata: (row.metadata as Record | null) ?? null, + runtimeServices, createdAt: row.createdAt, updatedAt: row.updatedAt, }; @@ -63,7 +180,7 @@ export function executionWorkspaceService(db: Db) { .from(executionWorkspaces) .where(and(...conditions)) .orderBy(desc(executionWorkspaces.lastUsedAt), desc(executionWorkspaces.createdAt)); - return rows.map(toExecutionWorkspace); + return rows.map((row) => toExecutionWorkspace(row)); }, getById: async (id: string) => { @@ -72,7 +189,13 @@ export function executionWorkspaceService(db: Db) { .from(executionWorkspaces) .where(eq(executionWorkspaces.id, id)) .then((rows) => rows[0] ?? null); - return row ? toExecutionWorkspace(row) : null; + if (!row) return null; + const runtimeServiceRows = await db + .select() + .from(workspaceRuntimeServices) + .where(eq(workspaceRuntimeServices.executionWorkspaceId, row.id)) + .orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt)); + return toExecutionWorkspace(row, runtimeServiceRows.map(toRuntimeService)); }, create: async (data: typeof executionWorkspaces.$inferInsert) => { diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index c909b9b7..3c94f76d 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -4,7 +4,7 @@ import { execFile as execFileCallback } from "node:child_process"; import { promisify } from "node:util"; import { and, asc, desc, eq, gt, inArray, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; -import type { BillingType } from "@paperclipai/shared"; +import type { BillingType, ExecutionWorkspaceConfig } from "@paperclipai/shared"; import { agents, agentRuntimeState, @@ -40,7 +40,7 @@ import { sanitizeRuntimeServiceBaseEnv, } from "./workspace-runtime.js"; import { issueService } from "./issues.js"; -import { executionWorkspaceService } from "./execution-workspaces.js"; +import { executionWorkspaceService, mergeExecutionWorkspaceConfig } from "./execution-workspaces.js"; import { workspaceOperationService } from "./workspace-operations.js"; import { buildExecutionWorkspaceAdapterConfig, @@ -76,6 +76,57 @@ const SESSIONED_LOCAL_ADAPTERS = new Set([ "pi_local", ]); +function applyPersistedExecutionWorkspaceConfig(input: { + config: Record; + workspaceConfig: ExecutionWorkspaceConfig | null; + mode: ReturnType; +}) { + if (!input.workspaceConfig) return input.config; + + const nextConfig = { ...input.config }; + + if (input.mode !== "agent_default") { + if (input.workspaceConfig.workspaceRuntime === null) { + delete nextConfig.workspaceRuntime; + } else { + nextConfig.workspaceRuntime = { ...input.workspaceConfig.workspaceRuntime }; + } + } + + if (input.mode === "isolated_workspace") { + const nextStrategy = parseObject(nextConfig.workspaceStrategy); + if (input.workspaceConfig.provisionCommand === null) delete nextStrategy.provisionCommand; + else nextStrategy.provisionCommand = input.workspaceConfig.provisionCommand; + if (input.workspaceConfig.teardownCommand === null) delete nextStrategy.teardownCommand; + else nextStrategy.teardownCommand = input.workspaceConfig.teardownCommand; + nextConfig.workspaceStrategy = nextStrategy; + } + + return nextConfig; +} + +function buildExecutionWorkspaceConfigSnapshot(config: Record): Partial | null { + const strategy = parseObject(config.workspaceStrategy); + const snapshot: Partial = {}; + + if ("workspaceStrategy" in config) { + snapshot.provisionCommand = typeof strategy.provisionCommand === "string" ? strategy.provisionCommand : null; + snapshot.teardownCommand = typeof strategy.teardownCommand === "string" ? strategy.teardownCommand : null; + } + + if ("workspaceRuntime" in config) { + const workspaceRuntime = parseObject(config.workspaceRuntime); + snapshot.workspaceRuntime = Object.keys(workspaceRuntime).length > 0 ? workspaceRuntime : null; + } + + const hasSnapshot = Object.values(snapshot).some((value) => { + if (value === null) return false; + if (typeof value === "object") return Object.keys(value).length > 0; + return true; + }); + return hasSnapshot ? snapshot : null; +} + function deriveRepoNameFromRepoUrl(repoUrl: string | null): string | null { const trimmed = repoUrl?.trim() ?? ""; if (!trimmed) return null; @@ -2048,18 +2099,6 @@ export function heartbeatService(db: Db) { mode: executionWorkspaceMode, legacyUseProjectWorkspace: issueAssigneeOverrides?.useProjectWorkspace ?? null, }); - const mergedConfig = issueAssigneeOverrides?.adapterConfig - ? { ...workspaceManagedConfig, ...issueAssigneeOverrides.adapterConfig } - : workspaceManagedConfig; - const { config: resolvedConfig, secretKeys } = await secretsSvc.resolveAdapterConfigForRuntime( - agent.companyId, - mergedConfig, - ); - const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId); - const runtimeConfig = { - ...resolvedConfig, - paperclipRuntimeSkills: runtimeSkillEntries, - }; const issueRef = issueContext ? { id: issueContext.id, @@ -2073,6 +2112,24 @@ export function heartbeatService(db: Db) { : null; const existingExecutionWorkspace = issueRef?.executionWorkspaceId ? await executionWorkspacesSvc.getById(issueRef.executionWorkspaceId) : null; + const persistedWorkspaceManagedConfig = applyPersistedExecutionWorkspaceConfig({ + config: workspaceManagedConfig, + workspaceConfig: existingExecutionWorkspace?.config ?? null, + mode: executionWorkspaceMode, + }); + const mergedConfig = issueAssigneeOverrides?.adapterConfig + ? { ...persistedWorkspaceManagedConfig, ...issueAssigneeOverrides.adapterConfig } + : persistedWorkspaceManagedConfig; + const { config: resolvedConfig, secretKeys } = await secretsSvc.resolveAdapterConfigForRuntime( + agent.companyId, + mergedConfig, + ); + const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId); + const runtimeConfig = { + ...resolvedConfig, + paperclipRuntimeSkills: runtimeSkillEntries, + }; + const configSnapshot = buildExecutionWorkspaceConfigSnapshot(resolvedConfig); const workspaceOperationRecorder = workspaceOperationsSvc.createRecorder({ companyId: agent.companyId, heartbeatRunId: run.id, @@ -2103,6 +2160,14 @@ export function heartbeatService(db: Db) { existingExecutionWorkspace && existingExecutionWorkspace.status !== "archived"; let persistedExecutionWorkspace = null; + const nextExecutionWorkspaceMetadataBase = { + ...(existingExecutionWorkspace?.metadata ?? {}), + source: executionWorkspace.source, + createdByRuntime: executionWorkspace.created, + } as Record; + const nextExecutionWorkspaceMetadata = configSnapshot + ? mergeExecutionWorkspaceConfig(nextExecutionWorkspaceMetadataBase, configSnapshot) + : nextExecutionWorkspaceMetadataBase; try { persistedExecutionWorkspace = shouldReuseExisting && existingExecutionWorkspace ? await executionWorkspacesSvc.update(existingExecutionWorkspace.id, { @@ -2114,11 +2179,7 @@ export function heartbeatService(db: Db) { providerRef: executionWorkspace.worktreePath, status: "active", lastUsedAt: new Date(), - metadata: { - ...(existingExecutionWorkspace.metadata ?? {}), - source: executionWorkspace.source, - createdByRuntime: executionWorkspace.created, - }, + metadata: nextExecutionWorkspaceMetadata, }) : resolvedProjectId ? await executionWorkspacesSvc.create({ @@ -2145,10 +2206,7 @@ export function heartbeatService(db: Db) { providerRef: executionWorkspace.worktreePath, lastUsedAt: new Date(), openedAt: new Date(), - metadata: { - source: executionWorkspace.source, - createdByRuntime: executionWorkspace.created, - }, + metadata: nextExecutionWorkspaceMetadata, }) : null; } catch (error) { @@ -2175,7 +2233,8 @@ export function heartbeatService(db: Db) { cwd: resolvedWorkspace.cwd, cleanupCommand: null, }, - teardownCommand: projectExecutionWorkspacePolicy?.workspaceStrategy?.teardownCommand ?? null, + cleanupCommand: configSnapshot?.cleanupCommand ?? null, + teardownCommand: configSnapshot?.teardownCommand ?? projectExecutionWorkspacePolicy?.workspaceStrategy?.teardownCommand ?? null, recorder: workspaceOperationRecorder, }); } catch (cleanupError) { diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index 7cb780ce..12375701 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -702,6 +702,7 @@ export async function cleanupExecutionWorkspaceArtifacts(input: { cwd: string | null; cleanupCommand: string | null; } | null; + cleanupCommand?: string | null; teardownCommand?: string | null; recorder?: WorkspaceOperationRecorder | null; }) { @@ -713,6 +714,7 @@ export async function cleanupExecutionWorkspaceArtifacts(input: { }); const createdByRuntime = input.workspace.metadata?.createdByRuntime === true; const cleanupCommands = [ + input.cleanupCommand ?? null, input.projectWorkspace?.cleanupCommand ?? null, input.teardownCommand ?? null, ] diff --git a/ui/src/lib/project-workspaces-tab.test.ts b/ui/src/lib/project-workspaces-tab.test.ts index a037a7eb..0e07e7c6 100644 --- a/ui/src/lib/project-workspaces-tab.test.ts +++ b/ui/src/lib/project-workspaces-tab.test.ts @@ -87,7 +87,9 @@ function createExecutionWorkspace(overrides: Partial): Execu closedAt: overrides.closedAt ?? null, cleanupEligibleAt: overrides.cleanupEligibleAt ?? null, cleanupReason: overrides.cleanupReason ?? null, + config: overrides.config ?? null, metadata: overrides.metadata ?? null, + runtimeServices: overrides.runtimeServices ?? [], createdAt: overrides.createdAt ?? new Date("2026-03-26T09:00:00Z"), updatedAt: overrides.updatedAt ?? new Date("2026-03-26T09:30:00Z"), }; diff --git a/ui/src/pages/ExecutionWorkspaceDetail.tsx b/ui/src/pages/ExecutionWorkspaceDetail.tsx index e4f3da31..484b7ac8 100644 --- a/ui/src/pages/ExecutionWorkspaceDetail.tsx +++ b/ui/src/pages/ExecutionWorkspaceDetail.tsx @@ -1,8 +1,31 @@ +import { useEffect, useMemo, useState } from "react"; import { Link, useParams } from "@/lib/router"; -import { useQuery } from "@tanstack/react-query"; -import { ExternalLink } from "lucide-react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { ExecutionWorkspace, Project, ProjectWorkspace } from "@paperclipai/shared"; +import { ArrowLeft, Check, Copy, ExternalLink, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { CopyText } from "../components/CopyText"; import { executionWorkspacesApi } from "../api/execution-workspaces"; +import { issuesApi } from "../api/issues"; +import { projectsApi } from "../api/projects"; +import { useBreadcrumbs } from "../context/BreadcrumbContext"; +import { useCompany } from "../context/CompanyContext"; import { queryKeys } from "../lib/queryKeys"; +import { cn, formatDateTime, issueUrl, projectRouteRef, projectWorkspaceUrl } from "../lib/utils"; + +type WorkspaceFormState = { + name: string; + cwd: string; + repoUrl: string; + baseRef: string; + branchName: string; + providerRef: string; + provisionCommand: string; + teardownCommand: string; + cleanupCommand: string; + workspaceRuntime: string; +}; function isSafeExternalUrl(value: string | null | undefined) { if (!value) return false; @@ -14,68 +37,567 @@ function isSafeExternalUrl(value: string | null | undefined) { } } +function readText(value: string | null | undefined) { + return value ?? ""; +} + +function formatJson(value: Record | null | undefined) { + if (!value || Object.keys(value).length === 0) return ""; + return JSON.stringify(value, null, 2); +} + +function normalizeText(value: string) { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function parseWorkspaceRuntimeJson(value: string) { + const trimmed = value.trim(); + if (!trimmed) return { ok: true as const, value: null as Record | null }; + + try { + const parsed = JSON.parse(trimmed); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { + ok: false as const, + error: "Workspace runtime JSON must be a JSON object.", + }; + } + return { ok: true as const, value: parsed as Record }; + } catch (error) { + return { + ok: false as const, + error: error instanceof Error ? error.message : "Invalid JSON.", + }; + } +} + +function formStateFromWorkspace(workspace: ExecutionWorkspace): WorkspaceFormState { + return { + name: workspace.name, + cwd: readText(workspace.cwd), + repoUrl: readText(workspace.repoUrl), + baseRef: readText(workspace.baseRef), + branchName: readText(workspace.branchName), + providerRef: readText(workspace.providerRef), + provisionCommand: readText(workspace.config?.provisionCommand), + teardownCommand: readText(workspace.config?.teardownCommand), + cleanupCommand: readText(workspace.config?.cleanupCommand), + workspaceRuntime: formatJson(workspace.config?.workspaceRuntime), + }; +} + +function buildWorkspacePatch(initialState: WorkspaceFormState, nextState: WorkspaceFormState) { + const patch: Record = {}; + const configPatch: Record = {}; + + const maybeAssign = ( + key: keyof Pick, + ) => { + if (initialState[key] === nextState[key]) return; + patch[key] = key === "name" ? (normalizeText(nextState[key]) ?? initialState.name) : normalizeText(nextState[key]); + }; + + maybeAssign("name"); + maybeAssign("cwd"); + maybeAssign("repoUrl"); + maybeAssign("baseRef"); + maybeAssign("branchName"); + maybeAssign("providerRef"); + + const maybeAssignConfigText = (key: keyof Pick) => { + if (initialState[key] === nextState[key]) return; + configPatch[key] = normalizeText(nextState[key]); + }; + + maybeAssignConfigText("provisionCommand"); + maybeAssignConfigText("teardownCommand"); + maybeAssignConfigText("cleanupCommand"); + + if (initialState.workspaceRuntime !== nextState.workspaceRuntime) { + const parsed = parseWorkspaceRuntimeJson(nextState.workspaceRuntime); + if (!parsed.ok) throw new Error(parsed.error); + configPatch.workspaceRuntime = parsed.value; + } + + if (Object.keys(configPatch).length > 0) { + patch.config = configPatch; + } + + return patch; +} + +function validateForm(form: WorkspaceFormState) { + const repoUrl = normalizeText(form.repoUrl); + if (repoUrl) { + try { + new URL(repoUrl); + } catch { + return "Repo URL must be a valid URL."; + } + } + + const runtimeJson = parseWorkspaceRuntimeJson(form.workspaceRuntime); + if (!runtimeJson.ok) { + return runtimeJson.error; + } + + return null; +} + +function Field({ + label, + hint, + children, +}: { + label: string; + hint?: string; + children: React.ReactNode; +}) { + return ( + + ); +} + function DetailRow({ label, children }: { label: string; children: React.ReactNode }) { return (
-
{label}
+
{label}
{children}
); } +function StatusPill({ children, className }: { children: React.ReactNode; className?: string }) { + return ( +
+ {children} +
+ ); +} + +function MonoValue({ value, copy }: { value: string; copy?: boolean }) { + return ( +
+ {value} + {copy ? ( + + + + ) : null} +
+ ); +} + +function WorkspaceLink({ + project, + workspace, +}: { + project: Project; + workspace: ProjectWorkspace; +}) { + return {workspace.name}; +} + export function ExecutionWorkspaceDetail() { const { workspaceId } = useParams<{ workspaceId: string }>(); + const queryClient = useQueryClient(); + const { setBreadcrumbs } = useBreadcrumbs(); + const { selectedCompanyId, setSelectedCompanyId } = useCompany(); + const [form, setForm] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); - const { data: workspace, isLoading, error } = useQuery({ + const workspaceQuery = useQuery({ queryKey: queryKeys.executionWorkspaces.detail(workspaceId!), queryFn: () => executionWorkspacesApi.get(workspaceId!), enabled: Boolean(workspaceId), }); + const workspace = workspaceQuery.data ?? null; - if (isLoading) return

Loading...

; - if (error) return

{error instanceof Error ? error.message : "Failed to load workspace"}

; - if (!workspace) return null; + const projectQuery = useQuery({ + queryKey: workspace ? [...queryKeys.projects.detail(workspace.projectId), workspace.companyId] : ["projects", "detail", "__pending__"], + queryFn: () => projectsApi.get(workspace!.projectId, workspace!.companyId), + enabled: Boolean(workspace?.projectId), + }); + const project = projectQuery.data ?? null; + + const sourceIssueQuery = useQuery({ + queryKey: workspace?.sourceIssueId ? queryKeys.issues.detail(workspace.sourceIssueId) : ["issues", "detail", "__none__"], + queryFn: () => issuesApi.get(workspace!.sourceIssueId!), + enabled: Boolean(workspace?.sourceIssueId), + }); + const sourceIssue = sourceIssueQuery.data ?? null; + + const derivedWorkspaceQuery = useQuery({ + queryKey: workspace?.derivedFromExecutionWorkspaceId + ? queryKeys.executionWorkspaces.detail(workspace.derivedFromExecutionWorkspaceId) + : ["execution-workspaces", "detail", "__none__"], + queryFn: () => executionWorkspacesApi.get(workspace!.derivedFromExecutionWorkspaceId!), + enabled: Boolean(workspace?.derivedFromExecutionWorkspaceId), + }); + const derivedWorkspace = derivedWorkspaceQuery.data ?? null; + + const linkedProjectWorkspace = useMemo( + () => project?.workspaces.find((item) => item.id === workspace?.projectWorkspaceId) ?? null, + [project, workspace?.projectWorkspaceId], + ); + + const initialState = useMemo(() => (workspace ? formStateFromWorkspace(workspace) : null), [workspace]); + const isDirty = Boolean(form && initialState && JSON.stringify(form) !== JSON.stringify(initialState)); + const projectRef = project ? projectRouteRef(project) : workspace?.projectId ?? ""; + + useEffect(() => { + if (!workspace?.companyId || workspace.companyId === selectedCompanyId) return; + setSelectedCompanyId(workspace.companyId, { source: "route_sync" }); + }, [workspace?.companyId, selectedCompanyId, setSelectedCompanyId]); + + useEffect(() => { + if (!workspace) return; + setForm(formStateFromWorkspace(workspace)); + setErrorMessage(null); + }, [workspace]); + + useEffect(() => { + if (!workspace) return; + const crumbs = [ + { label: "Projects", href: "/projects" }, + ...(project ? [{ label: project.name, href: `/projects/${projectRef}` }] : []), + ...(project ? [{ label: "Workspaces", href: `/projects/${projectRef}/workspaces` }] : []), + { label: workspace.name }, + ]; + setBreadcrumbs(crumbs); + }, [setBreadcrumbs, workspace, project, projectRef]); + + const updateWorkspace = useMutation({ + mutationFn: (patch: Record) => executionWorkspacesApi.update(workspace!.id, patch), + onSuccess: (nextWorkspace) => { + queryClient.setQueryData(queryKeys.executionWorkspaces.detail(nextWorkspace.id), nextWorkspace); + if (project) { + queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.id) }); + queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.urlKey) }); + } + if (sourceIssue) { + queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(sourceIssue.id) }); + } + setErrorMessage(null); + }, + onError: (error) => { + setErrorMessage(error instanceof Error ? error.message : "Failed to save execution workspace."); + }, + }); + + if (workspaceQuery.isLoading) return

Loading workspace…

; + if (workspaceQuery.error) { + return ( +

+ {workspaceQuery.error instanceof Error ? workspaceQuery.error.message : "Failed to load workspace"} +

+ ); + } + if (!workspace || !form || !initialState) return null; + + const saveChanges = () => { + const validationError = validateForm(form); + if (validationError) { + setErrorMessage(validationError); + return; + } + + let patch: Record; + try { + patch = buildWorkspacePatch(initialState, form); + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : "Failed to build workspace update."); + return; + } + + if (Object.keys(patch).length === 0) return; + updateWorkspace.mutate(patch); + }; return ( -
-
-
Execution workspace
-

{workspace.name}

-
- {workspace.status} · {workspace.mode} · {workspace.providerType} -
+
+
+ + {workspace.mode} + {workspace.providerType} + + {workspace.status} +
-
- - {workspace.projectId ? {workspace.projectId} : "None"} - - - {workspace.sourceIssueId ? {workspace.sourceIssueId} : "None"} - - {workspace.branchName ?? "None"} - {workspace.baseRef ?? "None"} - - {workspace.cwd ?? "None"} - - - {workspace.providerRef ?? "None"} - - - {workspace.repoUrl && isSafeExternalUrl(workspace.repoUrl) ? ( - - {workspace.repoUrl} - - - ) : workspace.repoUrl ? ( - {workspace.repoUrl} - ) : "None"} - - {new Date(workspace.openedAt).toLocaleString()} - {new Date(workspace.lastUsedAt).toLocaleString()} - - {workspace.cleanupEligibleAt ? `${new Date(workspace.cleanupEligibleAt).toLocaleString()}${workspace.cleanupReason ? ` · ${workspace.cleanupReason}` : ""}` : "Not scheduled"} - +
+
+
+
+
+
+ Execution workspace +
+

{workspace.name}

+

+ Configure the concrete runtime workspace that Paperclip reuses for this issue flow. These settings stay + attached to the execution workspace so future runs can keep local paths, repo refs, provisioning, teardown, + and runtime-service behavior in sync with the actual workspace being reused. +

+
+
+ + + +
+ + setForm((current) => current ? { ...current, name: event.target.value } : current)} + placeholder="Execution workspace name" + /> + + + setForm((current) => current ? { ...current, branchName: event.target.value } : current)} + placeholder="PAP-946-workspace" + /> + +
+ +
+ + setForm((current) => current ? { ...current, cwd: event.target.value } : current)} + placeholder="/absolute/path/to/workspace" + /> + + + setForm((current) => current ? { ...current, providerRef: event.target.value } : current)} + placeholder="/path/to/worktree or provider ref" + /> + +
+ +
+ + setForm((current) => current ? { ...current, repoUrl: event.target.value } : current)} + placeholder="https://github.com/org/repo" + /> + + + setForm((current) => current ? { ...current, baseRef: event.target.value } : current)} + placeholder="origin/main" + /> + +
+ +
+ +