Merge public-gh/master into PAP-881-document-revisions-bulid-it

This commit is contained in:
dotta 2026-03-31 07:31:17 -05:00
commit 41f261eaf5
194 changed files with 29520 additions and 2185 deletions

View file

@ -9,6 +9,7 @@ const ROOT_KEY = "instructionsRootPath";
const ENTRY_KEY = "instructionsEntryFile";
const FILE_KEY = "instructionsFilePath";
const PROMPT_KEY = "promptTemplate";
/** @deprecated Use the managed instructions bundle system instead. */
const BOOTSTRAP_PROMPT_KEY = "bootstrapPromptTemplate";
const LEGACY_PROMPT_TEMPLATE_PATH = "promptTemplate.legacy.md";
const IGNORED_INSTRUCTIONS_FILE_NAMES = new Set([".DS_Store", "Thumbs.db", "Desktop.ini"]);

View file

@ -1475,7 +1475,7 @@ function normalizePortableConfig(
key === "instructionsRootPath" ||
key === "instructionsEntryFile" ||
key === "promptTemplate" ||
key === "bootstrapPromptTemplate" ||
key === "bootstrapPromptTemplate" || // deprecated — kept for backward compat
key === "paperclipSkillSync"
) continue;
if (key === "env") continue;
@ -3895,7 +3895,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
desiredSkills,
);
delete adapterConfigWithSkills.promptTemplate;
delete adapterConfigWithSkills.bootstrapPromptTemplate;
delete adapterConfigWithSkills.bootstrapPromptTemplate; // deprecated
delete adapterConfigWithSkills.instructionsFilePath;
delete adapterConfigWithSkills.instructionsBundleMode;
delete adapterConfigWithSkills.instructionsRootPath;

View file

@ -1,11 +1,292 @@
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
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, issues, projects, projectWorkspaces, workspaceRuntimeServices } from "@paperclipai/db";
import type {
ExecutionWorkspace,
ExecutionWorkspaceCloseAction,
ExecutionWorkspaceCloseGitReadiness,
ExecutionWorkspaceCloseReadiness,
ExecutionWorkspaceConfig,
WorkspaceRuntimeService,
} from "@paperclipai/shared";
import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
type ExecutionWorkspaceRow = typeof executionWorkspaces.$inferSelect;
type WorkspaceRuntimeServiceRow = typeof workspaceRuntimeServices.$inferSelect;
const execFileAsync = promisify(execFile);
const TERMINAL_ISSUE_STATUSES = new Set(["done", "cancelled"]);
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 };
}
async function pathExists(value: string | null | undefined) {
if (!value) return false;
try {
await fs.access(value);
return true;
} catch {
return false;
}
}
async function runGit(args: string[], cwd: string) {
return await execFileAsync("git", ["-C", cwd, ...args], { cwd });
}
async function inspectGitCloseReadiness(workspace: ExecutionWorkspace): Promise<{
git: ExecutionWorkspaceCloseGitReadiness | null;
warnings: string[];
}> {
const warnings: string[] = [];
const workspacePath = readNullableString(workspace.providerRef) ?? readNullableString(workspace.cwd);
const createdByRuntime = workspace.metadata?.createdByRuntime === true;
const expectsGitInspection =
workspace.providerType === "git_worktree" ||
Boolean(workspace.repoUrl || workspace.baseRef || workspace.branchName || workspacePath);
if (!expectsGitInspection) {
return { git: null, warnings };
}
if (!workspacePath) {
warnings.push("Workspace has no local path, so Paperclip cannot inspect git status before close.");
return { git: null, warnings };
}
if (!(await pathExists(workspacePath))) {
warnings.push(`Workspace path "${workspacePath}" does not exist, so Paperclip cannot inspect git status before close.`);
return {
git: {
repoRoot: null,
workspacePath,
branchName: workspace.branchName,
baseRef: workspace.baseRef,
hasDirtyTrackedFiles: false,
hasUntrackedFiles: false,
dirtyEntryCount: 0,
untrackedEntryCount: 0,
aheadCount: null,
behindCount: null,
isMergedIntoBase: null,
createdByRuntime,
},
warnings,
};
}
let repoRoot: string | null = null;
try {
repoRoot = (await runGit(["rev-parse", "--show-toplevel"], workspacePath)).stdout.trim() || null;
} catch (error) {
warnings.push(
`Could not inspect git status for "${workspacePath}": ${error instanceof Error ? error.message : String(error)}`,
);
}
let branchName = workspace.branchName;
if (repoRoot && !branchName) {
try {
branchName = (await runGit(["rev-parse", "--abbrev-ref", "HEAD"], workspacePath)).stdout.trim() || null;
} catch {
branchName = workspace.branchName;
}
}
let dirtyEntryCount = 0;
let untrackedEntryCount = 0;
if (repoRoot) {
try {
const statusOutput = (await runGit(["status", "--porcelain=v1", "--untracked-files=all"], workspacePath)).stdout;
for (const line of statusOutput.split(/\r?\n/)) {
if (!line) continue;
if (line.startsWith("??")) {
untrackedEntryCount += 1;
continue;
}
dirtyEntryCount += 1;
}
} catch (error) {
warnings.push(
`Could not read git working tree status for "${workspacePath}": ${error instanceof Error ? error.message : String(error)}`,
);
}
}
let aheadCount: number | null = null;
let behindCount: number | null = null;
let isMergedIntoBase: boolean | null = null;
const baseRef = workspace.baseRef;
if (repoRoot && baseRef) {
try {
const counts = (await runGit(["rev-list", "--left-right", "--count", `${baseRef}...HEAD`], workspacePath)).stdout.trim();
const [behindRaw, aheadRaw] = counts.split(/\s+/);
behindCount = behindRaw ? Number.parseInt(behindRaw, 10) : 0;
aheadCount = aheadRaw ? Number.parseInt(aheadRaw, 10) : 0;
} catch (error) {
warnings.push(
`Could not compare this workspace against ${baseRef}: ${error instanceof Error ? error.message : String(error)}`,
);
}
try {
await runGit(["merge-base", "--is-ancestor", "HEAD", baseRef], workspacePath);
isMergedIntoBase = true;
} catch (error) {
const code = typeof error === "object" && error && "code" in error ? (error as { code?: unknown }).code : null;
if (code === 1) isMergedIntoBase = false;
else {
warnings.push(
`Could not determine whether this workspace is merged into ${baseRef}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
return {
git: {
repoRoot,
workspacePath,
branchName,
baseRef,
hasDirtyTrackedFiles: dirtyEntryCount > 0,
hasUntrackedFiles: untrackedEntryCount > 0,
dirtyEntryCount,
untrackedEntryCount,
aheadCount,
behindCount,
isMergedIntoBase,
createdByRuntime,
},
warnings,
};
}
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),
desiredState: raw.desiredState === "running" || raw.desiredState === "stopped" ? raw.desiredState : null,
};
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,
desiredState: 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,
desiredState:
patch.desiredState !== undefined
? patch.desiredState === "running" || patch.desiredState === "stopped"
? patch.desiredState
: null
: current.desiredState,
};
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,
desiredState: nextConfig.desiredState,
};
} 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 +309,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 +346,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 +355,268 @@ 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));
},
getCloseReadiness: async (id: string): Promise<ExecutionWorkspaceCloseReadiness | null> => {
const workspace = await db
.select()
.from(executionWorkspaces)
.where(eq(executionWorkspaces.id, id))
.then((rows) => rows[0] ?? null);
if (!workspace) return null;
const runtimeServiceRows = await db
.select()
.from(workspaceRuntimeServices)
.where(eq(workspaceRuntimeServices.executionWorkspaceId, workspace.id))
.orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt));
const runtimeServices = runtimeServiceRows.map(toRuntimeService);
const linkedIssues = await db
.select({
id: issues.id,
identifier: issues.identifier,
title: issues.title,
status: issues.status,
})
.from(issues)
.where(and(eq(issues.companyId, workspace.companyId), eq(issues.executionWorkspaceId, workspace.id)));
const projectWorkspace = workspace.projectWorkspaceId
? await db
.select({
id: projectWorkspaces.id,
cwd: projectWorkspaces.cwd,
cleanupCommand: projectWorkspaces.cleanupCommand,
isPrimary: projectWorkspaces.isPrimary,
})
.from(projectWorkspaces)
.where(
and(
eq(projectWorkspaces.companyId, workspace.companyId),
eq(projectWorkspaces.id, workspace.projectWorkspaceId),
),
)
.then((rows) => rows[0] ?? null)
: null;
const primaryProjectWorkspace = workspace.projectId
? await db
.select({
id: projectWorkspaces.id,
})
.from(projectWorkspaces)
.where(
and(
eq(projectWorkspaces.companyId, workspace.companyId),
eq(projectWorkspaces.projectId, workspace.projectId),
eq(projectWorkspaces.isPrimary, true),
),
)
.then((rows) => rows[0] ?? null)
: null;
const projectPolicy = workspace.projectId
? await db
.select({
executionWorkspacePolicy: projects.executionWorkspacePolicy,
})
.from(projects)
.where(and(eq(projects.id, workspace.projectId), eq(projects.companyId, workspace.companyId)))
.then((rows) => parseProjectExecutionWorkspacePolicy(rows[0]?.executionWorkspacePolicy))
: null;
const executionWorkspace = toExecutionWorkspace(workspace, runtimeServices);
const config = readExecutionWorkspaceConfig((workspace.metadata as Record<string, unknown> | null) ?? null);
const { git, warnings: gitWarnings } = await inspectGitCloseReadiness(executionWorkspace);
const warnings = [...gitWarnings];
const blockingReasons: string[] = [];
const isSharedWorkspace = executionWorkspace.mode === "shared_workspace";
const workspacePath = readNullableString(executionWorkspace.providerRef) ?? readNullableString(executionWorkspace.cwd);
const resolvedWorkspacePath = workspacePath ? path.resolve(workspacePath) : null;
const resolvedPrimaryWorkspacePath = projectWorkspace?.cwd ? path.resolve(projectWorkspace.cwd) : null;
const isProjectPrimaryWorkspace =
workspace.projectWorkspaceId != null
&& workspace.projectWorkspaceId === primaryProjectWorkspace?.id
&& resolvedWorkspacePath != null
&& resolvedPrimaryWorkspacePath != null
&& resolvedWorkspacePath === resolvedPrimaryWorkspacePath;
const linkedIssueSummaries = linkedIssues.map((issue) => ({
...issue,
isTerminal: TERMINAL_ISSUE_STATUSES.has(issue.status),
}));
const blockingIssues = linkedIssueSummaries.filter((issue) => !issue.isTerminal);
if (blockingIssues.length > 0) {
const linkedIssueMessage =
blockingIssues.length === 1
? "This workspace is still linked to an open issue."
: `This workspace is still linked to ${blockingIssues.length} open issues.`;
if (isSharedWorkspace) {
warnings.push(`${linkedIssueMessage} Archiving it will detach this shared workspace session from those issues, but keep the underlying project workspace available.`);
} else {
blockingReasons.push(linkedIssueMessage);
}
}
if (isSharedWorkspace) {
warnings.push("This shared workspace session points at project workspace infrastructure. Archiving it only removes the session record.");
}
if (runtimeServices.some((service) => service.status !== "stopped")) {
warnings.push(
runtimeServices.length === 1
? "Closing this workspace will stop 1 attached runtime service."
: `Closing this workspace will stop ${runtimeServices.length} attached runtime services.`,
);
}
if (git?.hasDirtyTrackedFiles) {
warnings.push(
git.dirtyEntryCount === 1
? "The workspace has 1 modified tracked file."
: `The workspace has ${git.dirtyEntryCount} modified tracked files.`,
);
}
if (git?.hasUntrackedFiles) {
warnings.push(
git.untrackedEntryCount === 1
? "The workspace has 1 untracked file."
: `The workspace has ${git.untrackedEntryCount} untracked files.`,
);
}
if (git?.aheadCount && git.aheadCount > 0 && git.isMergedIntoBase === false) {
warnings.push(
git.aheadCount === 1
? `This workspace is 1 commit ahead of ${git.baseRef ?? "the base ref"} and is not merged.`
: `This workspace is ${git.aheadCount} commits ahead of ${git.baseRef ?? "the base ref"} and is not merged.`,
);
}
if (git?.behindCount && git.behindCount > 0) {
warnings.push(
git.behindCount === 1
? `This workspace is 1 commit behind ${git.baseRef ?? "the base ref"}.`
: `This workspace is ${git.behindCount} commits behind ${git.baseRef ?? "the base ref"}.`,
);
}
const plannedActions: ExecutionWorkspaceCloseAction[] = [
{
kind: "archive_record",
label: "Archive workspace record",
description: "Keep the execution workspace history and issue linkage, but remove it from active workspace lists.",
command: null,
},
];
if (runtimeServices.some((service) => service.status !== "stopped")) {
plannedActions.push({
kind: "stop_runtime_services",
label: runtimeServices.length === 1 ? "Stop attached runtime service" : "Stop attached runtime services",
description:
runtimeServices.length === 1
? `${runtimeServices[0]?.serviceName ?? "A runtime service"} will be stopped before cleanup.`
: `${runtimeServices.length} runtime services will be stopped before cleanup.`,
command: null,
});
}
const configuredCleanupCommands = [
{
kind: "cleanup_command" as const,
label: "Run workspace cleanup command",
description: "Workspace-specific cleanup runs before teardown.",
command: config?.cleanupCommand ?? null,
},
{
kind: "cleanup_command" as const,
label: "Run project workspace cleanup command",
description: "Project workspace cleanup runs before execution workspace teardown.",
command: projectWorkspace?.cleanupCommand ?? null,
},
];
for (const action of configuredCleanupCommands) {
if (!action.command) continue;
plannedActions.push(action);
}
const teardownCommand = config?.teardownCommand ?? projectPolicy?.workspaceStrategy?.teardownCommand ?? null;
if (teardownCommand) {
plannedActions.push({
kind: "teardown_command",
label: "Run teardown command",
description: "Teardown runs after cleanup commands during workspace close.",
command: teardownCommand,
});
}
if (executionWorkspace.providerType === "git_worktree" && workspacePath) {
plannedActions.push({
kind: "git_worktree_remove",
label: "Remove git worktree",
description: `Paperclip will run git worktree cleanup for ${workspacePath}.`,
command: `git worktree remove --force ${workspacePath}`,
});
}
if (git?.createdByRuntime && executionWorkspace.branchName) {
plannedActions.push({
kind: "git_branch_delete",
label: "Delete runtime-created branch",
description: "Paperclip will try to delete the runtime-created branch after removing the worktree.",
command: `git branch -d ${executionWorkspace.branchName}`,
});
}
if (executionWorkspace.providerType === "local_fs" && git?.createdByRuntime && workspacePath) {
const resolvedWorkspacePath = path.resolve(workspacePath);
const resolvedProjectWorkspacePath = projectWorkspace?.cwd ? path.resolve(projectWorkspace.cwd) : null;
const containsProjectWorkspace = resolvedProjectWorkspacePath
? (
resolvedWorkspacePath === resolvedProjectWorkspacePath ||
resolvedProjectWorkspacePath.startsWith(`${resolvedWorkspacePath}${path.sep}`)
)
: false;
if (containsProjectWorkspace) {
warnings.push(`Paperclip will archive this workspace but keep "${workspacePath}" because it contains the project workspace.`);
} else {
plannedActions.push({
kind: "remove_local_directory",
label: "Remove runtime-created directory",
description: `Paperclip will remove the runtime-created directory at ${workspacePath}.`,
command: `rm -rf ${workspacePath}`,
});
}
}
const state =
blockingReasons.length > 0
? "blocked"
: warnings.length > 0
? "ready_with_warnings"
: "ready";
return {
workspaceId: workspace.id,
state,
blockingReasons,
warnings,
linkedIssues: linkedIssueSummaries,
plannedActions,
isDestructiveCloseAllowed: blockingReasons.length === 0,
isSharedWorkspace,
isProjectPrimaryWorkspace,
git,
runtimeServices,
};
},
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 { 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, ExecutionWorkspace, ExecutionWorkspaceConfig } from "@paperclipai/shared";
import {
agents,
agentRuntimeState,
@ -37,10 +37,12 @@ import {
persistAdapterManagedRuntimeServices,
realizeExecutionWorkspace,
releaseRuntimeServicesForRun,
type ExecutionWorkspaceInput,
type RealizedExecutionWorkspace,
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 +78,87 @@ const SESSIONED_LOCAL_ADAPTERS = new Set([
"pi_local",
]);
export function applyPersistedExecutionWorkspaceConfig(input: {
config: Record<string, unknown>;
workspaceConfig: ExecutionWorkspaceConfig | null;
mode: ReturnType<typeof resolveExecutionWorkspaceMode>;
}) {
const nextConfig = { ...input.config };
if (input.mode !== "agent_default") {
if (input.workspaceConfig?.workspaceRuntime === null) {
delete nextConfig.workspaceRuntime;
} else if (input.workspaceConfig?.workspaceRuntime) {
nextConfig.workspaceRuntime = { ...input.workspaceConfig.workspaceRuntime };
}
}
if (input.workspaceConfig && 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;
}
export function stripWorkspaceRuntimeFromExecutionRunConfig(config: Record<string, unknown>) {
const nextConfig = { ...config };
delete nextConfig.workspaceRuntime;
return nextConfig;
}
export function buildRealizedExecutionWorkspaceFromPersisted(input: {
base: ExecutionWorkspaceInput;
workspace: ExecutionWorkspace;
}): RealizedExecutionWorkspace | null {
const cwd = readNonEmptyString(input.workspace.cwd) ?? readNonEmptyString(input.workspace.providerRef);
if (!cwd) {
return null;
}
const strategy = input.workspace.strategyType === "git_worktree" ? "git_worktree" : "project_primary";
return {
baseCwd: input.base.baseCwd,
source: input.workspace.mode === "shared_workspace" ? "project_primary" : "task_session",
projectId: input.workspace.projectId ?? input.base.projectId,
workspaceId: input.workspace.projectWorkspaceId ?? input.base.workspaceId,
repoUrl: input.workspace.repoUrl ?? input.base.repoUrl,
repoRef: input.workspace.baseRef ?? input.base.repoRef,
strategy,
cwd,
branchName: input.workspace.branchName ?? null,
worktreePath: strategy === "git_worktree" ? (readNonEmptyString(input.workspace.providerRef) ?? cwd) : null,
warnings: [],
created: false,
};
}
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;
@ -2030,7 +2113,7 @@ export function heartbeatService(db: Db) {
(explicitResumeSessionDisplayId ? { sessionId: explicitResumeSessionDisplayId } : null) ??
normalizeSessionParams(sessionCodec.deserialize(taskSessionForRun?.sessionParamsJson ?? null));
const config = parseObject(agent.adapterConfig);
const executionWorkspaceMode = resolveExecutionWorkspaceMode({
const requestedExecutionWorkspaceMode = resolveExecutionWorkspaceMode({
projectPolicy: projectExecutionWorkspacePolicy,
issueSettings: issueExecutionWorkspaceSettings,
legacyUseProjectWorkspace: issueAssigneeOverrides?.useProjectWorkspace ?? null,
@ -2039,27 +2122,8 @@ export function heartbeatService(db: Db) {
agent,
context,
previousSessionParams,
{ useProjectWorkspace: executionWorkspaceMode !== "agent_default" },
{ useProjectWorkspace: requestedExecutionWorkspaceMode !== "agent_default" },
);
const workspaceManagedConfig = buildExecutionWorkspaceAdapterConfig({
agentConfig: config,
projectPolicy: projectExecutionWorkspacePolicy,
issueSettings: issueExecutionWorkspaceSettings,
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,36 +2137,90 @@ export function heartbeatService(db: Db) {
: null;
const existingExecutionWorkspace =
issueRef?.executionWorkspaceId ? await executionWorkspacesSvc.getById(issueRef.executionWorkspaceId) : null;
const shouldReuseExisting =
issueRef?.executionWorkspacePreference === "reuse_existing" &&
existingExecutionWorkspace &&
existingExecutionWorkspace.status !== "archived";
const persistedExecutionWorkspaceMode = shouldReuseExisting && existingExecutionWorkspace
? issueExecutionWorkspaceModeForPersistedWorkspace(existingExecutionWorkspace.mode)
: null;
const effectiveExecutionWorkspaceMode: ReturnType<typeof resolveExecutionWorkspaceMode> =
persistedExecutionWorkspaceMode === "isolated_workspace" ||
persistedExecutionWorkspaceMode === "operator_branch" ||
persistedExecutionWorkspaceMode === "agent_default"
? persistedExecutionWorkspaceMode
: requestedExecutionWorkspaceMode;
const workspaceManagedConfig = shouldReuseExisting
? { ...config }
: buildExecutionWorkspaceAdapterConfig({
agentConfig: config,
projectPolicy: projectExecutionWorkspacePolicy,
issueSettings: issueExecutionWorkspaceSettings,
mode: requestedExecutionWorkspaceMode,
legacyUseProjectWorkspace: issueAssigneeOverrides?.useProjectWorkspace ?? null,
});
const persistedWorkspaceManagedConfig = applyPersistedExecutionWorkspaceConfig({
config: workspaceManagedConfig,
workspaceConfig: existingExecutionWorkspace?.config ?? null,
mode: effectiveExecutionWorkspaceMode,
});
const mergedConfig = issueAssigneeOverrides?.adapterConfig
? { ...persistedWorkspaceManagedConfig, ...issueAssigneeOverrides.adapterConfig }
: persistedWorkspaceManagedConfig;
const configSnapshot = buildExecutionWorkspaceConfigSnapshot(mergedConfig);
const executionRunConfig = stripWorkspaceRuntimeFromExecutionRunConfig(mergedConfig);
const { config: resolvedConfig, secretKeys } = await secretsSvc.resolveAdapterConfigForRuntime(
agent.companyId,
executionRunConfig,
);
const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId);
const runtimeConfig = {
...resolvedConfig,
paperclipRuntimeSkills: runtimeSkillEntries,
};
const workspaceOperationRecorder = workspaceOperationsSvc.createRecorder({
companyId: agent.companyId,
heartbeatRunId: run.id,
executionWorkspaceId: existingExecutionWorkspace?.id ?? null,
});
const executionWorkspace = await realizeExecutionWorkspace({
base: {
baseCwd: resolvedWorkspace.cwd,
source: resolvedWorkspace.source,
projectId: resolvedWorkspace.projectId,
workspaceId: resolvedWorkspace.workspaceId,
repoUrl: resolvedWorkspace.repoUrl,
repoRef: resolvedWorkspace.repoRef,
},
config: runtimeConfig,
issue: issueRef,
agent: {
id: agent.id,
name: agent.name,
companyId: agent.companyId,
},
recorder: workspaceOperationRecorder,
});
const executionWorkspaceBase = {
baseCwd: resolvedWorkspace.cwd,
source: resolvedWorkspace.source,
projectId: resolvedWorkspace.projectId,
workspaceId: resolvedWorkspace.workspaceId,
repoUrl: resolvedWorkspace.repoUrl,
repoRef: resolvedWorkspace.repoRef,
} satisfies ExecutionWorkspaceInput;
const reusedExecutionWorkspace = shouldReuseExisting && existingExecutionWorkspace
? buildRealizedExecutionWorkspaceFromPersisted({
base: executionWorkspaceBase,
workspace: existingExecutionWorkspace,
})
: null;
const executionWorkspace = reusedExecutionWorkspace ?? await realizeExecutionWorkspace({
base: executionWorkspaceBase,
config: runtimeConfig,
issue: issueRef,
agent: {
id: agent.id,
name: agent.name,
companyId: agent.companyId,
},
recorder: workspaceOperationRecorder,
});
const resolvedProjectId = executionWorkspace.projectId ?? issueRef?.projectId ?? executionProjectId ?? null;
const resolvedProjectWorkspaceId = issueRef?.projectWorkspaceId ?? resolvedWorkspace.workspaceId ?? null;
const shouldReuseExisting =
issueRef?.executionWorkspacePreference === "reuse_existing" &&
existingExecutionWorkspace &&
existingExecutionWorkspace.status !== "archived";
let persistedExecutionWorkspace = null;
const nextExecutionWorkspaceMetadataBase = {
...(existingExecutionWorkspace?.metadata ?? {}),
source: executionWorkspace.source,
createdByRuntime: executionWorkspace.created,
} as Record<string, unknown>;
const nextExecutionWorkspaceMetadata = shouldReuseExisting
? nextExecutionWorkspaceMetadataBase
: configSnapshot
? mergeExecutionWorkspaceConfig(nextExecutionWorkspaceMetadataBase, configSnapshot)
: nextExecutionWorkspaceMetadataBase;
try {
persistedExecutionWorkspace = shouldReuseExisting && existingExecutionWorkspace
? await executionWorkspacesSvc.update(existingExecutionWorkspace.id, {
@ -2114,11 +2232,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({
@ -2127,11 +2241,11 @@ export function heartbeatService(db: Db) {
projectWorkspaceId: resolvedProjectWorkspaceId,
sourceIssueId: issueRef?.id ?? null,
mode:
executionWorkspaceMode === "isolated_workspace"
requestedExecutionWorkspaceMode === "isolated_workspace"
? "isolated_workspace"
: executionWorkspaceMode === "operator_branch"
: requestedExecutionWorkspaceMode === "operator_branch"
? "operator_branch"
: executionWorkspaceMode === "agent_default"
: requestedExecutionWorkspaceMode === "agent_default"
? "adapter_managed"
: "shared_workspace",
strategyType: executionWorkspace.strategy === "git_worktree" ? "git_worktree" : "project_primary",
@ -2145,10 +2259,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 +2286,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) {
@ -2208,8 +2320,8 @@ export function heartbeatService(db: Db) {
const nextIssueWorkspaceMode = issueExecutionWorkspaceModeForPersistedWorkspace(persistedExecutionWorkspace.mode);
const shouldSwitchIssueToExistingWorkspace =
issueRef?.executionWorkspacePreference === "reuse_existing" ||
executionWorkspaceMode === "isolated_workspace" ||
executionWorkspaceMode === "operator_branch";
requestedExecutionWorkspaceMode === "isolated_workspace" ||
requestedExecutionWorkspaceMode === "operator_branch";
const nextIssuePatch: Record<string, unknown> = {};
if (issueRef?.executionWorkspaceId !== persistedExecutionWorkspace.id) {
nextIssuePatch.executionWorkspaceId = persistedExecutionWorkspace.id;
@ -2262,7 +2374,7 @@ export function heartbeatService(db: Db) {
context.paperclipWorkspace = {
cwd: executionWorkspace.cwd,
source: executionWorkspace.source,
mode: executionWorkspaceMode,
mode: effectiveExecutionWorkspaceMode,
strategy: executionWorkspace.strategy,
projectId: executionWorkspace.projectId,
workspaceId: executionWorkspace.workspaceId,

View file

@ -28,5 +28,5 @@ export { workProductService } from "./work-products.js";
export { logActivity, type LogActivityInput } from "./activity-log.js";
export { notifyHireApproved, type NotifyHireApprovedInput } from "./hire-hook.js";
export { publishLiveEvent, subscribeCompanyLiveEvents } from "./live-events.js";
export { reconcilePersistedRuntimeServicesOnStartup } from "./workspace-runtime.js";
export { reconcilePersistedRuntimeServicesOnStartup, restartDesiredRuntimeServicesOnStartup } from "./workspace-runtime.js";
export { createStorageServiceFromConfig, getStorageService } from "../storage/index.js";

View file

@ -11,6 +11,7 @@ import {
heartbeatRuns,
executionWorkspaces,
issueAttachments,
issueInboxArchives,
issueLabels,
issueComments,
issueDocuments,
@ -25,6 +26,7 @@ import { conflict, notFound, unprocessable } from "../errors.js";
import {
defaultIssueExecutionWorkspaceSettingsForProject,
gateProjectExecutionWorkspacePolicy,
issueExecutionWorkspaceModeForPersistedWorkspace,
parseProjectExecutionWorkspacePolicy,
} from "./execution-workspace-policy.js";
import { instanceSettingsService } from "./instance-settings.js";
@ -66,8 +68,10 @@ export interface IssueFilters {
participantAgentId?: string;
assigneeUserId?: string;
touchedByUserId?: string;
inboxArchivedByUserId?: string;
unreadForUserId?: string;
projectId?: string;
executionWorkspaceId?: string;
parentId?: string;
labelId?: string;
originKind?: string;
@ -102,6 +106,11 @@ type IssueUserContextInput = {
updatedAt: Date | string;
};
type ProjectGoalReader = Pick<Db, "select">;
type DbReader = Pick<Db, "select">;
type IssueCreateInput = Omit<typeof issues.$inferInsert, "companyId"> & {
labelIds?: string[];
inheritExecutionWorkspaceFromIssueId?: string | null;
};
function sameRunLock(checkoutRunId: string | null, actorRunId: string | null) {
if (actorRunId) return checkoutRunId === actorRunId;
@ -128,6 +137,28 @@ async function getProjectDefaultGoalId(
return row?.goalId ?? null;
}
async function getWorkspaceInheritanceIssue(
db: DbReader,
companyId: string,
issueId: string,
) {
const issue = await db
.select({
id: issues.id,
projectId: issues.projectId,
projectWorkspaceId: issues.projectWorkspaceId,
executionWorkspaceId: issues.executionWorkspaceId,
executionWorkspaceSettings: issues.executionWorkspaceSettings,
})
.from(issues)
.where(and(eq(issues.id, issueId), eq(issues.companyId, companyId)))
.then((rows) => rows[0] ?? null);
if (!issue) {
throw notFound("Workspace inheritance issue not found");
}
return issue;
}
function touchedByUserCondition(companyId: string, userId: string) {
return sql<boolean>`
(
@ -212,6 +243,36 @@ function myLastTouchAtExpr(companyId: string, userId: string) {
`;
}
function lastExternalCommentAtExpr(companyId: string, userId: string) {
return sql<Date | null>`
(
SELECT MAX(${issueComments.createdAt})
FROM ${issueComments}
WHERE ${issueComments.issueId} = ${issues.id}
AND ${issueComments.companyId} = ${companyId}
AND (
${issueComments.authorUserId} IS NULL
OR ${issueComments.authorUserId} <> ${userId}
)
)
`;
}
function issueLastActivityAtExpr(companyId: string, userId: string) {
const lastExternalCommentAt = lastExternalCommentAtExpr(companyId, userId);
const myLastTouchAt = myLastTouchAtExpr(companyId, userId);
return sql<Date>`
COALESCE(
${lastExternalCommentAt},
CASE
WHEN ${issues.updatedAt} > COALESCE(${myLastTouchAt}, to_timestamp(0))
THEN ${issues.updatedAt}
ELSE to_timestamp(0)
END
)
`;
}
function unreadForUserCondition(companyId: string, userId: string) {
const touchedCondition = touchedByUserCondition(companyId, userId);
const myLastTouchAt = myLastTouchAtExpr(companyId, userId);
@ -233,6 +294,20 @@ function unreadForUserCondition(companyId: string, userId: string) {
`;
}
function inboxVisibleForUserCondition(companyId: string, userId: string) {
const issueLastActivityAt = issueLastActivityAtExpr(companyId, userId);
return sql<boolean>`
NOT EXISTS (
SELECT 1
FROM ${issueInboxArchives}
WHERE ${issueInboxArchives.issueId} = ${issues.id}
AND ${issueInboxArchives.companyId} = ${companyId}
AND ${issueInboxArchives.userId} = ${userId}
AND ${issueInboxArchives.archivedAt} >= ${issueLastActivityAt}
)
`;
}
/** Named entities commonly emitted in saved issue bodies; unknown `&name;` sequences are left unchanged. */
const WELL_KNOWN_NAMED_HTML_ENTITIES: Readonly<Record<string, string>> = {
amp: "&",
@ -440,8 +515,13 @@ export function issueService(db: Db) {
}
}
async function assertValidProjectWorkspace(companyId: string, projectId: string | null | undefined, projectWorkspaceId: string) {
const workspace = await db
async function assertValidProjectWorkspace(
companyId: string,
projectId: string | null | undefined,
projectWorkspaceId: string,
dbOrTx: DbReader = db,
) {
const workspace = await dbOrTx
.select({
id: projectWorkspaces.id,
companyId: projectWorkspaces.companyId,
@ -457,8 +537,13 @@ export function issueService(db: Db) {
}
}
async function assertValidExecutionWorkspace(companyId: string, projectId: string | null | undefined, executionWorkspaceId: string) {
const workspace = await db
async function assertValidExecutionWorkspace(
companyId: string,
projectId: string | null | undefined,
executionWorkspaceId: string,
dbOrTx: DbReader = db,
) {
const workspace = await dbOrTx
.select({
id: executionWorkspaces.id,
companyId: executionWorkspaces.companyId,
@ -556,8 +641,9 @@ export function issueService(db: Db) {
list: async (companyId: string, filters?: IssueFilters) => {
const conditions = [eq(issues.companyId, companyId)];
const touchedByUserId = filters?.touchedByUserId?.trim() || undefined;
const inboxArchivedByUserId = filters?.inboxArchivedByUserId?.trim() || undefined;
const unreadForUserId = filters?.unreadForUserId?.trim() || undefined;
const contextUserId = unreadForUserId ?? touchedByUserId;
const contextUserId = unreadForUserId ?? touchedByUserId ?? inboxArchivedByUserId;
const rawSearch = filters?.q?.trim() ?? "";
const hasSearch = rawSearch.length > 0;
const escapedSearch = hasSearch ? escapeLikePattern(rawSearch) : "";
@ -593,10 +679,16 @@ export function issueService(db: Db) {
if (touchedByUserId) {
conditions.push(touchedByUserCondition(companyId, touchedByUserId));
}
if (inboxArchivedByUserId) {
conditions.push(inboxVisibleForUserCondition(companyId, inboxArchivedByUserId));
}
if (unreadForUserId) {
conditions.push(unreadForUserCondition(companyId, unreadForUserId));
}
if (filters?.projectId) conditions.push(eq(issues.projectId, filters.projectId));
if (filters?.executionWorkspaceId) {
conditions.push(eq(issues.executionWorkspaceId, filters.executionWorkspaceId));
}
if (filters?.parentId) conditions.push(eq(issues.parentId, filters.parentId));
if (filters?.originKind) conditions.push(eq(issues.originKind, filters.originKind));
if (filters?.originId) conditions.push(eq(issues.originId, filters.originId));
@ -741,6 +833,56 @@ export function issueService(db: Db) {
return row;
},
markUnread: async (companyId: string, issueId: string, userId: string) => {
const deleted = await db
.delete(issueReadStates)
.where(
and(
eq(issueReadStates.companyId, companyId),
eq(issueReadStates.issueId, issueId),
eq(issueReadStates.userId, userId),
),
)
.returning();
return deleted.length > 0;
},
archiveInbox: async (companyId: string, issueId: string, userId: string, archivedAt: Date = new Date()) => {
const now = new Date();
const [row] = await db
.insert(issueInboxArchives)
.values({
companyId,
issueId,
userId,
archivedAt,
updatedAt: now,
})
.onConflictDoUpdate({
target: [issueInboxArchives.companyId, issueInboxArchives.issueId, issueInboxArchives.userId],
set: {
archivedAt,
updatedAt: now,
},
})
.returning();
return row;
},
unarchiveInbox: async (companyId: string, issueId: string, userId: string) => {
const [row] = await db
.delete(issueInboxArchives)
.where(
and(
eq(issueInboxArchives.companyId, companyId),
eq(issueInboxArchives.issueId, issueId),
eq(issueInboxArchives.userId, userId),
),
)
.returning();
return row ?? null;
},
getById: async (id: string) => {
const row = await db
.select()
@ -765,9 +907,9 @@ export function issueService(db: Db) {
create: async (
companyId: string,
data: Omit<typeof issues.$inferInsert, "companyId"> & { labelIds?: string[] },
data: IssueCreateInput,
) => {
const { labelIds: inputLabelIds, ...issueData } = data;
const { labelIds: inputLabelIds, inheritExecutionWorkspaceFromIssueId, ...issueData } = data;
const isolatedWorkspacesEnabled = (await instanceSettings.getExperimental()).enableIsolatedWorkspaces;
if (!isolatedWorkspacesEnabled) {
delete issueData.executionWorkspaceId;
@ -783,21 +925,55 @@ export function issueService(db: Db) {
if (data.assigneeUserId) {
await assertAssignableUser(companyId, data.assigneeUserId);
}
if (data.projectWorkspaceId) {
await assertValidProjectWorkspace(companyId, data.projectId, data.projectWorkspaceId);
}
if (data.executionWorkspaceId) {
await assertValidExecutionWorkspace(companyId, data.projectId, data.executionWorkspaceId);
}
if (data.status === "in_progress" && !data.assigneeAgentId && !data.assigneeUserId) {
throw unprocessable("in_progress issues require an assignee");
}
return db.transaction(async (tx) => {
const defaultCompanyGoal = await getDefaultCompanyGoal(tx, companyId);
const projectGoalId = await getProjectDefaultGoalId(tx, companyId, issueData.projectId);
let projectWorkspaceId = issueData.projectWorkspaceId ?? null;
let executionWorkspaceId = issueData.executionWorkspaceId ?? null;
let executionWorkspacePreference = issueData.executionWorkspacePreference ?? null;
let executionWorkspaceSettings =
(issueData.executionWorkspaceSettings as Record<string, unknown> | null | undefined) ?? null;
if (executionWorkspaceSettings == null && issueData.projectId) {
const workspaceInheritanceIssueId = inheritExecutionWorkspaceFromIssueId ?? issueData.parentId ?? null;
const hasExplicitExecutionWorkspaceOverride =
issueData.executionWorkspaceId !== undefined ||
issueData.executionWorkspacePreference !== undefined ||
issueData.executionWorkspaceSettings !== undefined;
if (workspaceInheritanceIssueId) {
const workspaceSource = await getWorkspaceInheritanceIssue(tx, companyId, workspaceInheritanceIssueId);
if (projectWorkspaceId == null && workspaceSource.projectWorkspaceId) {
projectWorkspaceId = workspaceSource.projectWorkspaceId;
}
if (
isolatedWorkspacesEnabled &&
!hasExplicitExecutionWorkspaceOverride &&
workspaceSource.executionWorkspaceId
) {
const sourceWorkspace = await tx
.select({
id: executionWorkspaces.id,
mode: executionWorkspaces.mode,
})
.from(executionWorkspaces)
.where(eq(executionWorkspaces.id, workspaceSource.executionWorkspaceId))
.then((rows) => rows[0] ?? null);
if (sourceWorkspace) {
executionWorkspaceId = sourceWorkspace.id;
executionWorkspacePreference = "reuse_existing";
executionWorkspaceSettings = {
...((workspaceSource.executionWorkspaceSettings as Record<string, unknown> | null | undefined) ?? {}),
mode: issueExecutionWorkspaceModeForPersistedWorkspace(sourceWorkspace.mode),
};
}
}
}
if (
executionWorkspaceSettings == null &&
executionWorkspaceId == null &&
issueData.projectId
) {
const project = await tx
.select({ executionWorkspacePolicy: projects.executionWorkspacePolicy })
.from(projects)
@ -811,7 +987,6 @@ export function issueService(db: Db) {
),
) as Record<string, unknown> | null;
}
let projectWorkspaceId = issueData.projectWorkspaceId ?? null;
if (!projectWorkspaceId && issueData.projectId) {
const project = await tx
.select({
@ -831,6 +1006,12 @@ export function issueService(db: Db) {
.then((rows) => rows[0]?.id ?? null);
}
}
if (projectWorkspaceId) {
await assertValidProjectWorkspace(companyId, issueData.projectId, projectWorkspaceId, tx);
}
if (executionWorkspaceId) {
await assertValidExecutionWorkspace(companyId, issueData.projectId, executionWorkspaceId, tx);
}
const [company] = await tx
.update(companies)
.set({ issueCounter: sql`${companies.issueCounter} + 1` })
@ -850,6 +1031,8 @@ export function issueService(db: Db) {
defaultGoalId: defaultCompanyGoal?.id ?? null,
}),
...(projectWorkspaceId ? { projectWorkspaceId } : {}),
...(executionWorkspaceId ? { executionWorkspaceId } : {}),
...(executionWorkspacePreference ? { executionWorkspacePreference } : {}),
...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}),
companyId,
issueNumber,

View file

@ -0,0 +1,302 @@
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { promisify } from "node:util";
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
const execFileAsync = promisify(execFile);
export interface LocalServiceRegistryRecord {
version: 1;
serviceKey: string;
profileKind: string;
serviceName: string;
command: string;
cwd: string;
envFingerprint: string;
port: number | null;
url: string | null;
pid: number;
processGroupId: number | null;
provider: "local_process";
runtimeServiceId: string | null;
reuseKey: string | null;
startedAt: string;
lastSeenAt: string;
metadata: Record<string, unknown> | null;
}
export interface LocalServiceIdentityInput {
profileKind: string;
serviceName: string;
cwd: string;
command: string;
envFingerprint: string;
port: number | null;
scope: Record<string, unknown> | null;
}
function stableStringify(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
}
if (value && typeof value === "object") {
const rec = value as Record<string, unknown>;
return `{${Object.keys(rec).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(rec[key])}`).join(",")}}`;
}
return JSON.stringify(value);
}
function sanitizeServiceKeySegment(value: string, fallback: string): string {
const normalized = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-+|-+$/g, "");
return normalized || fallback;
}
function getRuntimeServicesDir() {
return path.resolve(resolvePaperclipInstanceRoot(), "runtime-services");
}
function getRuntimeServiceRegistryPath(serviceKey: string) {
return path.resolve(getRuntimeServicesDir(), `${serviceKey}.json`);
}
function normalizeRegistryRecord(raw: unknown): LocalServiceRegistryRecord | null {
if (!raw || typeof raw !== "object") return null;
const rec = raw as Record<string, unknown>;
if (
rec.version !== 1 ||
typeof rec.serviceKey !== "string" ||
typeof rec.profileKind !== "string" ||
typeof rec.serviceName !== "string" ||
typeof rec.command !== "string" ||
typeof rec.cwd !== "string" ||
typeof rec.envFingerprint !== "string" ||
typeof rec.pid !== "number"
) {
return null;
}
return {
version: 1,
serviceKey: rec.serviceKey,
profileKind: rec.profileKind,
serviceName: rec.serviceName,
command: rec.command,
cwd: rec.cwd,
envFingerprint: rec.envFingerprint,
port: typeof rec.port === "number" ? rec.port : null,
url: typeof rec.url === "string" ? rec.url : null,
pid: rec.pid,
processGroupId: typeof rec.processGroupId === "number" ? rec.processGroupId : null,
provider: "local_process",
runtimeServiceId: typeof rec.runtimeServiceId === "string" ? rec.runtimeServiceId : null,
reuseKey: typeof rec.reuseKey === "string" ? rec.reuseKey : null,
startedAt: typeof rec.startedAt === "string" ? rec.startedAt : new Date().toISOString(),
lastSeenAt: typeof rec.lastSeenAt === "string" ? rec.lastSeenAt : new Date().toISOString(),
metadata:
rec.metadata && typeof rec.metadata === "object" && !Array.isArray(rec.metadata)
? (rec.metadata as Record<string, unknown>)
: null,
};
}
async function safeReadRegistryRecord(filePath: string) {
try {
const raw = JSON.parse(await fs.readFile(filePath, "utf8")) as unknown;
return normalizeRegistryRecord(raw);
} catch {
return null;
}
}
export function createLocalServiceKey(input: LocalServiceIdentityInput) {
const digest = createHash("sha256")
.update(
stableStringify({
profileKind: input.profileKind,
serviceName: input.serviceName,
cwd: path.resolve(input.cwd),
command: input.command,
envFingerprint: input.envFingerprint,
port: input.port,
scope: input.scope ?? null,
}),
)
.digest("hex")
.slice(0, 24);
return `${sanitizeServiceKeySegment(input.profileKind, "service")}-${sanitizeServiceKeySegment(input.serviceName, "service")}-${digest}`;
}
export async function writeLocalServiceRegistryRecord(record: LocalServiceRegistryRecord) {
await fs.mkdir(getRuntimeServicesDir(), { recursive: true });
await fs.writeFile(
getRuntimeServiceRegistryPath(record.serviceKey),
`${JSON.stringify(record, null, 2)}\n`,
"utf8",
);
}
export async function removeLocalServiceRegistryRecord(serviceKey: string) {
await fs.rm(getRuntimeServiceRegistryPath(serviceKey), { force: true });
}
export async function readLocalServiceRegistryRecord(serviceKey: string) {
return await safeReadRegistryRecord(getRuntimeServiceRegistryPath(serviceKey));
}
export async function listLocalServiceRegistryRecords(filter?: {
profileKind?: string;
metadata?: Record<string, unknown>;
}) {
try {
const entries = await fs.readdir(getRuntimeServicesDir(), { withFileTypes: true });
const records = await Promise.all(
entries
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
.map((entry) => safeReadRegistryRecord(path.resolve(getRuntimeServicesDir(), entry.name))),
);
return records
.filter((record): record is LocalServiceRegistryRecord => record !== null)
.filter((record) => {
if (filter?.profileKind && record.profileKind !== filter.profileKind) return false;
if (!filter?.metadata) return true;
return Object.entries(filter.metadata).every(([key, value]) => record.metadata?.[key] === value);
})
.sort((left, right) => left.serviceKey.localeCompare(right.serviceKey));
} catch {
return [];
}
}
export async function findLocalServiceRegistryRecordByRuntimeServiceId(input: {
runtimeServiceId: string;
profileKind?: string;
}) {
const records = await listLocalServiceRegistryRecords(
input.profileKind ? { profileKind: input.profileKind } : undefined,
);
return records.find((record) => record.runtimeServiceId === input.runtimeServiceId) ?? null;
}
export function isPidAlive(pid: number) {
if (!Number.isInteger(pid) || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function isLikelyMatchingCommand(record: LocalServiceRegistryRecord) {
if (process.platform === "win32") return true;
try {
const { stdout } = await execFileAsync("ps", ["-o", "command=", "-p", String(record.pid)]);
const commandLine = stdout.trim();
if (!commandLine) return false;
return commandLine.includes(record.command) || commandLine.includes(record.serviceName);
} catch {
return true;
}
}
export async function findAdoptableLocalService(input: {
serviceKey: string;
command?: string | null;
cwd?: string | null;
envFingerprint?: string | null;
port?: number | null;
}) {
const record = await readLocalServiceRegistryRecord(input.serviceKey);
if (!record) return null;
if (!isPidAlive(record.pid)) {
await removeLocalServiceRegistryRecord(input.serviceKey);
return null;
}
if (!(await isLikelyMatchingCommand(record))) {
await removeLocalServiceRegistryRecord(input.serviceKey);
return null;
}
if (input.command && record.command !== input.command) return null;
if (input.cwd && path.resolve(record.cwd) !== path.resolve(input.cwd)) return null;
if (input.envFingerprint && record.envFingerprint !== input.envFingerprint) return null;
if (input.port !== undefined && input.port !== null && record.port !== input.port) return null;
return record;
}
export async function touchLocalServiceRegistryRecord(
serviceKey: string,
patch?: Partial<Omit<LocalServiceRegistryRecord, "serviceKey" | "version">>,
) {
const existing = await readLocalServiceRegistryRecord(serviceKey);
if (!existing) return null;
const next: LocalServiceRegistryRecord = {
...existing,
...patch,
version: 1,
serviceKey,
lastSeenAt: patch?.lastSeenAt ?? new Date().toISOString(),
};
await writeLocalServiceRegistryRecord(next);
return next;
}
export async function terminateLocalService(
record: Pick<LocalServiceRegistryRecord, "pid" | "processGroupId">,
opts?: { signal?: NodeJS.Signals; forceAfterMs?: number },
) {
const signal = opts?.signal ?? "SIGTERM";
const targetProcessGroup = process.platform !== "win32" && record.processGroupId && record.processGroupId > 0;
try {
if (targetProcessGroup) {
process.kill(-record.processGroupId!, signal);
} else {
process.kill(record.pid, signal);
}
} catch {
return;
}
const deadline = Date.now() + (opts?.forceAfterMs ?? 2_000);
while (Date.now() < deadline) {
if (!isPidAlive(record.pid)) {
return;
}
await delay(100);
}
if (!isPidAlive(record.pid)) return;
try {
if (targetProcessGroup) {
process.kill(-record.processGroupId!, "SIGKILL");
} else {
process.kill(record.pid, "SIGKILL");
}
} catch {
// Ignore cleanup races.
}
}
export async function readLocalServicePortOwner(port: number) {
if (!Number.isInteger(port) || port <= 0 || process.platform === "win32") return null;
try {
const { stdout } = await execFileAsync("lsof", ["-nPiTCP", `:${port}`, "-sTCP:LISTEN", "-t"]);
const firstPid = stdout
.split("\n")
.map((line) => Number.parseInt(line.trim(), 10))
.find((value) => Number.isInteger(value) && value > 0);
return firstPid ?? null;
} catch {
return null;
}
}

View file

@ -0,0 +1,59 @@
import type { ProjectWorkspaceRuntimeConfig } from "@paperclipai/shared";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function cloneRecord(value: unknown): Record<string, unknown> | null {
return isRecord(value) ? { ...value } : null;
}
function readDesiredState(value: unknown): ProjectWorkspaceRuntimeConfig["desiredState"] {
return value === "running" || value === "stopped" ? value : null;
}
export function readProjectWorkspaceRuntimeConfig(
metadata: Record<string, unknown> | null | undefined,
): ProjectWorkspaceRuntimeConfig | null {
const raw = isRecord(metadata?.runtimeConfig) ? metadata.runtimeConfig : null;
if (!raw) return null;
const config: ProjectWorkspaceRuntimeConfig = {
workspaceRuntime: cloneRecord(raw.workspaceRuntime),
desiredState: readDesiredState(raw.desiredState),
};
const hasConfig = config.workspaceRuntime !== null || config.desiredState !== null;
return hasConfig ? config : null;
}
export function mergeProjectWorkspaceRuntimeConfig(
metadata: Record<string, unknown> | null | undefined,
patch: Partial<ProjectWorkspaceRuntimeConfig> | null,
): Record<string, unknown> | null {
const nextMetadata = isRecord(metadata) ? { ...metadata } : {};
const current = readProjectWorkspaceRuntimeConfig(metadata) ?? {
workspaceRuntime: null,
desiredState: null,
};
if (patch === null) {
delete nextMetadata.runtimeConfig;
return Object.keys(nextMetadata).length > 0 ? nextMetadata : null;
}
const nextConfig: ProjectWorkspaceRuntimeConfig = {
workspaceRuntime:
patch.workspaceRuntime !== undefined ? cloneRecord(patch.workspaceRuntime) : current.workspaceRuntime,
desiredState:
patch.desiredState !== undefined ? readDesiredState(patch.desiredState) : current.desiredState,
};
if (nextConfig.workspaceRuntime === null && nextConfig.desiredState === null) {
delete nextMetadata.runtimeConfig;
} else {
nextMetadata.runtimeConfig = nextConfig;
}
return Object.keys(nextMetadata).length > 0 ? nextMetadata : null;
}

View file

@ -9,11 +9,13 @@ import {
type ProjectCodebase,
type ProjectExecutionWorkspacePolicy,
type ProjectGoalRef,
type ProjectWorkspaceRuntimeConfig,
type ProjectWorkspace,
type WorkspaceRuntimeService,
} from "@paperclipai/shared";
import { listWorkspaceRuntimeServicesForProjectWorkspaces } from "./workspace-runtime.js";
import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
import { mergeProjectWorkspaceRuntimeConfig, readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js";
import { resolveManagedProjectWorkspaceDir } from "../home-paths.js";
type ProjectRow = typeof projects.$inferSelect;
@ -34,6 +36,7 @@ type CreateWorkspaceInput = {
remoteWorkspaceRef?: string | null;
sharedWorkspaceKey?: string | null;
metadata?: Record<string, unknown> | null;
runtimeConfig?: Partial<ProjectWorkspaceRuntimeConfig> | null;
isPrimary?: boolean;
};
type UpdateWorkspaceInput = Partial<CreateWorkspaceInput>;
@ -149,6 +152,7 @@ function toWorkspace(
remoteWorkspaceRef: row.remoteWorkspaceRef ?? null,
sharedWorkspaceKey: row.sharedWorkspaceKey ?? null,
metadata: (row.metadata as Record<string, unknown> | null) ?? null,
runtimeConfig: readProjectWorkspaceRuntimeConfig((row.metadata as Record<string, unknown> | null) ?? null),
isPrimary: row.isPrimary,
runtimeServices,
createdAt: row.createdAt,
@ -611,7 +615,13 @@ export function projectService(db: Db) {
remoteProvider: readNonEmptyString(data.remoteProvider),
remoteWorkspaceRef,
sharedWorkspaceKey: readNonEmptyString(data.sharedWorkspaceKey),
metadata: (data.metadata as Record<string, unknown> | null | undefined) ?? null,
metadata:
data.runtimeConfig !== undefined
? mergeProjectWorkspaceRuntimeConfig(
(data.metadata as Record<string, unknown> | null | undefined) ?? null,
data.runtimeConfig ?? null,
)
: (data.metadata as Record<string, unknown> | null | undefined) ?? null,
isPrimary: shouldBePrimary,
})
.returning()
@ -681,7 +691,17 @@ export function projectService(db: Db) {
if (data.remoteProvider !== undefined) patch.remoteProvider = readNonEmptyString(data.remoteProvider);
if (data.remoteWorkspaceRef !== undefined) patch.remoteWorkspaceRef = nextRemoteWorkspaceRef;
if (data.sharedWorkspaceKey !== undefined) patch.sharedWorkspaceKey = readNonEmptyString(data.sharedWorkspaceKey);
if (data.metadata !== undefined) patch.metadata = data.metadata;
if (data.metadata !== undefined || data.runtimeConfig !== undefined) {
patch.metadata =
data.runtimeConfig !== undefined
? mergeProjectWorkspaceRuntimeConfig(
data.metadata !== undefined
? (data.metadata as Record<string, unknown> | null | undefined)
: ((existing.metadata as Record<string, unknown> | null | undefined) ?? null),
data.runtimeConfig ?? null,
)
: data.metadata;
}
const updated = await db.transaction(async (tx) => {
if (data.isPrimary === true) {

View file

@ -6,11 +6,23 @@ import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import type { AdapterRuntimeServiceReport } from "@paperclipai/adapter-utils";
import type { Db } from "@paperclipai/db";
import { workspaceRuntimeServices } from "@paperclipai/db";
import { executionWorkspaces, projectWorkspaces, workspaceRuntimeServices } from "@paperclipai/db";
import { and, desc, eq, inArray } from "drizzle-orm";
import { asNumber, asString, parseObject, renderTemplate } from "../adapters/utils.js";
import { resolveHomeAwarePath } from "../home-paths.js";
import {
createLocalServiceKey,
findLocalServiceRegistryRecordByRuntimeServiceId,
findAdoptableLocalService,
readLocalServicePortOwner,
removeLocalServiceRegistryRecord,
terminateLocalService,
touchLocalServiceRegistryRecord,
writeLocalServiceRegistryRecord,
} from "./local-service-supervisor.js";
import type { WorkspaceOperationRecorder } from "./workspace-operations.js";
import { readExecutionWorkspaceConfig } from "./execution-workspaces.js";
import { readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js";
export interface ExecutionWorkspaceInput {
baseCwd: string;
@ -28,7 +40,7 @@ export interface ExecutionWorkspaceIssueRef {
}
export interface ExecutionWorkspaceAgentRef {
id: string;
id: string | null;
name: string;
companyId: string;
}
@ -77,12 +89,24 @@ interface RuntimeServiceRecord extends RuntimeServiceRef {
leaseRunIds: Set<string>;
idleTimer: ReturnType<typeof globalThis.setTimeout> | null;
envFingerprint: string;
serviceKey: string;
profileKind: string;
processGroupId: number | null;
}
const runtimeServicesById = new Map<string, RuntimeServiceRecord>();
const runtimeServicesByReuseKey = new Map<string, string>();
const runtimeServiceLeasesByRun = new Map<string, string[]>();
export async function resetRuntimeServicesForTests() {
for (const record of runtimeServicesById.values()) {
clearIdleTimer(record);
}
runtimeServicesById.clear();
runtimeServicesByReuseKey.clear();
runtimeServiceLeasesByRun.clear();
}
function stableStringify(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
@ -102,6 +126,8 @@ export function sanitizeRuntimeServiceBaseEnv(baseEnv: NodeJS.ProcessEnv): NodeJ
}
}
delete env.DATABASE_URL;
delete env.npm_config_tailscale_auth;
delete env.npm_config_authenticated_private;
return env;
}
@ -168,9 +194,9 @@ function toRuntimeServiceRef(record: RuntimeServiceRecord, overrides?: Partial<R
function sanitizeSlugPart(value: string | null | undefined, fallback: string): string {
const raw = (value ?? "").trim().toLowerCase();
const normalized = raw
.replace(/[^a-z0-9/_-]+/g, "-")
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^[-/]+|[-/]+$/g, "");
.replace(/^[-_]+|[-_]+$/g, "");
return normalized.length > 0 ? normalized : fallback;
}
@ -189,7 +215,7 @@ function renderWorkspaceTemplate(template: string, input: {
title: input.issue?.title ?? "",
},
agent: {
id: input.agent.id,
id: input.agent.id ?? "",
name: input.agent.name,
},
project: {
@ -312,7 +338,7 @@ function buildWorkspaceCommandEnv(input: {
env.PAPERCLIP_WORKSPACE_CREATED = input.created ? "true" : "false";
env.PAPERCLIP_PROJECT_ID = input.base.projectId ?? "";
env.PAPERCLIP_PROJECT_WORKSPACE_ID = input.base.workspaceId ?? "";
env.PAPERCLIP_AGENT_ID = input.agent.id;
env.PAPERCLIP_AGENT_ID = input.agent.id ?? "";
env.PAPERCLIP_AGENT_NAME = input.agent.name;
env.PAPERCLIP_COMPANY_ID = input.agent.companyId;
env.PAPERCLIP_ISSUE_ID = input.issue?.id ?? "";
@ -702,6 +728,7 @@ export async function cleanupExecutionWorkspaceArtifacts(input: {
cwd: string | null;
cleanupCommand: string | null;
} | null;
cleanupCommand?: string | null;
teardownCommand?: string | null;
recorder?: WorkspaceOperationRecorder | null;
}) {
@ -713,6 +740,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,
]
@ -879,13 +907,95 @@ function buildTemplateData(input: {
title: input.issue?.title ?? "",
},
agent: {
id: input.agent.id,
id: input.agent.id ?? "",
name: input.agent.name,
},
port: input.port ?? "",
};
}
function renderRuntimeServiceEnv(input: {
envConfig: Record<string, unknown>;
templateData: ReturnType<typeof buildTemplateData>;
}) {
const rendered: Record<string, string> = {};
for (const [key, value] of Object.entries(input.envConfig)) {
if (typeof value !== "string") continue;
rendered[key] = renderTemplate(value, input.templateData);
}
return rendered;
}
function resolveRuntimeServiceReuseIdentity(input: {
service: Record<string, unknown>;
workspace: RealizedExecutionWorkspace;
agent: ExecutionWorkspaceAgentRef;
issue: ExecutionWorkspaceIssueRef | null;
adapterEnv: Record<string, string>;
scopeType: RuntimeServiceRef["scopeType"];
scopeId: string | null;
}): {
serviceName: string;
lifecycle: RuntimeServiceRef["lifecycle"];
command: string;
serviceCwd: string;
envConfig: Record<string, unknown>;
envFingerprint: string;
explicitPort: number;
identityPort: number | null;
reuseKey: string | null;
} {
const serviceName = asString(input.service.name, "service");
const lifecycle = asString(input.service.lifecycle, "shared") === "ephemeral" ? "ephemeral" : "shared";
const command = asString(input.service.command, "");
const serviceCwdTemplate = asString(input.service.cwd, ".");
const portConfig = parseObject(input.service.port);
const envConfig = parseObject(input.service.env);
const explicitPort = asNumber(portConfig.value, asNumber(input.service.port, 0));
const identityPort = explicitPort > 0 ? explicitPort : null;
const templateData = buildTemplateData({
workspace: input.workspace,
agent: input.agent,
issue: input.issue,
adapterEnv: input.adapterEnv,
port: identityPort,
});
const serviceCwd = resolveConfiguredPath(renderTemplate(serviceCwdTemplate, templateData), input.workspace.cwd);
const renderedEnv = renderRuntimeServiceEnv({
envConfig,
templateData,
});
const envFingerprint = createHash("sha256").update(stableStringify(renderedEnv)).digest("hex");
const reuseKey =
lifecycle === "shared"
? createHash("sha256")
.update(
stableStringify({
scopeType: input.scopeType,
scopeId: input.scopeId,
serviceName,
command,
cwd: serviceCwd,
port: identityPort,
env: renderedEnv,
}),
)
.digest("hex")
: null;
return {
serviceName,
lifecycle,
command,
serviceCwd,
envConfig,
envFingerprint,
explicitPort,
identityPort,
reuseKey,
};
}
function resolveServiceScopeId(input: {
service: Record<string, unknown>;
workspace: RealizedExecutionWorkspace;
@ -1067,7 +1177,7 @@ export function normalizeAdapterManagedRuntimeServices(input: {
url: report.url ?? null,
provider: "adapter_managed",
providerRef: report.providerRef ?? null,
ownerAgentId: report.ownerAgentId ?? input.agent.id,
ownerAgentId: report.ownerAgentId ?? input.agent.id ?? null,
startedByRunId: input.runId,
lastUsedAt: nowIso,
startedAt: nowIso,
@ -1082,6 +1192,8 @@ export function normalizeAdapterManagedRuntimeServices(input: {
async function startLocalRuntimeService(input: {
db?: Db;
runId: string;
leaseRunId?: string | null;
startedByRunId?: string | null;
agent: ExecutionWorkspaceAgentRef;
issue: ExecutionWorkspaceIssueRef | null;
workspace: RealizedExecutionWorkspace;
@ -1093,14 +1205,33 @@ async function startLocalRuntimeService(input: {
scopeType: "project_workspace" | "execution_workspace" | "run" | "agent";
scopeId: string | null;
}): Promise<RuntimeServiceRecord> {
const serviceName = asString(input.service.name, "service");
const lifecycle = asString(input.service.lifecycle, "shared") === "ephemeral" ? "ephemeral" : "shared";
const command = asString(input.service.command, "");
const leaseRunId = input.leaseRunId === undefined ? input.runId : input.leaseRunId;
const startedByRunId = input.startedByRunId === undefined ? input.runId : input.startedByRunId;
const identity = resolveRuntimeServiceReuseIdentity({
service: input.service,
workspace: input.workspace,
agent: input.agent,
issue: input.issue,
adapterEnv: input.adapterEnv,
scopeType: input.scopeType,
scopeId: input.scopeId,
});
const serviceName = identity.serviceName;
const lifecycle = identity.lifecycle;
const command = identity.command;
if (!command) throw new Error(`Runtime service "${serviceName}" is missing command`);
const serviceCwdTemplate = asString(input.service.cwd, ".");
const portConfig = parseObject(input.service.port);
const port = asString(portConfig.type, "") === "auto" ? await allocatePort() : null;
const envConfig = parseObject(input.service.env);
const envConfig = identity.envConfig;
const envFingerprint = identity.envFingerprint;
const serviceIdentityFingerprint = input.reuseKey ?? envFingerprint;
const explicitPort = identity.explicitPort;
const identityPort = identity.identityPort;
const port =
asString(portConfig.type, "") === "auto"
? await allocatePort()
: explicitPort > 0
? explicitPort
: null;
const templateData = buildTemplateData({
workspace: input.workspace,
agent: input.agent,
@ -1108,20 +1239,95 @@ async function startLocalRuntimeService(input: {
adapterEnv: input.adapterEnv,
port,
});
const serviceCwd = resolveConfiguredPath(renderTemplate(serviceCwdTemplate, templateData), input.workspace.cwd);
const serviceCwd =
port === identityPort
? identity.serviceCwd
: resolveConfiguredPath(renderTemplate(asString(input.service.cwd, "."), templateData), input.workspace.cwd);
const env: Record<string, string> = {
...sanitizeRuntimeServiceBaseEnv(process.env),
...input.adapterEnv,
} as Record<string, string>;
for (const [key, value] of Object.entries(envConfig)) {
if (typeof value === "string") {
env[key] = renderTemplate(value, templateData);
}
for (const [key, value] of Object.entries(renderRuntimeServiceEnv({ envConfig, templateData }))) {
env[key] = value;
}
if (port) {
const portEnvKey = asString(portConfig.envKey, "PORT");
env[portEnvKey] = String(port);
}
const expose = parseObject(input.service.expose);
const readiness = parseObject(input.service.readiness);
const urlTemplate =
asString(expose.urlTemplate, "") ||
asString(readiness.urlTemplate, "");
const url = urlTemplate ? renderTemplate(urlTemplate, templateData) : null;
const stopPolicy = parseObject(input.service.stopPolicy);
const serviceKey = createLocalServiceKey({
profileKind: "workspace-runtime",
serviceName,
cwd: serviceCwd,
command,
envFingerprint: serviceIdentityFingerprint,
port: identityPort,
scope: {
scopeType: input.scopeType,
scopeId: input.scopeId,
executionWorkspaceId: input.executionWorkspaceId ?? null,
reuseKey: input.reuseKey,
},
});
const adoptedRecord = await findAdoptableLocalService({
serviceKey,
command,
cwd: serviceCwd,
envFingerprint: serviceIdentityFingerprint,
port: identityPort,
});
if (adoptedRecord) {
return {
id: adoptedRecord.runtimeServiceId ?? randomUUID(),
companyId: input.agent.companyId,
projectId: input.workspace.projectId,
projectWorkspaceId: input.workspace.workspaceId,
executionWorkspaceId: input.executionWorkspaceId ?? null,
issueId: input.issue?.id ?? null,
serviceName,
status: "running",
lifecycle,
scopeType: input.scopeType,
scopeId: input.scopeId,
reuseKey: input.reuseKey,
command,
cwd: serviceCwd,
port: adoptedRecord.port ?? port,
url: adoptedRecord.url ?? url,
provider: "local_process",
providerRef: String(adoptedRecord.pid),
ownerAgentId: input.agent.id ?? null,
startedByRunId,
lastUsedAt: new Date().toISOString(),
startedAt: adoptedRecord.startedAt,
stoppedAt: null,
stopPolicy,
healthStatus: "healthy",
reused: true,
db: input.db,
child: null,
leaseRunIds: leaseRunId ? new Set([leaseRunId]) : new Set(),
idleTimer: null,
envFingerprint,
serviceKey,
profileKind: "workspace-runtime",
processGroupId: adoptedRecord.processGroupId ?? null,
};
}
if (identityPort) {
const ownerPid = await readLocalServicePortOwner(identityPort);
if (ownerPid) {
throw new Error(
`Runtime service "${serviceName}" could not start because port ${identityPort} is already in use by pid ${ownerPid}`,
);
}
}
const shell = process.env.SHELL?.trim() || "/bin/sh";
const child = spawn(shell, ["-lc", command], {
cwd: serviceCwd,
@ -1142,13 +1348,6 @@ async function startLocalRuntimeService(input: {
if (input.onLog) await input.onLog("stderr", `[service:${serviceName}] ${text}`);
});
const expose = parseObject(input.service.expose);
const readiness = parseObject(input.service.readiness);
const urlTemplate =
asString(expose.urlTemplate, "") ||
asString(readiness.urlTemplate, "");
const url = urlTemplate ? renderTemplate(urlTemplate, templateData) : null;
try {
await waitForReadiness({ service: input.service, url });
} catch (err) {
@ -1158,8 +1357,7 @@ async function startLocalRuntimeService(input: {
);
}
const envFingerprint = createHash("sha256").update(stableStringify(envConfig)).digest("hex");
return {
const record: RuntimeServiceRecord = {
id: randomUUID(),
companyId: input.agent.companyId,
projectId: input.workspace.projectId,
@ -1178,20 +1376,54 @@ async function startLocalRuntimeService(input: {
url,
provider: "local_process",
providerRef: child.pid ? String(child.pid) : null,
ownerAgentId: input.agent.id,
startedByRunId: input.runId,
ownerAgentId: input.agent.id ?? null,
startedByRunId,
lastUsedAt: new Date().toISOString(),
startedAt: new Date().toISOString(),
stoppedAt: null,
stopPolicy: parseObject(input.service.stopPolicy),
stopPolicy,
healthStatus: "healthy",
reused: false,
db: input.db,
child,
leaseRunIds: new Set([input.runId]),
leaseRunIds: leaseRunId ? new Set([leaseRunId]) : new Set(),
idleTimer: null,
envFingerprint,
serviceKey,
profileKind: "workspace-runtime",
processGroupId: child.pid ?? null,
};
if (child.pid) {
await writeLocalServiceRegistryRecord({
version: 1,
serviceKey,
profileKind: "workspace-runtime",
serviceName,
command,
cwd: serviceCwd,
envFingerprint: serviceIdentityFingerprint,
port,
url,
pid: child.pid,
processGroupId: child.pid,
provider: "local_process",
runtimeServiceId: record.id,
reuseKey: input.reuseKey,
startedAt: record.startedAt,
lastSeenAt: record.lastUsedAt,
metadata: {
projectId: record.projectId,
projectWorkspaceId: record.projectWorkspaceId,
executionWorkspaceId: record.executionWorkspaceId,
issueId: record.issueId,
scopeType: record.scopeType,
scopeId: record.scopeId,
},
});
}
return record;
}
function scheduleIdleStop(record: RuntimeServiceRecord) {
@ -1209,15 +1441,28 @@ async function stopRuntimeService(serviceId: string) {
if (!record) return;
clearIdleTimer(record);
record.status = "stopped";
record.healthStatus = "unknown";
record.lastUsedAt = new Date().toISOString();
record.stoppedAt = new Date().toISOString();
if (record.child && record.child.pid) {
terminateChildProcess(record.child);
}
runtimeServicesById.delete(serviceId);
if (record.reuseKey) {
if (record.reuseKey && runtimeServicesByReuseKey.get(record.reuseKey) === record.id) {
runtimeServicesByReuseKey.delete(record.reuseKey);
}
if (record.child && record.child.pid) {
await terminateLocalService({
pid: record.child.pid,
processGroupId: record.processGroupId ?? record.child.pid,
});
} else if (record.providerRef) {
const pid = Number.parseInt(record.providerRef, 10);
if (Number.isInteger(pid) && pid > 0) {
await terminateLocalService({
pid,
processGroupId: record.processGroupId,
});
}
}
await removeLocalServiceRegistryRecord(record.serviceKey);
await persistRuntimeServiceRecord(record.db, record);
}
@ -1262,10 +1507,18 @@ function registerRuntimeService(db: Db | undefined, record: RuntimeServiceRecord
if (current.reuseKey && runtimeServicesByReuseKey.get(current.reuseKey) === current.id) {
runtimeServicesByReuseKey.delete(current.reuseKey);
}
void removeLocalServiceRegistryRecord(current.serviceKey);
void persistRuntimeServiceRecord(db, current);
});
}
function readRuntimeServiceEntries(config: Record<string, unknown>) {
const runtime = parseObject(config.workspaceRuntime);
return Array.isArray(runtime.services)
? runtime.services.filter((entry): entry is Record<string, unknown> => typeof entry === "object" && entry !== null)
: [];
}
export async function ensureRuntimeServicesForRun(input: {
db?: Db;
runId: string;
@ -1277,17 +1530,13 @@ export async function ensureRuntimeServicesForRun(input: {
adapterEnv: Record<string, string>;
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
}): Promise<RuntimeServiceRef[]> {
const runtime = parseObject(input.config.workspaceRuntime);
const rawServices = Array.isArray(runtime.services)
? runtime.services.filter((entry): entry is Record<string, unknown> => typeof entry === "object" && entry !== null)
: [];
const rawServices = readRuntimeServiceEntries(input.config);
const acquiredServiceIds: string[] = [];
const refs: RuntimeServiceRef[] = [];
runtimeServiceLeasesByRun.set(input.runId, acquiredServiceIds);
try {
for (const service of rawServices) {
const lifecycle = asString(service.lifecycle, "shared") === "ephemeral" ? "ephemeral" : "shared";
const { scopeType, scopeId } = resolveServiceScopeId({
service,
workspace: input.workspace,
@ -1296,13 +1545,15 @@ export async function ensureRuntimeServicesForRun(input: {
runId: input.runId,
agent: input.agent,
});
const envConfig = parseObject(service.env);
const envFingerprint = createHash("sha256").update(stableStringify(envConfig)).digest("hex");
const serviceName = asString(service.name, "service");
const reuseKey =
lifecycle === "shared"
? [scopeType, scopeId ?? "", serviceName, envFingerprint].join(":")
: null;
const reuseKey = resolveRuntimeServiceReuseIdentity({
service,
workspace: input.workspace,
agent: input.agent,
issue: input.issue,
adapterEnv: input.adapterEnv,
scopeType,
scopeId,
}).reuseKey;
if (reuseKey) {
const existingId = runtimeServicesByReuseKey.get(reuseKey);
@ -1312,6 +1563,10 @@ export async function ensureRuntimeServicesForRun(input: {
existing.lastUsedAt = new Date().toISOString();
existing.stoppedAt = null;
clearIdleTimer(existing);
void touchLocalServiceRegistryRecord(existing.serviceKey, {
runtimeServiceId: existing.id,
lastSeenAt: existing.lastUsedAt,
});
await persistRuntimeServiceRecord(input.db, existing);
acquiredServiceIds.push(existing.id);
refs.push(toRuntimeServiceRef(existing, { reused: true }));
@ -1346,6 +1601,83 @@ export async function ensureRuntimeServicesForRun(input: {
return refs;
}
export async function startRuntimeServicesForWorkspaceControl(input: {
db?: Db;
invocationId?: string;
actor: ExecutionWorkspaceAgentRef;
issue: ExecutionWorkspaceIssueRef | null;
workspace: RealizedExecutionWorkspace;
executionWorkspaceId?: string | null;
config: Record<string, unknown>;
adapterEnv: Record<string, string>;
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
}): Promise<RuntimeServiceRef[]> {
const rawServices = readRuntimeServiceEntries(input.config);
const refs: RuntimeServiceRef[] = [];
const invocationId = input.invocationId ?? randomUUID();
for (const service of rawServices) {
const { scopeType, scopeId } = resolveServiceScopeId({
service,
workspace: input.workspace,
executionWorkspaceId: input.executionWorkspaceId,
issue: input.issue,
runId: invocationId,
agent: input.actor,
});
const reuseKey = resolveRuntimeServiceReuseIdentity({
service,
workspace: input.workspace,
agent: input.actor,
issue: input.issue,
adapterEnv: input.adapterEnv,
scopeType,
scopeId,
}).reuseKey;
if (reuseKey) {
const existingId = runtimeServicesByReuseKey.get(reuseKey);
const existing = existingId ? runtimeServicesById.get(existingId) : null;
if (existing && existing.status === "running") {
existing.lastUsedAt = new Date().toISOString();
existing.stoppedAt = null;
clearIdleTimer(existing);
void touchLocalServiceRegistryRecord(existing.serviceKey, {
runtimeServiceId: existing.id,
lastSeenAt: existing.lastUsedAt,
});
await persistRuntimeServiceRecord(input.db, existing);
refs.push(toRuntimeServiceRef(existing, { reused: true }));
continue;
}
}
// Manually controlled services are not tied to a heartbeat run lifecycle, so they do not
// retain a run lease and never persist a startedByRunId foreign key.
const record = await startLocalRuntimeService({
db: input.db,
runId: invocationId,
leaseRunId: null,
startedByRunId: null,
agent: input.actor,
issue: input.issue,
workspace: input.workspace,
executionWorkspaceId: input.executionWorkspaceId,
adapterEnv: input.adapterEnv,
service,
onLog: input.onLog,
reuseKey,
scopeType,
scopeId,
});
registerRuntimeService(input.db, record);
await persistRuntimeServiceRecord(input.db, record);
refs.push(toRuntimeServiceRef(record));
}
return refs;
}
export async function releaseRuntimeServicesForRun(runId: string) {
const acquired = runtimeServiceLeasesByRun.get(runId) ?? [];
runtimeServiceLeasesByRun.delete(runId);
@ -1396,6 +1728,39 @@ export async function stopRuntimeServicesForExecutionWorkspace(input: {
}
}
export async function stopRuntimeServicesForProjectWorkspace(input: {
db?: Db;
projectWorkspaceId: string;
}) {
const matchingServiceIds = Array.from(runtimeServicesById.values())
.filter((record) => record.projectWorkspaceId === input.projectWorkspaceId && record.scopeType === "project_workspace")
.map((record) => record.id);
for (const serviceId of matchingServiceIds) {
await stopRuntimeService(serviceId);
}
if (input.db) {
const now = new Date();
await input.db
.update(workspaceRuntimeServices)
.set({
status: "stopped",
healthStatus: "unknown",
stoppedAt: now,
lastUsedAt: now,
updatedAt: now,
})
.where(
and(
eq(workspaceRuntimeServices.projectWorkspaceId, input.projectWorkspaceId),
eq(workspaceRuntimeServices.scopeType, "project_workspace"),
inArray(workspaceRuntimeServices.status, ["starting", "running"]),
),
);
}
}
export async function listWorkspaceRuntimeServicesForProjectWorkspaces(
db: Db,
companyId: string,
@ -1409,6 +1774,7 @@ export async function listWorkspaceRuntimeServicesForProjectWorkspaces(
and(
eq(workspaceRuntimeServices.companyId, companyId),
inArray(workspaceRuntimeServices.projectWorkspaceId, projectWorkspaceIds),
eq(workspaceRuntimeServices.scopeType, "project_workspace"),
),
)
.orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt));
@ -1424,8 +1790,8 @@ export async function listWorkspaceRuntimeServicesForProjectWorkspaces(
}
export async function reconcilePersistedRuntimeServicesOnStartup(db: Db) {
const staleRows = await db
.select({ id: workspaceRuntimeServices.id })
const rows = await db
.select()
.from(workspaceRuntimeServices)
.where(
and(
@ -1434,26 +1800,171 @@ export async function reconcilePersistedRuntimeServicesOnStartup(db: Db) {
),
);
if (staleRows.length === 0) return { reconciled: 0 };
if (rows.length === 0) return { reconciled: 0, adopted: 0, stopped: 0 };
const now = new Date();
await db
.update(workspaceRuntimeServices)
.set({
status: "stopped",
healthStatus: "unknown",
stoppedAt: now,
lastUsedAt: now,
updatedAt: now,
})
.where(
and(
eq(workspaceRuntimeServices.provider, "local_process"),
inArray(workspaceRuntimeServices.status, ["starting", "running"]),
),
);
let adopted = 0;
let stopped = 0;
for (const row of rows) {
const adoptedRecord = await findLocalServiceRegistryRecordByRuntimeServiceId({
runtimeServiceId: row.id,
profileKind: "workspace-runtime",
});
if (adoptedRecord) {
const record: RuntimeServiceRecord = {
id: row.id,
companyId: row.companyId,
projectId: row.projectId ?? null,
projectWorkspaceId: row.projectWorkspaceId ?? null,
executionWorkspaceId: row.executionWorkspaceId ?? null,
issueId: row.issueId ?? null,
serviceName: row.serviceName,
status: "running",
lifecycle: row.lifecycle as RuntimeServiceRecord["lifecycle"],
scopeType: row.scopeType as RuntimeServiceRecord["scopeType"],
scopeId: row.scopeId ?? null,
reuseKey: row.reuseKey ?? null,
command: row.command ?? null,
cwd: row.cwd ?? null,
port: adoptedRecord.port ?? row.port ?? null,
url: adoptedRecord.url ?? row.url ?? null,
provider: "local_process",
providerRef: String(adoptedRecord.pid),
ownerAgentId: row.ownerAgentId ?? null,
startedByRunId: row.startedByRunId ?? null,
lastUsedAt: new Date().toISOString(),
startedAt: row.startedAt.toISOString(),
stoppedAt: null,
stopPolicy: (row.stopPolicy as Record<string, unknown> | null) ?? null,
healthStatus: "healthy",
reused: true,
db,
child: null,
leaseRunIds: new Set(),
idleTimer: null,
envFingerprint: row.reuseKey ?? "",
serviceKey: adoptedRecord.serviceKey,
profileKind: "workspace-runtime",
processGroupId: adoptedRecord.processGroupId ?? null,
};
registerRuntimeService(db, record);
await touchLocalServiceRegistryRecord(adoptedRecord.serviceKey, {
runtimeServiceId: row.id,
lastSeenAt: record.lastUsedAt,
});
await persistRuntimeServiceRecord(db, record);
adopted += 1;
continue;
}
return { reconciled: staleRows.length };
const now = new Date();
await db
.update(workspaceRuntimeServices)
.set({
status: "stopped",
healthStatus: "unknown",
stoppedAt: now,
lastUsedAt: now,
updatedAt: now,
})
.where(eq(workspaceRuntimeServices.id, row.id));
const registryRecord = await findLocalServiceRegistryRecordByRuntimeServiceId({
runtimeServiceId: row.id,
profileKind: "workspace-runtime",
});
if (registryRecord) {
await removeLocalServiceRegistryRecord(registryRecord.serviceKey);
}
stopped += 1;
}
return { reconciled: rows.length, adopted, stopped };
}
export async function restartDesiredRuntimeServicesOnStartup(db: Db) {
let restarted = 0;
let failed = 0;
const projectWorkspaceRows = await db
.select()
.from(projectWorkspaces);
for (const row of projectWorkspaceRows) {
const runtimeConfig = readProjectWorkspaceRuntimeConfig((row.metadata as Record<string, unknown> | null) ?? null);
if (runtimeConfig?.desiredState !== "running" || !runtimeConfig.workspaceRuntime || !row.cwd) continue;
try {
const refs = await startRuntimeServicesForWorkspaceControl({
db,
actor: { id: null, name: "Paperclip", companyId: row.companyId },
issue: null,
workspace: {
baseCwd: row.cwd,
source: "project_primary",
projectId: row.projectId,
workspaceId: row.id,
repoUrl: row.repoUrl ?? null,
repoRef: row.repoRef ?? null,
strategy: "project_primary",
cwd: row.cwd,
branchName: row.defaultRef ?? row.repoRef ?? null,
worktreePath: null,
warnings: [],
created: false,
},
config: { workspaceRuntime: runtimeConfig.workspaceRuntime },
adapterEnv: {},
});
if (refs.length > 0) restarted += refs.filter((ref) => !ref.reused).length;
} catch {
failed += 1;
}
}
const executionWorkspaceRows = await db
.select()
.from(executionWorkspaces)
.where(inArray(executionWorkspaces.status, ["active", "idle", "in_review", "cleanup_failed"]));
for (const row of executionWorkspaceRows) {
const config = readExecutionWorkspaceConfig((row.metadata as Record<string, unknown> | null) ?? null);
if (config?.desiredState !== "running" || !config.workspaceRuntime || !row.cwd) continue;
try {
const refs = await startRuntimeServicesForWorkspaceControl({
db,
actor: { id: null, name: "Paperclip", companyId: row.companyId },
issue: row.sourceIssueId
? {
id: row.sourceIssueId,
identifier: null,
title: row.name,
}
: null,
workspace: {
baseCwd: row.cwd,
source: row.mode === "shared_workspace" ? "project_primary" : "task_session",
projectId: row.projectId,
workspaceId: row.projectWorkspaceId ?? null,
repoUrl: row.repoUrl ?? null,
repoRef: row.baseRef ?? null,
strategy: row.strategyType === "git_worktree" ? "git_worktree" : "project_primary",
cwd: row.cwd,
branchName: row.branchName ?? null,
worktreePath: row.strategyType === "git_worktree" ? row.cwd : null,
warnings: [],
created: false,
},
executionWorkspaceId: row.id,
config: { workspaceRuntime: config.workspaceRuntime },
adapterEnv: {},
});
if (refs.length > 0) restarted += refs.filter((ref) => !ref.reused).length;
} catch {
failed += 1;
}
}
return { restarted, failed };
}
export async function persistAdapterManagedRuntimeServices(input: {