mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 18:30:39 +09:00
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
|
|
@ -1,11 +1,126 @@
|
|||
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { executionWorkspaces } from "@paperclipai/db";
|
||||
import type { ExecutionWorkspace } from "@paperclipai/shared";
|
||||
import { executionWorkspaces, workspaceRuntimeServices } from "@paperclipai/db";
|
||||
import type { ExecutionWorkspace, ExecutionWorkspaceConfig, WorkspaceRuntimeService } from "@paperclipai/shared";
|
||||
|
||||
type ExecutionWorkspaceRow = typeof executionWorkspaces.$inferSelect;
|
||||
type WorkspaceRuntimeServiceRow = typeof workspaceRuntimeServices.$inferSelect;
|
||||
|
||||
function toExecutionWorkspace(row: ExecutionWorkspaceRow): ExecutionWorkspace {
|
||||
function isRecord(value: unknown): value is Record<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 {
|
||||
id: row.id,
|
||||
companyId: row.companyId,
|
||||
|
|
@ -28,7 +143,9 @@ function toExecutionWorkspace(row: ExecutionWorkspaceRow): ExecutionWorkspace {
|
|||
closedAt: row.closedAt ?? null,
|
||||
cleanupEligibleAt: row.cleanupEligibleAt ?? null,
|
||||
cleanupReason: row.cleanupReason ?? null,
|
||||
config: readExecutionWorkspaceConfig((row.metadata as Record<string, unknown> | null) ?? null),
|
||||
metadata: (row.metadata as Record<string, unknown> | null) ?? null,
|
||||
runtimeServices,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
|
|
@ -63,7 +180,7 @@ export function executionWorkspaceService(db: Db) {
|
|||
.from(executionWorkspaces)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(executionWorkspaces.lastUsedAt), desc(executionWorkspaces.createdAt));
|
||||
return rows.map(toExecutionWorkspace);
|
||||
return rows.map((row) => toExecutionWorkspace(row));
|
||||
},
|
||||
|
||||
getById: async (id: string) => {
|
||||
|
|
@ -72,7 +189,13 @@ export function executionWorkspaceService(db: Db) {
|
|||
.from(executionWorkspaces)
|
||||
.where(eq(executionWorkspaces.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return row ? toExecutionWorkspace(row) : null;
|
||||
if (!row) return null;
|
||||
const runtimeServiceRows = await db
|
||||
.select()
|
||||
.from(workspaceRuntimeServices)
|
||||
.where(eq(workspaceRuntimeServices.executionWorkspaceId, row.id))
|
||||
.orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt));
|
||||
return toExecutionWorkspace(row, runtimeServiceRows.map(toRuntimeService));
|
||||
},
|
||||
|
||||
create: async (data: typeof executionWorkspaces.$inferInsert) => {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { execFile as execFileCallback } from "node:child_process";
|
|||
import { promisify } from "node:util";
|
||||
import { and, asc, desc, eq, gt, inArray, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import type { BillingType } from "@paperclipai/shared";
|
||||
import type { BillingType, ExecutionWorkspaceConfig } from "@paperclipai/shared";
|
||||
import {
|
||||
agents,
|
||||
agentRuntimeState,
|
||||
|
|
@ -40,7 +40,7 @@ import {
|
|||
sanitizeRuntimeServiceBaseEnv,
|
||||
} from "./workspace-runtime.js";
|
||||
import { issueService } from "./issues.js";
|
||||
import { executionWorkspaceService } from "./execution-workspaces.js";
|
||||
import { executionWorkspaceService, mergeExecutionWorkspaceConfig } from "./execution-workspaces.js";
|
||||
import { workspaceOperationService } from "./workspace-operations.js";
|
||||
import {
|
||||
buildExecutionWorkspaceAdapterConfig,
|
||||
|
|
@ -76,6 +76,57 @@ const SESSIONED_LOCAL_ADAPTERS = new Set([
|
|||
"pi_local",
|
||||
]);
|
||||
|
||||
function applyPersistedExecutionWorkspaceConfig(input: {
|
||||
config: Record<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 {
|
||||
const trimmed = repoUrl?.trim() ?? "";
|
||||
if (!trimmed) return null;
|
||||
|
|
@ -2048,18 +2099,6 @@ export function heartbeatService(db: Db) {
|
|||
mode: executionWorkspaceMode,
|
||||
legacyUseProjectWorkspace: issueAssigneeOverrides?.useProjectWorkspace ?? null,
|
||||
});
|
||||
const mergedConfig = issueAssigneeOverrides?.adapterConfig
|
||||
? { ...workspaceManagedConfig, ...issueAssigneeOverrides.adapterConfig }
|
||||
: workspaceManagedConfig;
|
||||
const { config: resolvedConfig, secretKeys } = await secretsSvc.resolveAdapterConfigForRuntime(
|
||||
agent.companyId,
|
||||
mergedConfig,
|
||||
);
|
||||
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId);
|
||||
const runtimeConfig = {
|
||||
...resolvedConfig,
|
||||
paperclipRuntimeSkills: runtimeSkillEntries,
|
||||
};
|
||||
const issueRef = issueContext
|
||||
? {
|
||||
id: issueContext.id,
|
||||
|
|
@ -2073,6 +2112,24 @@ export function heartbeatService(db: Db) {
|
|||
: null;
|
||||
const existingExecutionWorkspace =
|
||||
issueRef?.executionWorkspaceId ? await executionWorkspacesSvc.getById(issueRef.executionWorkspaceId) : null;
|
||||
const persistedWorkspaceManagedConfig = applyPersistedExecutionWorkspaceConfig({
|
||||
config: workspaceManagedConfig,
|
||||
workspaceConfig: existingExecutionWorkspace?.config ?? null,
|
||||
mode: executionWorkspaceMode,
|
||||
});
|
||||
const mergedConfig = issueAssigneeOverrides?.adapterConfig
|
||||
? { ...persistedWorkspaceManagedConfig, ...issueAssigneeOverrides.adapterConfig }
|
||||
: persistedWorkspaceManagedConfig;
|
||||
const { config: resolvedConfig, secretKeys } = await secretsSvc.resolveAdapterConfigForRuntime(
|
||||
agent.companyId,
|
||||
mergedConfig,
|
||||
);
|
||||
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId);
|
||||
const runtimeConfig = {
|
||||
...resolvedConfig,
|
||||
paperclipRuntimeSkills: runtimeSkillEntries,
|
||||
};
|
||||
const configSnapshot = buildExecutionWorkspaceConfigSnapshot(resolvedConfig);
|
||||
const workspaceOperationRecorder = workspaceOperationsSvc.createRecorder({
|
||||
companyId: agent.companyId,
|
||||
heartbeatRunId: run.id,
|
||||
|
|
@ -2103,6 +2160,14 @@ export function heartbeatService(db: Db) {
|
|||
existingExecutionWorkspace &&
|
||||
existingExecutionWorkspace.status !== "archived";
|
||||
let persistedExecutionWorkspace = null;
|
||||
const nextExecutionWorkspaceMetadataBase = {
|
||||
...(existingExecutionWorkspace?.metadata ?? {}),
|
||||
source: executionWorkspace.source,
|
||||
createdByRuntime: executionWorkspace.created,
|
||||
} as Record<string, unknown>;
|
||||
const nextExecutionWorkspaceMetadata = configSnapshot
|
||||
? mergeExecutionWorkspaceConfig(nextExecutionWorkspaceMetadataBase, configSnapshot)
|
||||
: nextExecutionWorkspaceMetadataBase;
|
||||
try {
|
||||
persistedExecutionWorkspace = shouldReuseExisting && existingExecutionWorkspace
|
||||
? await executionWorkspacesSvc.update(existingExecutionWorkspace.id, {
|
||||
|
|
@ -2114,11 +2179,7 @@ export function heartbeatService(db: Db) {
|
|||
providerRef: executionWorkspace.worktreePath,
|
||||
status: "active",
|
||||
lastUsedAt: new Date(),
|
||||
metadata: {
|
||||
...(existingExecutionWorkspace.metadata ?? {}),
|
||||
source: executionWorkspace.source,
|
||||
createdByRuntime: executionWorkspace.created,
|
||||
},
|
||||
metadata: nextExecutionWorkspaceMetadata,
|
||||
})
|
||||
: resolvedProjectId
|
||||
? await executionWorkspacesSvc.create({
|
||||
|
|
@ -2145,10 +2206,7 @@ export function heartbeatService(db: Db) {
|
|||
providerRef: executionWorkspace.worktreePath,
|
||||
lastUsedAt: new Date(),
|
||||
openedAt: new Date(),
|
||||
metadata: {
|
||||
source: executionWorkspace.source,
|
||||
createdByRuntime: executionWorkspace.created,
|
||||
},
|
||||
metadata: nextExecutionWorkspaceMetadata,
|
||||
})
|
||||
: null;
|
||||
} catch (error) {
|
||||
|
|
@ -2175,7 +2233,8 @@ export function heartbeatService(db: Db) {
|
|||
cwd: resolvedWorkspace.cwd,
|
||||
cleanupCommand: null,
|
||||
},
|
||||
teardownCommand: projectExecutionWorkspacePolicy?.workspaceStrategy?.teardownCommand ?? null,
|
||||
cleanupCommand: configSnapshot?.cleanupCommand ?? null,
|
||||
teardownCommand: configSnapshot?.teardownCommand ?? projectExecutionWorkspacePolicy?.workspaceStrategy?.teardownCommand ?? null,
|
||||
recorder: workspaceOperationRecorder,
|
||||
});
|
||||
} catch (cleanupError) {
|
||||
|
|
|
|||
|
|
@ -702,6 +702,7 @@ export async function cleanupExecutionWorkspaceArtifacts(input: {
|
|||
cwd: string | null;
|
||||
cleanupCommand: string | null;
|
||||
} | null;
|
||||
cleanupCommand?: string | null;
|
||||
teardownCommand?: string | null;
|
||||
recorder?: WorkspaceOperationRecorder | null;
|
||||
}) {
|
||||
|
|
@ -713,6 +714,7 @@ export async function cleanupExecutionWorkspaceArtifacts(input: {
|
|||
});
|
||||
const createdByRuntime = input.workspace.metadata?.createdByRuntime === true;
|
||||
const cleanupCommands = [
|
||||
input.cleanupCommand ?? null,
|
||||
input.projectWorkspace?.cleanupCommand ?? null,
|
||||
input.teardownCommand ?? null,
|
||||
]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue