mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 10:30:37 +09:00
Merge public-gh/master into PAP-881-document-revisions-bulid-it
This commit is contained in:
commit
41f261eaf5
194 changed files with 29520 additions and 2185 deletions
|
|
@ -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"]);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
302
server/src/services/local-service-supervisor.ts
Normal file
302
server/src/services/local-service-supervisor.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
59
server/src/services/project-workspace-runtime-config.ts
Normal file
59
server/src/services/project-workspace-runtime-config.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue