Improve execution workspace detail editing
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
84e35b801c
commit
c114ff4dc6
12 changed files with 905 additions and 77 deletions
|
|
@ -186,6 +186,7 @@ export type {
|
||||||
ProjectGoalRef,
|
ProjectGoalRef,
|
||||||
ProjectWorkspace,
|
ProjectWorkspace,
|
||||||
ExecutionWorkspace,
|
ExecutionWorkspace,
|
||||||
|
ExecutionWorkspaceConfig,
|
||||||
WorkspaceRuntimeService,
|
WorkspaceRuntimeService,
|
||||||
WorkspaceOperation,
|
WorkspaceOperation,
|
||||||
WorkspaceOperationPhase,
|
WorkspaceOperationPhase,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,7 @@ export {
|
||||||
} from "./work-product.js";
|
} from "./work-product.js";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
executionWorkspaceConfigSchema,
|
||||||
updateExecutionWorkspaceSchema,
|
updateExecutionWorkspaceSchema,
|
||||||
executionWorkspaceStatusSchema,
|
executionWorkspaceStatusSchema,
|
||||||
type UpdateExecutionWorkspace,
|
type UpdateExecutionWorkspace,
|
||||||
|
|
|
||||||
72
server/src/__tests__/execution-workspaces-service.test.ts
Normal file
72
server/src/__tests__/execution-workspaces-service.test.ts
Normal 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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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
|
||||||
|
|
@ -101,7 +122,7 @@ export function executionWorkspaceRoutes(db: Db) {
|
||||||
cleanupCommand: projectWorkspaces.cleanupCommand,
|
cleanupCommand: projectWorkspaces.cleanupCommand,
|
||||||
})
|
})
|
||||||
.from(projectWorkspaces)
|
.from(projectWorkspaces)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(projectWorkspaces.id, existing.projectWorkspaceId),
|
eq(projectWorkspaces.id, existing.projectWorkspaceId),
|
||||||
eq(projectWorkspaces.companyId, existing.companyId),
|
eq(projectWorkspaces.companyId, existing.companyId),
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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,68 +37,567 @@ 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>
|
||||||
<h1 className="text-2xl font-semibold">{workspace.name}</h1>
|
<Link to={project ? `/projects/${projectRef}/workspaces` : "/projects"}>
|
||||||
<div className="text-sm text-muted-foreground">
|
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||||
{workspace.status} · {workspace.mode} · {workspace.providerType}
|
Back to all workspaces
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="rounded-lg border border-border p-4">
|
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(18rem,0.95fr)]">
|
||||||
<DetailRow label="Project">
|
<div className="space-y-6">
|
||||||
{workspace.projectId ? <Link to={`/projects/${workspace.projectId}`} className="hover:underline">{workspace.projectId}</Link> : "None"}
|
<div className="rounded-2xl border border-border bg-card p-5">
|
||||||
</DetailRow>
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
<DetailRow label="Source issue">
|
<div className="space-y-2">
|
||||||
{workspace.sourceIssueId ? <Link to={`/issues/${workspace.sourceIssueId}`} className="hover:underline">{workspace.sourceIssueId}</Link> : "None"}
|
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">
|
||||||
</DetailRow>
|
Execution workspace
|
||||||
<DetailRow label="Branch">{workspace.branchName ?? "None"}</DetailRow>
|
</div>
|
||||||
<DetailRow label="Base ref">{workspace.baseRef ?? "None"}</DetailRow>
|
<h1 className="text-2xl font-semibold">{workspace.name}</h1>
|
||||||
<DetailRow label="Working dir">
|
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||||
<span className="break-all font-mono text-xs">{workspace.cwd ?? "None"}</span>
|
Configure the concrete runtime workspace that Paperclip reuses for this issue flow. These settings stay
|
||||||
</DetailRow>
|
attached to the execution workspace so future runs can keep local paths, repo refs, provisioning, teardown,
|
||||||
<DetailRow label="Provider ref">
|
and runtime-service behavior in sync with the actual workspace being reused.
|
||||||
<span className="break-all font-mono text-xs">{workspace.providerRef ?? "None"}</span>
|
</p>
|
||||||
</DetailRow>
|
</div>
|
||||||
<DetailRow label="Repo URL">
|
</div>
|
||||||
{workspace.repoUrl && isSafeExternalUrl(workspace.repoUrl) ? (
|
|
||||||
<a href={workspace.repoUrl} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 hover:underline">
|
<Separator className="my-5" />
|
||||||
{workspace.repoUrl}
|
|
||||||
<ExternalLink className="h-3 w-3" />
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
</a>
|
<Field label="Workspace name">
|
||||||
) : workspace.repoUrl ? (
|
<input
|
||||||
<span className="break-all font-mono text-xs">{workspace.repoUrl}</span>
|
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none"
|
||||||
) : "None"}
|
value={form.name}
|
||||||
</DetailRow>
|
onChange={(event) => setForm((current) => current ? { ...current, name: event.target.value } : current)}
|
||||||
<DetailRow label="Opened">{new Date(workspace.openedAt).toLocaleString()}</DetailRow>
|
placeholder="Execution workspace name"
|
||||||
<DetailRow label="Last used">{new Date(workspace.lastUsedAt).toLocaleString()}</DetailRow>
|
/>
|
||||||
<DetailRow label="Cleanup">
|
</Field>
|
||||||
{workspace.cleanupEligibleAt ? `${new Date(workspace.cleanupEligibleAt).toLocaleString()}${workspace.cleanupReason ? ` · ${workspace.cleanupReason}` : ""}` : "Not scheduled"}
|
<Field label="Branch name" hint="Useful for isolated worktrees">
|
||||||
</DetailRow>
|
<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">
|
||||||
|
{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 label="Source issue">
|
||||||
|
{sourceIssue ? (
|
||||||
|
<Link to={issueUrl(sourceIssue)} className="hover:underline">
|
||||||
|
{sourceIssue.identifier ?? sourceIssue.id} · {sourceIssue.title}
|
||||||
|
</Link>
|
||||||
|
) : workspace.sourceIssueId ? (
|
||||||
|
<MonoValue value={workspace.sourceIssueId} />
|
||||||
|
) : (
|
||||||
|
"None"
|
||||||
|
)}
|
||||||
|
</DetailRow>
|
||||||
|
<DetailRow label="Derived from">
|
||||||
|
{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">
|
||||||
|
{workspace.cwd ? <MonoValue value={workspace.cwd} copy /> : "None"}
|
||||||
|
</DetailRow>
|
||||||
|
<DetailRow label="Provider ref">
|
||||||
|
{workspace.providerRef ? <MonoValue value={workspace.providerRef} copy /> : "None"}
|
||||||
|
</DetailRow>
|
||||||
|
<DetailRow label="Repo URL">
|
||||||
|
{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">
|
||||||
|
{workspace.repoUrl}
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
|
</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 ? (
|
||||||
|
<MonoValue value={workspace.repoUrl} copy />
|
||||||
|
) : (
|
||||||
|
"None"
|
||||||
|
)}
|
||||||
|
</DetailRow>
|
||||||
|
<DetailRow label="Base ref">
|
||||||
|
{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">
|
||||||
|
{workspace.cleanupEligibleAt
|
||||||
|
? `${formatDateTime(workspace.cleanupEligibleAt)}${workspace.cleanupReason ? ` · ${workspace.cleanupReason}` : ""}`
|
||||||
|
: "Not scheduled"}
|
||||||
|
</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">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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue