Improve execution workspace detail editing

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-03-28 12:15:34 -05:00
parent 84e35b801c
commit c114ff4dc6
12 changed files with 905 additions and 77 deletions

View file

@ -186,6 +186,7 @@ export type {
ProjectGoalRef, ProjectGoalRef,
ProjectWorkspace, ProjectWorkspace,
ExecutionWorkspace, ExecutionWorkspace,
ExecutionWorkspaceConfig,
WorkspaceRuntimeService, WorkspaceRuntimeService,
WorkspaceOperation, WorkspaceOperation,
WorkspaceOperationPhase, WorkspaceOperationPhase,

View file

@ -50,6 +50,7 @@ export type { AssetImage } from "./asset.js";
export type { Project, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectWorkspace } from "./project.js"; export type { Project, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectWorkspace } from "./project.js";
export type { export type {
ExecutionWorkspace, ExecutionWorkspace,
ExecutionWorkspaceConfig,
WorkspaceRuntimeService, WorkspaceRuntimeService,
ExecutionWorkspaceStrategyType, ExecutionWorkspaceStrategyType,
ExecutionWorkspaceMode, ExecutionWorkspaceMode,

View file

@ -40,6 +40,13 @@ export interface ExecutionWorkspaceStrategy {
teardownCommand?: string | null; teardownCommand?: string | null;
} }
export interface ExecutionWorkspaceConfig {
provisionCommand: string | null;
teardownCommand: string | null;
cleanupCommand: string | null;
workspaceRuntime: Record<string, unknown> | null;
}
export interface ProjectExecutionWorkspacePolicy { export interface ProjectExecutionWorkspacePolicy {
enabled: boolean; enabled: boolean;
defaultMode?: ProjectExecutionWorkspaceDefaultMode; defaultMode?: ProjectExecutionWorkspaceDefaultMode;
@ -81,7 +88,9 @@ export interface ExecutionWorkspace {
closedAt: Date | null; closedAt: Date | null;
cleanupEligibleAt: Date | null; cleanupEligibleAt: Date | null;
cleanupReason: string | null; cleanupReason: string | null;
config: ExecutionWorkspaceConfig | null;
metadata: Record<string, unknown> | null; metadata: Record<string, unknown> | null;
runtimeServices?: WorkspaceRuntimeService[];
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }

View file

@ -8,10 +8,24 @@ export const executionWorkspaceStatusSchema = z.enum([
"cleanup_failed", "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({ 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(), status: executionWorkspaceStatusSchema.optional(),
cleanupEligibleAt: z.string().datetime().optional().nullable(), cleanupEligibleAt: z.string().datetime().optional().nullable(),
cleanupReason: z.string().optional().nullable(), cleanupReason: z.string().optional().nullable(),
config: executionWorkspaceConfigSchema.optional().nullable(),
metadata: z.record(z.unknown()).optional().nullable(), metadata: z.record(z.unknown()).optional().nullable(),
}).strict(); }).strict();

View file

@ -151,6 +151,7 @@ export {
} from "./work-product.js"; } from "./work-product.js";
export { export {
executionWorkspaceConfigSchema,
updateExecutionWorkspaceSchema, updateExecutionWorkspaceSchema,
executionWorkspaceStatusSchema, executionWorkspaceStatusSchema,
type UpdateExecutionWorkspace, type UpdateExecutionWorkspace,

View file

@ -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",
});
});
});

View file

@ -5,6 +5,7 @@ import { issues, projects, projectWorkspaces } from "@paperclipai/db";
import { updateExecutionWorkspaceSchema } from "@paperclipai/shared"; import { updateExecutionWorkspaceSchema } from "@paperclipai/shared";
import { validate } from "../middleware/validate.js"; import { validate } from "../middleware/validate.js";
import { executionWorkspaceService, logActivity, workspaceOperationService } from "../services/index.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 { parseProjectExecutionWorkspacePolicy } from "../services/execution-workspace-policy.js";
import { import {
cleanupExecutionWorkspaceArtifacts, cleanupExecutionWorkspaceArtifacts,
@ -52,11 +53,31 @@ export function executionWorkspaceRoutes(db: Db) {
} }
assertCompanyAccess(req, existing.companyId); assertCompanyAccess(req, existing.companyId);
const patch: Record<string, unknown> = { const patch: Record<string, unknown> = {
...req.body, ...(req.body.name === undefined ? {} : { name: req.body.name }),
...(req.body.cleanupEligibleAt ? { cleanupEligibleAt: new Date(req.body.cleanupEligibleAt) } : {}), ...(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<string, unknown> | null)
: (req.body.metadata as Record<string, unknown> | null);
patch.metadata = req.body.config === undefined
? requestedMetadata
: mergeExecutionWorkspaceConfig(requestedMetadata, req.body.config ?? null);
}
let workspace = existing; let workspace = existing;
let cleanupWarnings: string[] = []; let cleanupWarnings: string[] = [];
const configForCleanup = readExecutionWorkspaceConfig(
((patch.metadata as Record<string, unknown> | null | undefined) ?? (existing.metadata as Record<string, unknown> | null)) ?? null,
);
if (req.body.status === "archived" && existing.status !== "archived") { if (req.body.status === "archived" && existing.status !== "archived") {
const linkedIssues = await db const linkedIssues = await db
@ -121,7 +142,8 @@ export function executionWorkspaceRoutes(db: Db) {
const cleanupResult = await cleanupExecutionWorkspaceArtifacts({ const cleanupResult = await cleanupExecutionWorkspaceArtifacts({
workspace: existing, workspace: existing,
projectWorkspace, projectWorkspace,
teardownCommand: projectPolicy?.workspaceStrategy?.teardownCommand ?? null, teardownCommand: configForCleanup?.teardownCommand ?? projectPolicy?.workspaceStrategy?.teardownCommand ?? null,
cleanupCommand: configForCleanup?.cleanupCommand ?? null,
recorder: workspaceOperationsSvc.createRecorder({ recorder: workspaceOperationsSvc.createRecorder({
companyId: existing.companyId, companyId: existing.companyId,
executionWorkspaceId: existing.id, executionWorkspaceId: existing.id,

View file

@ -1,11 +1,126 @@
import { and, desc, eq, inArray } from "drizzle-orm"; import { and, desc, eq, inArray } from "drizzle-orm";
import type { Db } from "@paperclipai/db"; import type { Db } from "@paperclipai/db";
import { executionWorkspaces } from "@paperclipai/db"; import { executionWorkspaces, workspaceRuntimeServices } from "@paperclipai/db";
import type { ExecutionWorkspace } from "@paperclipai/shared"; import type { ExecutionWorkspace, ExecutionWorkspaceConfig, WorkspaceRuntimeService } from "@paperclipai/shared";
type ExecutionWorkspaceRow = typeof executionWorkspaces.$inferSelect; type ExecutionWorkspaceRow = typeof executionWorkspaces.$inferSelect;
type WorkspaceRuntimeServiceRow = typeof workspaceRuntimeServices.$inferSelect;
function toExecutionWorkspace(row: ExecutionWorkspaceRow): ExecutionWorkspace { function isRecord(value: unknown): value is Record<string, unknown> {
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<string, unknown> | null {
if (!isRecord(value)) return null;
return { ...value };
}
export function readExecutionWorkspaceConfig(metadata: Record<string, unknown> | 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<string, unknown> | null | undefined,
patch: Partial<ExecutionWorkspaceConfig> | null,
): Record<string, unknown> | 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<string, unknown> | null) ?? null,
healthStatus: row.healthStatus as WorkspaceRuntimeService["healthStatus"],
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
function toExecutionWorkspace(
row: ExecutionWorkspaceRow,
runtimeServices: WorkspaceRuntimeService[] = [],
): ExecutionWorkspace {
return { return {
id: row.id, id: row.id,
companyId: row.companyId, companyId: row.companyId,
@ -28,7 +143,9 @@ function toExecutionWorkspace(row: ExecutionWorkspaceRow): ExecutionWorkspace {
closedAt: row.closedAt ?? null, closedAt: row.closedAt ?? null,
cleanupEligibleAt: row.cleanupEligibleAt ?? null, cleanupEligibleAt: row.cleanupEligibleAt ?? null,
cleanupReason: row.cleanupReason ?? null, cleanupReason: row.cleanupReason ?? null,
config: readExecutionWorkspaceConfig((row.metadata as Record<string, unknown> | null) ?? null),
metadata: (row.metadata as Record<string, unknown> | null) ?? null, metadata: (row.metadata as Record<string, unknown> | null) ?? null,
runtimeServices,
createdAt: row.createdAt, createdAt: row.createdAt,
updatedAt: row.updatedAt, updatedAt: row.updatedAt,
}; };
@ -63,7 +180,7 @@ export function executionWorkspaceService(db: Db) {
.from(executionWorkspaces) .from(executionWorkspaces)
.where(and(...conditions)) .where(and(...conditions))
.orderBy(desc(executionWorkspaces.lastUsedAt), desc(executionWorkspaces.createdAt)); .orderBy(desc(executionWorkspaces.lastUsedAt), desc(executionWorkspaces.createdAt));
return rows.map(toExecutionWorkspace); return rows.map((row) => toExecutionWorkspace(row));
}, },
getById: async (id: string) => { getById: async (id: string) => {
@ -72,7 +189,13 @@ export function executionWorkspaceService(db: Db) {
.from(executionWorkspaces) .from(executionWorkspaces)
.where(eq(executionWorkspaces.id, id)) .where(eq(executionWorkspaces.id, id))
.then((rows) => rows[0] ?? null); .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) => { create: async (data: typeof executionWorkspaces.$inferInsert) => {

View file

@ -4,7 +4,7 @@ import { execFile as execFileCallback } from "node:child_process";
import { promisify } from "node:util"; import { promisify } from "node:util";
import { and, asc, desc, eq, gt, inArray, sql } from "drizzle-orm"; import { and, asc, desc, eq, gt, inArray, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db"; import type { Db } from "@paperclipai/db";
import type { BillingType } from "@paperclipai/shared"; import type { BillingType, ExecutionWorkspaceConfig } from "@paperclipai/shared";
import { import {
agents, agents,
agentRuntimeState, agentRuntimeState,
@ -40,7 +40,7 @@ import {
sanitizeRuntimeServiceBaseEnv, sanitizeRuntimeServiceBaseEnv,
} from "./workspace-runtime.js"; } from "./workspace-runtime.js";
import { issueService } from "./issues.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 { workspaceOperationService } from "./workspace-operations.js";
import { import {
buildExecutionWorkspaceAdapterConfig, buildExecutionWorkspaceAdapterConfig,
@ -76,6 +76,57 @@ const SESSIONED_LOCAL_ADAPTERS = new Set([
"pi_local", "pi_local",
]); ]);
function applyPersistedExecutionWorkspaceConfig(input: {
config: Record<string, unknown>;
workspaceConfig: ExecutionWorkspaceConfig | null;
mode: ReturnType<typeof resolveExecutionWorkspaceMode>;
}) {
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<string, unknown>): Partial<ExecutionWorkspaceConfig> | null {
const strategy = parseObject(config.workspaceStrategy);
const snapshot: Partial<ExecutionWorkspaceConfig> = {};
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 { function deriveRepoNameFromRepoUrl(repoUrl: string | null): string | null {
const trimmed = repoUrl?.trim() ?? ""; const trimmed = repoUrl?.trim() ?? "";
if (!trimmed) return null; if (!trimmed) return null;
@ -2048,18 +2099,6 @@ export function heartbeatService(db: Db) {
mode: executionWorkspaceMode, mode: executionWorkspaceMode,
legacyUseProjectWorkspace: issueAssigneeOverrides?.useProjectWorkspace ?? null, 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 const issueRef = issueContext
? { ? {
id: issueContext.id, id: issueContext.id,
@ -2073,6 +2112,24 @@ export function heartbeatService(db: Db) {
: null; : null;
const existingExecutionWorkspace = const existingExecutionWorkspace =
issueRef?.executionWorkspaceId ? await executionWorkspacesSvc.getById(issueRef.executionWorkspaceId) : null; 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({ const workspaceOperationRecorder = workspaceOperationsSvc.createRecorder({
companyId: agent.companyId, companyId: agent.companyId,
heartbeatRunId: run.id, heartbeatRunId: run.id,
@ -2103,6 +2160,14 @@ export function heartbeatService(db: Db) {
existingExecutionWorkspace && existingExecutionWorkspace &&
existingExecutionWorkspace.status !== "archived"; existingExecutionWorkspace.status !== "archived";
let persistedExecutionWorkspace = null; let persistedExecutionWorkspace = null;
const nextExecutionWorkspaceMetadataBase = {
...(existingExecutionWorkspace?.metadata ?? {}),
source: executionWorkspace.source,
createdByRuntime: executionWorkspace.created,
} as Record<string, unknown>;
const nextExecutionWorkspaceMetadata = configSnapshot
? mergeExecutionWorkspaceConfig(nextExecutionWorkspaceMetadataBase, configSnapshot)
: nextExecutionWorkspaceMetadataBase;
try { try {
persistedExecutionWorkspace = shouldReuseExisting && existingExecutionWorkspace persistedExecutionWorkspace = shouldReuseExisting && existingExecutionWorkspace
? await executionWorkspacesSvc.update(existingExecutionWorkspace.id, { ? await executionWorkspacesSvc.update(existingExecutionWorkspace.id, {
@ -2114,11 +2179,7 @@ export function heartbeatService(db: Db) {
providerRef: executionWorkspace.worktreePath, providerRef: executionWorkspace.worktreePath,
status: "active", status: "active",
lastUsedAt: new Date(), lastUsedAt: new Date(),
metadata: { metadata: nextExecutionWorkspaceMetadata,
...(existingExecutionWorkspace.metadata ?? {}),
source: executionWorkspace.source,
createdByRuntime: executionWorkspace.created,
},
}) })
: resolvedProjectId : resolvedProjectId
? await executionWorkspacesSvc.create({ ? await executionWorkspacesSvc.create({
@ -2145,10 +2206,7 @@ export function heartbeatService(db: Db) {
providerRef: executionWorkspace.worktreePath, providerRef: executionWorkspace.worktreePath,
lastUsedAt: new Date(), lastUsedAt: new Date(),
openedAt: new Date(), openedAt: new Date(),
metadata: { metadata: nextExecutionWorkspaceMetadata,
source: executionWorkspace.source,
createdByRuntime: executionWorkspace.created,
},
}) })
: null; : null;
} catch (error) { } catch (error) {
@ -2175,7 +2233,8 @@ export function heartbeatService(db: Db) {
cwd: resolvedWorkspace.cwd, cwd: resolvedWorkspace.cwd,
cleanupCommand: null, cleanupCommand: null,
}, },
teardownCommand: projectExecutionWorkspacePolicy?.workspaceStrategy?.teardownCommand ?? null, cleanupCommand: configSnapshot?.cleanupCommand ?? null,
teardownCommand: configSnapshot?.teardownCommand ?? projectExecutionWorkspacePolicy?.workspaceStrategy?.teardownCommand ?? null,
recorder: workspaceOperationRecorder, recorder: workspaceOperationRecorder,
}); });
} catch (cleanupError) { } catch (cleanupError) {

View file

@ -702,6 +702,7 @@ export async function cleanupExecutionWorkspaceArtifacts(input: {
cwd: string | null; cwd: string | null;
cleanupCommand: string | null; cleanupCommand: string | null;
} | null; } | null;
cleanupCommand?: string | null;
teardownCommand?: string | null; teardownCommand?: string | null;
recorder?: WorkspaceOperationRecorder | null; recorder?: WorkspaceOperationRecorder | null;
}) { }) {
@ -713,6 +714,7 @@ export async function cleanupExecutionWorkspaceArtifacts(input: {
}); });
const createdByRuntime = input.workspace.metadata?.createdByRuntime === true; const createdByRuntime = input.workspace.metadata?.createdByRuntime === true;
const cleanupCommands = [ const cleanupCommands = [
input.cleanupCommand ?? null,
input.projectWorkspace?.cleanupCommand ?? null, input.projectWorkspace?.cleanupCommand ?? null,
input.teardownCommand ?? null, input.teardownCommand ?? null,
] ]

View file

@ -87,7 +87,9 @@ function createExecutionWorkspace(overrides: Partial<ExecutionWorkspace>): Execu
closedAt: overrides.closedAt ?? null, closedAt: overrides.closedAt ?? null,
cleanupEligibleAt: overrides.cleanupEligibleAt ?? null, cleanupEligibleAt: overrides.cleanupEligibleAt ?? null,
cleanupReason: overrides.cleanupReason ?? null, cleanupReason: overrides.cleanupReason ?? null,
config: overrides.config ?? null,
metadata: overrides.metadata ?? null, metadata: overrides.metadata ?? null,
runtimeServices: overrides.runtimeServices ?? [],
createdAt: overrides.createdAt ?? new Date("2026-03-26T09:00:00Z"), createdAt: overrides.createdAt ?? new Date("2026-03-26T09:00:00Z"),
updatedAt: overrides.updatedAt ?? new Date("2026-03-26T09:30:00Z"), updatedAt: overrides.updatedAt ?? new Date("2026-03-26T09:30:00Z"),
}; };

View file

@ -1,8 +1,31 @@
import { useEffect, useMemo, useState } from "react";
import { Link, useParams } from "@/lib/router"; import { Link, useParams } from "@/lib/router";
import { useQuery } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ExternalLink } from "lucide-react"; 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 { 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 { 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) { function isSafeExternalUrl(value: string | null | undefined) {
if (!value) return false; if (!value) return false;
@ -14,69 +37,568 @@ function isSafeExternalUrl(value: string | null | undefined) {
} }
} }
function readText(value: string | null | undefined) {
return value ?? "";
}
function formatJson(value: Record<string, unknown> | 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<string, unknown> | 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<string, unknown> };
} 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<string, unknown> = {};
const configPatch: Record<string, unknown> = {};
const maybeAssign = (
key: keyof Pick<WorkspaceFormState, "name" | "cwd" | "repoUrl" | "baseRef" | "branchName" | "providerRef">,
) => {
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<WorkspaceFormState, "provisionCommand" | "teardownCommand" | "cleanupCommand">) => {
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 (
<label className="space-y-1.5">
<div className="flex items-center justify-between gap-3">
<span className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">{label}</span>
{hint ? <span className="text-[11px] text-muted-foreground">{hint}</span> : null}
</div>
{children}
</label>
);
}
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) { function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
return ( return (
<div className="flex items-start gap-3 py-1.5"> <div className="flex items-start gap-3 py-1.5">
<div className="w-28 shrink-0 text-xs text-muted-foreground">{label}</div> <div className="w-32 shrink-0 text-xs text-muted-foreground">{label}</div>
<div className="min-w-0 flex-1 text-sm">{children}</div> <div className="min-w-0 flex-1 text-sm">{children}</div>
</div> </div>
); );
} }
function StatusPill({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<div className={cn("inline-flex items-center rounded-full border border-border bg-background px-2.5 py-1 text-xs text-muted-foreground", className)}>
{children}
</div>
);
}
function MonoValue({ value, copy }: { value: string; copy?: boolean }) {
return (
<div className="inline-flex max-w-full items-center gap-2">
<span className="break-all font-mono text-xs">{value}</span>
{copy ? (
<CopyText text={value} className="shrink-0 text-muted-foreground hover:text-foreground" copiedLabel="Copied">
<Copy className="h-3.5 w-3.5" />
</CopyText>
) : null}
</div>
);
}
function WorkspaceLink({
project,
workspace,
}: {
project: Project;
workspace: ProjectWorkspace;
}) {
return <Link to={projectWorkspaceUrl(project, workspace.id)} className="hover:underline">{workspace.name}</Link>;
}
export function ExecutionWorkspaceDetail() { export function ExecutionWorkspaceDetail() {
const { workspaceId } = useParams<{ workspaceId: string }>(); const { workspaceId } = useParams<{ workspaceId: string }>();
const queryClient = useQueryClient();
const { setBreadcrumbs } = useBreadcrumbs();
const { selectedCompanyId, setSelectedCompanyId } = useCompany();
const [form, setForm] = useState<WorkspaceFormState | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const { data: workspace, isLoading, error } = useQuery({ const workspaceQuery = useQuery({
queryKey: queryKeys.executionWorkspaces.detail(workspaceId!), queryKey: queryKeys.executionWorkspaces.detail(workspaceId!),
queryFn: () => executionWorkspacesApi.get(workspaceId!), queryFn: () => executionWorkspacesApi.get(workspaceId!),
enabled: Boolean(workspaceId), enabled: Boolean(workspaceId),
}); });
const workspace = workspaceQuery.data ?? null;
if (isLoading) return <p className="text-sm text-muted-foreground">Loading...</p>; const projectQuery = useQuery({
if (error) return <p className="text-sm text-destructive">{error instanceof Error ? error.message : "Failed to load workspace"}</p>; queryKey: workspace ? [...queryKeys.projects.detail(workspace.projectId), workspace.companyId] : ["projects", "detail", "__pending__"],
if (!workspace) return null; 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<string, unknown>) => 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 <p className="text-sm text-muted-foreground">Loading workspace</p>;
if (workspaceQuery.error) {
return (
<p className="text-sm text-destructive">
{workspaceQuery.error instanceof Error ? workspaceQuery.error.message : "Failed to load workspace"}
</p>
);
}
if (!workspace || !form || !initialState) return null;
const saveChanges = () => {
const validationError = validateForm(form);
if (validationError) {
setErrorMessage(validationError);
return;
}
let patch: Record<string, unknown>;
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 ( return (
<div className="max-w-2xl space-y-4"> <div className="mx-auto max-w-5xl space-y-6">
<div className="space-y-1"> <div className="flex flex-wrap items-center gap-3">
<div className="text-xs text-muted-foreground">Execution workspace</div> <Button variant="ghost" size="sm" asChild>
<Link to={project ? `/projects/${projectRef}/workspaces` : "/projects"}>
<ArrowLeft className="mr-1 h-4 w-4" />
Back to all workspaces
</Link>
</Button>
<StatusPill>{workspace.mode}</StatusPill>
<StatusPill>{workspace.providerType}</StatusPill>
<StatusPill className={workspace.status === "active" ? "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300" : undefined}>
{workspace.status}
</StatusPill>
</div>
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(18rem,0.95fr)]">
<div className="space-y-6">
<div className="rounded-2xl border border-border bg-card p-5">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">
Execution workspace
</div>
<h1 className="text-2xl font-semibold">{workspace.name}</h1> <h1 className="text-2xl font-semibold">{workspace.name}</h1>
<div className="text-sm text-muted-foreground"> <p className="max-w-2xl text-sm text-muted-foreground">
{workspace.status} · {workspace.mode} · {workspace.providerType} 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.
</p>
</div> </div>
</div> </div>
<div className="rounded-lg border border-border p-4"> <Separator className="my-5" />
<div className="grid gap-4 md:grid-cols-2">
<Field label="Workspace name">
<input
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none"
value={form.name}
onChange={(event) => setForm((current) => current ? { ...current, name: event.target.value } : current)}
placeholder="Execution workspace name"
/>
</Field>
<Field label="Branch name" hint="Useful for isolated worktrees">
<input
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
value={form.branchName}
onChange={(event) => setForm((current) => current ? { ...current, branchName: event.target.value } : current)}
placeholder="PAP-946-workspace"
/>
</Field>
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<Field label="Working directory">
<input
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
value={form.cwd}
onChange={(event) => setForm((current) => current ? { ...current, cwd: event.target.value } : current)}
placeholder="/absolute/path/to/workspace"
/>
</Field>
<Field label="Provider path / ref">
<input
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
value={form.providerRef}
onChange={(event) => setForm((current) => current ? { ...current, providerRef: event.target.value } : current)}
placeholder="/path/to/worktree or provider ref"
/>
</Field>
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<Field label="Repo URL">
<input
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none"
value={form.repoUrl}
onChange={(event) => setForm((current) => current ? { ...current, repoUrl: event.target.value } : current)}
placeholder="https://github.com/org/repo"
/>
</Field>
<Field label="Base ref">
<input
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
value={form.baseRef}
onChange={(event) => setForm((current) => current ? { ...current, baseRef: event.target.value } : current)}
placeholder="origin/main"
/>
</Field>
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<Field label="Provision command" hint="Runs when Paperclip prepares this execution workspace">
<textarea
className="min-h-28 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
value={form.provisionCommand}
onChange={(event) => setForm((current) => current ? { ...current, provisionCommand: event.target.value } : current)}
placeholder="bash ./scripts/provision-worktree.sh"
/>
</Field>
<Field label="Teardown command" hint="Runs when the execution workspace is archived or cleaned up">
<textarea
className="min-h-28 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
value={form.teardownCommand}
onChange={(event) => setForm((current) => current ? { ...current, teardownCommand: event.target.value } : current)}
placeholder="bash ./scripts/teardown-worktree.sh"
/>
</Field>
</div>
<div className="mt-4 grid gap-4">
<Field label="Cleanup command" hint="Workspace-specific cleanup before teardown">
<textarea
className="min-h-24 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
value={form.cleanupCommand}
onChange={(event) => setForm((current) => current ? { ...current, cleanupCommand: event.target.value } : current)}
placeholder="pkill -f vite || true"
/>
</Field>
<Field label="Runtime services JSON" hint="Concrete workspace runtime settings, including services">
<textarea
className="min-h-48 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none"
value={form.workspaceRuntime}
onChange={(event) => setForm((current) => current ? { ...current, workspaceRuntime: event.target.value } : current)}
placeholder={'{\n "services": [\n {\n "name": "web",\n "command": "pnpm dev",\n "port": 3100\n }\n ]\n}'}
/>
</Field>
</div>
<div className="mt-5 flex flex-wrap items-center gap-3">
<Button disabled={!isDirty || updateWorkspace.isPending} onClick={saveChanges}>
{updateWorkspace.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Save changes
</Button>
<Button
variant="outline"
disabled={!isDirty || updateWorkspace.isPending}
onClick={() => {
setForm(initialState);
setErrorMessage(null);
}}
>
Reset
</Button>
{errorMessage ? <p className="text-sm text-destructive">{errorMessage}</p> : null}
{!errorMessage && !isDirty ? <p className="text-sm text-muted-foreground">No unsaved changes.</p> : null}
</div>
</div>
</div>
<div className="space-y-6">
<div className="rounded-2xl border border-border bg-card p-5">
<div className="space-y-1">
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Linked objects</div>
<h2 className="text-lg font-semibold">Workspace context</h2>
</div>
<Separator className="my-4" />
<DetailRow label="Project"> <DetailRow label="Project">
{workspace.projectId ? <Link to={`/projects/${workspace.projectId}`} className="hover:underline">{workspace.projectId}</Link> : "None"} {project ? <Link to={`/projects/${projectRef}`} className="hover:underline">{project.name}</Link> : <MonoValue value={workspace.projectId} />}
</DetailRow>
<DetailRow label="Project workspace">
{project && linkedProjectWorkspace ? (
<WorkspaceLink project={project} workspace={linkedProjectWorkspace} />
) : workspace.projectWorkspaceId ? (
<MonoValue value={workspace.projectWorkspaceId} />
) : (
"None"
)}
</DetailRow> </DetailRow>
<DetailRow label="Source issue"> <DetailRow label="Source issue">
{workspace.sourceIssueId ? <Link to={`/issues/${workspace.sourceIssueId}`} className="hover:underline">{workspace.sourceIssueId}</Link> : "None"} {sourceIssue ? (
<Link to={issueUrl(sourceIssue)} className="hover:underline">
{sourceIssue.identifier ?? sourceIssue.id} · {sourceIssue.title}
</Link>
) : workspace.sourceIssueId ? (
<MonoValue value={workspace.sourceIssueId} />
) : (
"None"
)}
</DetailRow> </DetailRow>
<DetailRow label="Branch">{workspace.branchName ?? "None"}</DetailRow> <DetailRow label="Derived from">
<DetailRow label="Base ref">{workspace.baseRef ?? "None"}</DetailRow> {derivedWorkspace ? (
<Link to={`/execution-workspaces/${derivedWorkspace.id}`} className="hover:underline">
{derivedWorkspace.name}
</Link>
) : workspace.derivedFromExecutionWorkspaceId ? (
<MonoValue value={workspace.derivedFromExecutionWorkspaceId} />
) : (
"None"
)}
</DetailRow>
<DetailRow label="Workspace ID">
<MonoValue value={workspace.id} />
</DetailRow>
</div>
<div className="rounded-2xl border border-border bg-card p-5">
<div className="space-y-1">
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Paths and refs</div>
<h2 className="text-lg font-semibold">Concrete location</h2>
</div>
<Separator className="my-4" />
<DetailRow label="Working dir"> <DetailRow label="Working dir">
<span className="break-all font-mono text-xs">{workspace.cwd ?? "None"}</span> {workspace.cwd ? <MonoValue value={workspace.cwd} copy /> : "None"}
</DetailRow> </DetailRow>
<DetailRow label="Provider ref"> <DetailRow label="Provider ref">
<span className="break-all font-mono text-xs">{workspace.providerRef ?? "None"}</span> {workspace.providerRef ? <MonoValue value={workspace.providerRef} copy /> : "None"}
</DetailRow> </DetailRow>
<DetailRow label="Repo URL"> <DetailRow label="Repo URL">
{workspace.repoUrl && isSafeExternalUrl(workspace.repoUrl) ? ( {workspace.repoUrl && isSafeExternalUrl(workspace.repoUrl) ? (
<div className="inline-flex items-center gap-2">
<a href={workspace.repoUrl} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 hover:underline"> <a href={workspace.repoUrl} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 hover:underline">
{workspace.repoUrl} {workspace.repoUrl}
<ExternalLink className="h-3 w-3" /> <ExternalLink className="h-3.5 w-3.5" />
</a> </a>
<CopyText text={workspace.repoUrl} className="text-muted-foreground hover:text-foreground" copiedLabel="Copied">
<Copy className="h-3.5 w-3.5" />
</CopyText>
</div>
) : workspace.repoUrl ? ( ) : workspace.repoUrl ? (
<span className="break-all font-mono text-xs">{workspace.repoUrl}</span> <MonoValue value={workspace.repoUrl} copy />
) : "None"} ) : (
"None"
)}
</DetailRow> </DetailRow>
<DetailRow label="Opened">{new Date(workspace.openedAt).toLocaleString()}</DetailRow> <DetailRow label="Base ref">
<DetailRow label="Last used">{new Date(workspace.lastUsedAt).toLocaleString()}</DetailRow> {workspace.baseRef ? <MonoValue value={workspace.baseRef} copy /> : "None"}
</DetailRow>
<DetailRow label="Branch">
{workspace.branchName ? <MonoValue value={workspace.branchName} copy /> : "None"}
</DetailRow>
<DetailRow label="Opened">{formatDateTime(workspace.openedAt)}</DetailRow>
<DetailRow label="Last used">{formatDateTime(workspace.lastUsedAt)}</DetailRow>
<DetailRow label="Cleanup"> <DetailRow label="Cleanup">
{workspace.cleanupEligibleAt ? `${new Date(workspace.cleanupEligibleAt).toLocaleString()}${workspace.cleanupReason ? ` · ${workspace.cleanupReason}` : ""}` : "Not scheduled"} {workspace.cleanupEligibleAt
? `${formatDateTime(workspace.cleanupEligibleAt)}${workspace.cleanupReason ? ` · ${workspace.cleanupReason}` : ""}`
: "Not scheduled"}
</DetailRow> </DetailRow>
</div> </div>
<div className="rounded-2xl border border-border bg-card p-5">
<div className="space-y-1">
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">Runtime services</div>
<h2 className="text-lg font-semibold">Attached services</h2>
</div>
<Separator className="my-4" />
{workspace.runtimeServices && workspace.runtimeServices.length > 0 ? (
<div className="space-y-3">
{workspace.runtimeServices.map((service) => (
<div key={service.id} className="rounded-xl border border-border/80 bg-background px-3 py-2">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<div className="text-sm font-medium">{service.serviceName}</div>
<div className="text-xs text-muted-foreground">{service.status} · {service.lifecycle}</div>
<div className="space-y-1 text-xs text-muted-foreground">
{service.url ? (
<a href={service.url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 hover:underline">
{service.url}
<ExternalLink className="h-3.5 w-3.5" />
</a>
) : null}
{service.command ? <MonoValue value={service.command} copy /> : null}
{service.cwd ? <MonoValue value={service.cwd} copy /> : null}
</div>
</div>
<StatusPill>{service.healthStatus}</StatusPill>
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No runtime services are attached to this execution workspace.</p>
)}
</div>
</div>
</div>
</div> </div>
); );
} }