Add worktree-aware workspace runtime support
This commit is contained in:
@@ -3,6 +3,7 @@ export type {
|
|||||||
AdapterRuntime,
|
AdapterRuntime,
|
||||||
UsageSummary,
|
UsageSummary,
|
||||||
AdapterBillingType,
|
AdapterBillingType,
|
||||||
|
AdapterRuntimeServiceReport,
|
||||||
AdapterExecutionResult,
|
AdapterExecutionResult,
|
||||||
AdapterInvocationMeta,
|
AdapterInvocationMeta,
|
||||||
AdapterExecutionContext,
|
AdapterExecutionContext,
|
||||||
|
|||||||
@@ -32,6 +32,27 @@ export interface UsageSummary {
|
|||||||
|
|
||||||
export type AdapterBillingType = "api" | "subscription" | "unknown";
|
export type AdapterBillingType = "api" | "subscription" | "unknown";
|
||||||
|
|
||||||
|
export interface AdapterRuntimeServiceReport {
|
||||||
|
id?: string | null;
|
||||||
|
projectId?: string | null;
|
||||||
|
projectWorkspaceId?: string | null;
|
||||||
|
issueId?: string | null;
|
||||||
|
scopeType?: "project_workspace" | "execution_workspace" | "run" | "agent";
|
||||||
|
scopeId?: string | null;
|
||||||
|
serviceName: string;
|
||||||
|
status?: "starting" | "running" | "stopped" | "failed";
|
||||||
|
lifecycle?: "shared" | "ephemeral";
|
||||||
|
reuseKey?: string | null;
|
||||||
|
command?: string | null;
|
||||||
|
cwd?: string | null;
|
||||||
|
port?: number | null;
|
||||||
|
url?: string | null;
|
||||||
|
providerRef?: string | null;
|
||||||
|
ownerAgentId?: string | null;
|
||||||
|
stopPolicy?: Record<string, unknown> | null;
|
||||||
|
healthStatus?: "unknown" | "healthy" | "unhealthy";
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdapterExecutionResult {
|
export interface AdapterExecutionResult {
|
||||||
exitCode: number | null;
|
exitCode: number | null;
|
||||||
signal: string | null;
|
signal: string | null;
|
||||||
@@ -51,6 +72,7 @@ export interface AdapterExecutionResult {
|
|||||||
billingType?: AdapterBillingType | null;
|
billingType?: AdapterBillingType | null;
|
||||||
costUsd?: number | null;
|
costUsd?: number | null;
|
||||||
resultJson?: Record<string, unknown> | null;
|
resultJson?: Record<string, unknown> | null;
|
||||||
|
runtimeServices?: AdapterRuntimeServiceReport[];
|
||||||
summary?: string | null;
|
summary?: string | null;
|
||||||
clearSession?: boolean;
|
clearSession?: boolean;
|
||||||
}
|
}
|
||||||
@@ -208,6 +230,12 @@ export interface CreateConfigValues {
|
|||||||
envBindings: Record<string, unknown>;
|
envBindings: Record<string, unknown>;
|
||||||
url: string;
|
url: string;
|
||||||
bootstrapPrompt: string;
|
bootstrapPrompt: string;
|
||||||
|
payloadTemplateJson?: string;
|
||||||
|
workspaceStrategyType?: string;
|
||||||
|
workspaceBaseRef?: string;
|
||||||
|
workspaceBranchTemplate?: string;
|
||||||
|
worktreeParentDir?: string;
|
||||||
|
runtimeServicesJson?: string;
|
||||||
maxTurnsPerRun: number;
|
maxTurnsPerRun: number;
|
||||||
heartbeatEnabled: boolean;
|
heartbeatEnabled: boolean;
|
||||||
intervalSec: number;
|
intervalSec: number;
|
||||||
|
|||||||
@@ -25,8 +25,13 @@ Core fields:
|
|||||||
- command (string, optional): defaults to "claude"
|
- command (string, optional): defaults to "claude"
|
||||||
- extraArgs (string[], optional): additional CLI args
|
- extraArgs (string[], optional): additional CLI args
|
||||||
- env (object, optional): KEY=VALUE environment variables
|
- env (object, optional): KEY=VALUE environment variables
|
||||||
|
- workspaceStrategy (object, optional): execution workspace strategy; currently supports { type: "git_worktree", baseRef?, branchTemplate?, worktreeParentDir? }
|
||||||
|
- workspaceRuntime (object, optional): workspace runtime service intents; local host-managed services are realized before Claude starts and exposed back via context/env
|
||||||
|
|
||||||
Operational fields:
|
Operational fields:
|
||||||
- timeoutSec (number, optional): run timeout in seconds
|
- timeoutSec (number, optional): run timeout in seconds
|
||||||
- graceSec (number, optional): SIGTERM grace period in seconds
|
- graceSec (number, optional): SIGTERM grace period in seconds
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling.
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -115,14 +115,28 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
|
|||||||
const workspaceContext = parseObject(context.paperclipWorkspace);
|
const workspaceContext = parseObject(context.paperclipWorkspace);
|
||||||
const workspaceCwd = asString(workspaceContext.cwd, "");
|
const workspaceCwd = asString(workspaceContext.cwd, "");
|
||||||
const workspaceSource = asString(workspaceContext.source, "");
|
const workspaceSource = asString(workspaceContext.source, "");
|
||||||
|
const workspaceStrategy = asString(workspaceContext.strategy, "");
|
||||||
const workspaceId = asString(workspaceContext.workspaceId, "") || null;
|
const workspaceId = asString(workspaceContext.workspaceId, "") || null;
|
||||||
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "") || null;
|
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "") || null;
|
||||||
const workspaceRepoRef = asString(workspaceContext.repoRef, "") || null;
|
const workspaceRepoRef = asString(workspaceContext.repoRef, "") || null;
|
||||||
|
const workspaceBranch = asString(workspaceContext.branchName, "") || null;
|
||||||
|
const workspaceWorktreePath = asString(workspaceContext.worktreePath, "") || null;
|
||||||
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
|
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
|
||||||
? context.paperclipWorkspaces.filter(
|
? context.paperclipWorkspaces.filter(
|
||||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||||
)
|
)
|
||||||
: [];
|
: [];
|
||||||
|
const runtimeServiceIntents = Array.isArray(context.paperclipRuntimeServiceIntents)
|
||||||
|
? context.paperclipRuntimeServiceIntents.filter(
|
||||||
|
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const runtimeServices = Array.isArray(context.paperclipRuntimeServices)
|
||||||
|
? context.paperclipRuntimeServices.filter(
|
||||||
|
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const runtimePrimaryUrl = asString(context.paperclipRuntimePrimaryUrl, "");
|
||||||
const configuredCwd = asString(config.cwd, "");
|
const configuredCwd = asString(config.cwd, "");
|
||||||
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
|
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
|
||||||
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
||||||
@@ -183,6 +197,9 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
|
|||||||
if (workspaceSource) {
|
if (workspaceSource) {
|
||||||
env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
|
env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
|
||||||
}
|
}
|
||||||
|
if (workspaceStrategy) {
|
||||||
|
env.PAPERCLIP_WORKSPACE_STRATEGY = workspaceStrategy;
|
||||||
|
}
|
||||||
if (workspaceId) {
|
if (workspaceId) {
|
||||||
env.PAPERCLIP_WORKSPACE_ID = workspaceId;
|
env.PAPERCLIP_WORKSPACE_ID = workspaceId;
|
||||||
}
|
}
|
||||||
@@ -192,9 +209,24 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
|
|||||||
if (workspaceRepoRef) {
|
if (workspaceRepoRef) {
|
||||||
env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
|
env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
|
||||||
}
|
}
|
||||||
|
if (workspaceBranch) {
|
||||||
|
env.PAPERCLIP_WORKSPACE_BRANCH = workspaceBranch;
|
||||||
|
}
|
||||||
|
if (workspaceWorktreePath) {
|
||||||
|
env.PAPERCLIP_WORKSPACE_WORKTREE_PATH = workspaceWorktreePath;
|
||||||
|
}
|
||||||
if (workspaceHints.length > 0) {
|
if (workspaceHints.length > 0) {
|
||||||
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||||
}
|
}
|
||||||
|
if (runtimeServiceIntents.length > 0) {
|
||||||
|
env.PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(runtimeServiceIntents);
|
||||||
|
}
|
||||||
|
if (runtimeServices.length > 0) {
|
||||||
|
env.PAPERCLIP_RUNTIME_SERVICES_JSON = JSON.stringify(runtimeServices);
|
||||||
|
}
|
||||||
|
if (runtimePrimaryUrl) {
|
||||||
|
env.PAPERCLIP_RUNTIME_PRIMARY_URL = runtimePrimaryUrl;
|
||||||
|
}
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(envConfig)) {
|
for (const [key, value] of Object.entries(envConfig)) {
|
||||||
if (typeof value === "string") env[key] = value;
|
if (typeof value === "string") env[key] = value;
|
||||||
|
|||||||
@@ -50,6 +50,18 @@ function parseEnvBindings(bindings: unknown): Record<string, unknown> {
|
|||||||
return env;
|
return env;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseJsonObject(text: string): Record<string, unknown> | null {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed);
|
||||||
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
||||||
|
return parsed as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function buildClaudeLocalConfig(v: CreateConfigValues): Record<string, unknown> {
|
export function buildClaudeLocalConfig(v: CreateConfigValues): Record<string, unknown> {
|
||||||
const ac: Record<string, unknown> = {};
|
const ac: Record<string, unknown> = {};
|
||||||
if (v.cwd) ac.cwd = v.cwd;
|
if (v.cwd) ac.cwd = v.cwd;
|
||||||
@@ -70,6 +82,18 @@ export function buildClaudeLocalConfig(v: CreateConfigValues): Record<string, un
|
|||||||
if (Object.keys(env).length > 0) ac.env = env;
|
if (Object.keys(env).length > 0) ac.env = env;
|
||||||
ac.maxTurnsPerRun = v.maxTurnsPerRun;
|
ac.maxTurnsPerRun = v.maxTurnsPerRun;
|
||||||
ac.dangerouslySkipPermissions = v.dangerouslySkipPermissions;
|
ac.dangerouslySkipPermissions = v.dangerouslySkipPermissions;
|
||||||
|
if (v.workspaceStrategyType === "git_worktree") {
|
||||||
|
ac.workspaceStrategy = {
|
||||||
|
type: "git_worktree",
|
||||||
|
...(v.workspaceBaseRef ? { baseRef: v.workspaceBaseRef } : {}),
|
||||||
|
...(v.workspaceBranchTemplate ? { branchTemplate: v.workspaceBranchTemplate } : {}),
|
||||||
|
...(v.worktreeParentDir ? { worktreeParentDir: v.worktreeParentDir } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const runtimeServices = parseJsonObject(v.runtimeServicesJson ?? "");
|
||||||
|
if (runtimeServices && Array.isArray(runtimeServices.services)) {
|
||||||
|
ac.workspaceRuntime = runtimeServices;
|
||||||
|
}
|
||||||
if (v.command) ac.command = v.command;
|
if (v.command) ac.command = v.command;
|
||||||
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
|
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
|
||||||
return ac;
|
return ac;
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ Core fields:
|
|||||||
- command (string, optional): defaults to "codex"
|
- command (string, optional): defaults to "codex"
|
||||||
- extraArgs (string[], optional): additional CLI args
|
- extraArgs (string[], optional): additional CLI args
|
||||||
- env (object, optional): KEY=VALUE environment variables
|
- env (object, optional): KEY=VALUE environment variables
|
||||||
|
- workspaceStrategy (object, optional): execution workspace strategy; currently supports { type: "git_worktree", baseRef?, branchTemplate?, worktreeParentDir? }
|
||||||
|
- workspaceRuntime (object, optional): workspace runtime service intents; local host-managed services are realized before Codex starts and exposed back via context/env
|
||||||
|
|
||||||
Operational fields:
|
Operational fields:
|
||||||
- timeoutSec (number, optional): run timeout in seconds
|
- timeoutSec (number, optional): run timeout in seconds
|
||||||
@@ -40,4 +42,5 @@ Notes:
|
|||||||
- Prompts are piped via stdin (Codex receives "-" prompt argument).
|
- Prompts are piped via stdin (Codex receives "-" prompt argument).
|
||||||
- Paperclip auto-injects local skills into Codex personal skills dir ("$CODEX_HOME/skills" or "~/.codex/skills") when missing, so Codex can discover "$paperclip" and related skills.
|
- Paperclip auto-injects local skills into Codex personal skills dir ("$CODEX_HOME/skills" or "~/.codex/skills") when missing, so Codex can discover "$paperclip" and related skills.
|
||||||
- Some model/tool combinations reject certain effort levels (for example minimal with web search enabled).
|
- Some model/tool combinations reject certain effort levels (for example minimal with web search enabled).
|
||||||
|
- When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling.
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -126,14 +126,28 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
const workspaceContext = parseObject(context.paperclipWorkspace);
|
const workspaceContext = parseObject(context.paperclipWorkspace);
|
||||||
const workspaceCwd = asString(workspaceContext.cwd, "");
|
const workspaceCwd = asString(workspaceContext.cwd, "");
|
||||||
const workspaceSource = asString(workspaceContext.source, "");
|
const workspaceSource = asString(workspaceContext.source, "");
|
||||||
|
const workspaceStrategy = asString(workspaceContext.strategy, "");
|
||||||
const workspaceId = asString(workspaceContext.workspaceId, "");
|
const workspaceId = asString(workspaceContext.workspaceId, "");
|
||||||
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
|
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
|
||||||
const workspaceRepoRef = asString(workspaceContext.repoRef, "");
|
const workspaceRepoRef = asString(workspaceContext.repoRef, "");
|
||||||
|
const workspaceBranch = asString(workspaceContext.branchName, "");
|
||||||
|
const workspaceWorktreePath = asString(workspaceContext.worktreePath, "");
|
||||||
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
|
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
|
||||||
? context.paperclipWorkspaces.filter(
|
? context.paperclipWorkspaces.filter(
|
||||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||||
)
|
)
|
||||||
: [];
|
: [];
|
||||||
|
const runtimeServiceIntents = Array.isArray(context.paperclipRuntimeServiceIntents)
|
||||||
|
? context.paperclipRuntimeServiceIntents.filter(
|
||||||
|
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const runtimeServices = Array.isArray(context.paperclipRuntimeServices)
|
||||||
|
? context.paperclipRuntimeServices.filter(
|
||||||
|
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const runtimePrimaryUrl = asString(context.paperclipRuntimePrimaryUrl, "");
|
||||||
const configuredCwd = asString(config.cwd, "");
|
const configuredCwd = asString(config.cwd, "");
|
||||||
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
|
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
|
||||||
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
||||||
@@ -192,6 +206,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
if (workspaceSource) {
|
if (workspaceSource) {
|
||||||
env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
|
env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
|
||||||
}
|
}
|
||||||
|
if (workspaceStrategy) {
|
||||||
|
env.PAPERCLIP_WORKSPACE_STRATEGY = workspaceStrategy;
|
||||||
|
}
|
||||||
if (workspaceId) {
|
if (workspaceId) {
|
||||||
env.PAPERCLIP_WORKSPACE_ID = workspaceId;
|
env.PAPERCLIP_WORKSPACE_ID = workspaceId;
|
||||||
}
|
}
|
||||||
@@ -201,9 +218,24 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
if (workspaceRepoRef) {
|
if (workspaceRepoRef) {
|
||||||
env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
|
env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
|
||||||
}
|
}
|
||||||
|
if (workspaceBranch) {
|
||||||
|
env.PAPERCLIP_WORKSPACE_BRANCH = workspaceBranch;
|
||||||
|
}
|
||||||
|
if (workspaceWorktreePath) {
|
||||||
|
env.PAPERCLIP_WORKSPACE_WORKTREE_PATH = workspaceWorktreePath;
|
||||||
|
}
|
||||||
if (workspaceHints.length > 0) {
|
if (workspaceHints.length > 0) {
|
||||||
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||||
}
|
}
|
||||||
|
if (runtimeServiceIntents.length > 0) {
|
||||||
|
env.PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(runtimeServiceIntents);
|
||||||
|
}
|
||||||
|
if (runtimeServices.length > 0) {
|
||||||
|
env.PAPERCLIP_RUNTIME_SERVICES_JSON = JSON.stringify(runtimeServices);
|
||||||
|
}
|
||||||
|
if (runtimePrimaryUrl) {
|
||||||
|
env.PAPERCLIP_RUNTIME_PRIMARY_URL = runtimePrimaryUrl;
|
||||||
|
}
|
||||||
for (const [k, v] of Object.entries(envConfig)) {
|
for (const [k, v] of Object.entries(envConfig)) {
|
||||||
if (typeof v === "string") env[k] = v;
|
if (typeof v === "string") env[k] = v;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,18 @@ function parseEnvBindings(bindings: unknown): Record<string, unknown> {
|
|||||||
return env;
|
return env;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseJsonObject(text: string): Record<string, unknown> | null {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed);
|
||||||
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
||||||
|
return parsed as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function buildCodexLocalConfig(v: CreateConfigValues): Record<string, unknown> {
|
export function buildCodexLocalConfig(v: CreateConfigValues): Record<string, unknown> {
|
||||||
const ac: Record<string, unknown> = {};
|
const ac: Record<string, unknown> = {};
|
||||||
if (v.cwd) ac.cwd = v.cwd;
|
if (v.cwd) ac.cwd = v.cwd;
|
||||||
@@ -76,6 +88,18 @@ export function buildCodexLocalConfig(v: CreateConfigValues): Record<string, unk
|
|||||||
typeof v.dangerouslyBypassSandbox === "boolean"
|
typeof v.dangerouslyBypassSandbox === "boolean"
|
||||||
? v.dangerouslyBypassSandbox
|
? v.dangerouslyBypassSandbox
|
||||||
: DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX;
|
: DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX;
|
||||||
|
if (v.workspaceStrategyType === "git_worktree") {
|
||||||
|
ac.workspaceStrategy = {
|
||||||
|
type: "git_worktree",
|
||||||
|
...(v.workspaceBaseRef ? { baseRef: v.workspaceBaseRef } : {}),
|
||||||
|
...(v.workspaceBranchTemplate ? { branchTemplate: v.workspaceBranchTemplate } : {}),
|
||||||
|
...(v.worktreeParentDir ? { worktreeParentDir: v.worktreeParentDir } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const runtimeServices = parseJsonObject(v.runtimeServicesJson ?? "");
|
||||||
|
if (runtimeServices && Array.isArray(runtimeServices.services)) {
|
||||||
|
ac.workspaceRuntime = runtimeServices;
|
||||||
|
}
|
||||||
if (v.command) ac.command = v.command;
|
if (v.command) ac.command = v.command;
|
||||||
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
|
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
|
||||||
return ac;
|
return ac;
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ Gateway connect identity fields:
|
|||||||
|
|
||||||
Request behavior fields:
|
Request behavior fields:
|
||||||
- payloadTemplate (object, optional): additional fields merged into gateway agent params
|
- payloadTemplate (object, optional): additional fields merged into gateway agent params
|
||||||
|
- workspaceRuntime (object, optional): desired runtime service intents; Paperclip forwards these in a standardized paperclip.workspaceRuntime block for remote execution environments
|
||||||
- timeoutSec (number, optional): adapter timeout in seconds (default 120)
|
- timeoutSec (number, optional): adapter timeout in seconds (default 120)
|
||||||
- waitTimeoutMs (number, optional): agent.wait timeout override (default timeoutSec * 1000)
|
- waitTimeoutMs (number, optional): agent.wait timeout override (default timeoutSec * 1000)
|
||||||
- autoPairOnFirstConnect (boolean, optional): on first "pairing required", attempt device.pair.list/device.pair.approve via shared auth, then retry once (default true)
|
- autoPairOnFirstConnect (boolean, optional): on first "pairing required", attempt device.pair.list/device.pair.approve via shared auth, then retry once (default true)
|
||||||
@@ -39,4 +40,15 @@ Request behavior fields:
|
|||||||
Session routing fields:
|
Session routing fields:
|
||||||
- sessionKeyStrategy (string, optional): issue (default), fixed, or run
|
- sessionKeyStrategy (string, optional): issue (default), fixed, or run
|
||||||
- sessionKey (string, optional): fixed session key when strategy=fixed (default paperclip)
|
- sessionKey (string, optional): fixed session key when strategy=fixed (default paperclip)
|
||||||
|
|
||||||
|
Standard outbound payload additions:
|
||||||
|
- paperclip (object): standardized Paperclip context added to every gateway agent request
|
||||||
|
- paperclip.workspace (object, optional): resolved execution workspace for this run
|
||||||
|
- paperclip.workspaces (array, optional): additional workspace hints Paperclip exposed to the run
|
||||||
|
- paperclip.workspaceRuntime (object, optional): normalized runtime service intent config for the workspace
|
||||||
|
|
||||||
|
Standard result metadata supported:
|
||||||
|
- meta.runtimeServices (array, optional): normalized adapter-managed runtime service reports
|
||||||
|
- meta.previewUrl (string, optional): shorthand single preview URL
|
||||||
|
- meta.previewUrls (string[], optional): shorthand multiple preview URLs
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
import type {
|
||||||
|
AdapterExecutionContext,
|
||||||
|
AdapterExecutionResult,
|
||||||
|
AdapterRuntimeServiceReport,
|
||||||
|
} from "@paperclipai/adapter-utils";
|
||||||
import { asNumber, asString, buildPaperclipEnv, parseObject } from "@paperclipai/adapter-utils/server-utils";
|
import { asNumber, asString, buildPaperclipEnv, parseObject } from "@paperclipai/adapter-utils/server-utils";
|
||||||
import crypto, { randomUUID } from "node:crypto";
|
import crypto, { randomUUID } from "node:crypto";
|
||||||
import { WebSocket } from "ws";
|
import { WebSocket } from "ws";
|
||||||
@@ -411,6 +415,58 @@ function appendWakeText(baseText: string, wakeText: string): string {
|
|||||||
return trimmedBase.length > 0 ? `${trimmedBase}\n\n${wakeText}` : wakeText;
|
return trimmedBase.length > 0 ? `${trimmedBase}\n\n${wakeText}` : wakeText;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildStandardPaperclipPayload(
|
||||||
|
ctx: AdapterExecutionContext,
|
||||||
|
wakePayload: WakePayload,
|
||||||
|
paperclipEnv: Record<string, string>,
|
||||||
|
payloadTemplate: Record<string, unknown>,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const templatePaperclip = parseObject(payloadTemplate.paperclip);
|
||||||
|
const workspace = asRecord(ctx.context.paperclipWorkspace);
|
||||||
|
const workspaces = Array.isArray(ctx.context.paperclipWorkspaces)
|
||||||
|
? ctx.context.paperclipWorkspaces.filter((entry): entry is Record<string, unknown> => Boolean(asRecord(entry)))
|
||||||
|
: [];
|
||||||
|
const configuredWorkspaceRuntime = parseObject(ctx.config.workspaceRuntime);
|
||||||
|
const runtimeServiceIntents = Array.isArray(ctx.context.paperclipRuntimeServiceIntents)
|
||||||
|
? ctx.context.paperclipRuntimeServiceIntents.filter(
|
||||||
|
(entry): entry is Record<string, unknown> => Boolean(asRecord(entry)),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const standardPaperclip: Record<string, unknown> = {
|
||||||
|
runId: ctx.runId,
|
||||||
|
companyId: ctx.agent.companyId,
|
||||||
|
agentId: ctx.agent.id,
|
||||||
|
agentName: ctx.agent.name,
|
||||||
|
taskId: wakePayload.taskId,
|
||||||
|
issueId: wakePayload.issueId,
|
||||||
|
issueIds: wakePayload.issueIds,
|
||||||
|
wakeReason: wakePayload.wakeReason,
|
||||||
|
wakeCommentId: wakePayload.wakeCommentId,
|
||||||
|
approvalId: wakePayload.approvalId,
|
||||||
|
approvalStatus: wakePayload.approvalStatus,
|
||||||
|
apiUrl: paperclipEnv.PAPERCLIP_API_URL ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (workspace) {
|
||||||
|
standardPaperclip.workspace = workspace;
|
||||||
|
}
|
||||||
|
if (workspaces.length > 0) {
|
||||||
|
standardPaperclip.workspaces = workspaces;
|
||||||
|
}
|
||||||
|
if (runtimeServiceIntents.length > 0 || Object.keys(configuredWorkspaceRuntime).length > 0) {
|
||||||
|
standardPaperclip.workspaceRuntime = {
|
||||||
|
...configuredWorkspaceRuntime,
|
||||||
|
...(runtimeServiceIntents.length > 0 ? { services: runtimeServiceIntents } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...templatePaperclip,
|
||||||
|
...standardPaperclip,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeUrl(input: string): URL | null {
|
function normalizeUrl(input: string): URL | null {
|
||||||
try {
|
try {
|
||||||
return new URL(input);
|
return new URL(input);
|
||||||
@@ -835,6 +891,91 @@ function parseUsage(value: unknown): AdapterExecutionResult["usage"] | undefined
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractRuntimeServicesFromMeta(meta: Record<string, unknown> | null): AdapterRuntimeServiceReport[] {
|
||||||
|
if (!meta) return [];
|
||||||
|
const reports: AdapterRuntimeServiceReport[] = [];
|
||||||
|
|
||||||
|
const runtimeServices = Array.isArray(meta.runtimeServices)
|
||||||
|
? meta.runtimeServices.filter((entry): entry is Record<string, unknown> => Boolean(asRecord(entry)))
|
||||||
|
: [];
|
||||||
|
for (const entry of runtimeServices) {
|
||||||
|
const serviceName = nonEmpty(entry.serviceName) ?? nonEmpty(entry.name);
|
||||||
|
if (!serviceName) continue;
|
||||||
|
const rawStatus = nonEmpty(entry.status)?.toLowerCase();
|
||||||
|
const status =
|
||||||
|
rawStatus === "starting" || rawStatus === "running" || rawStatus === "stopped" || rawStatus === "failed"
|
||||||
|
? rawStatus
|
||||||
|
: "running";
|
||||||
|
const rawLifecycle = nonEmpty(entry.lifecycle)?.toLowerCase();
|
||||||
|
const lifecycle = rawLifecycle === "shared" ? "shared" : "ephemeral";
|
||||||
|
const rawScopeType = nonEmpty(entry.scopeType)?.toLowerCase();
|
||||||
|
const scopeType =
|
||||||
|
rawScopeType === "project_workspace" ||
|
||||||
|
rawScopeType === "execution_workspace" ||
|
||||||
|
rawScopeType === "agent"
|
||||||
|
? rawScopeType
|
||||||
|
: "run";
|
||||||
|
const rawHealth = nonEmpty(entry.healthStatus)?.toLowerCase();
|
||||||
|
const healthStatus =
|
||||||
|
rawHealth === "healthy" || rawHealth === "unhealthy" || rawHealth === "unknown"
|
||||||
|
? rawHealth
|
||||||
|
: status === "running"
|
||||||
|
? "healthy"
|
||||||
|
: "unknown";
|
||||||
|
|
||||||
|
reports.push({
|
||||||
|
id: nonEmpty(entry.id),
|
||||||
|
projectId: nonEmpty(entry.projectId),
|
||||||
|
projectWorkspaceId: nonEmpty(entry.projectWorkspaceId),
|
||||||
|
issueId: nonEmpty(entry.issueId),
|
||||||
|
scopeType,
|
||||||
|
scopeId: nonEmpty(entry.scopeId),
|
||||||
|
serviceName,
|
||||||
|
status,
|
||||||
|
lifecycle,
|
||||||
|
reuseKey: nonEmpty(entry.reuseKey),
|
||||||
|
command: nonEmpty(entry.command),
|
||||||
|
cwd: nonEmpty(entry.cwd),
|
||||||
|
port: parseOptionalPositiveInteger(entry.port),
|
||||||
|
url: nonEmpty(entry.url),
|
||||||
|
providerRef: nonEmpty(entry.providerRef) ?? nonEmpty(entry.previewId),
|
||||||
|
ownerAgentId: nonEmpty(entry.ownerAgentId),
|
||||||
|
stopPolicy: asRecord(entry.stopPolicy),
|
||||||
|
healthStatus,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewUrl = nonEmpty(meta.previewUrl);
|
||||||
|
if (previewUrl) {
|
||||||
|
reports.push({
|
||||||
|
serviceName: "preview",
|
||||||
|
status: "running",
|
||||||
|
lifecycle: "ephemeral",
|
||||||
|
scopeType: "run",
|
||||||
|
url: previewUrl,
|
||||||
|
providerRef: nonEmpty(meta.previewId) ?? previewUrl,
|
||||||
|
healthStatus: "healthy",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewUrls = Array.isArray(meta.previewUrls)
|
||||||
|
? meta.previewUrls.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
|
||||||
|
: [];
|
||||||
|
previewUrls.forEach((url, index) => {
|
||||||
|
reports.push({
|
||||||
|
serviceName: index === 0 ? "preview" : `preview-${index + 1}`,
|
||||||
|
status: "running",
|
||||||
|
lifecycle: "ephemeral",
|
||||||
|
scopeType: "run",
|
||||||
|
url,
|
||||||
|
providerRef: `${url}#${index}`,
|
||||||
|
healthStatus: "healthy",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return reports;
|
||||||
|
}
|
||||||
|
|
||||||
function extractResultText(value: unknown): string | null {
|
function extractResultText(value: unknown): string | null {
|
||||||
const record = asRecord(value);
|
const record = asRecord(value);
|
||||||
if (!record) return null;
|
if (!record) return null;
|
||||||
@@ -924,9 +1065,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
|
|
||||||
const templateMessage = nonEmpty(payloadTemplate.message) ?? nonEmpty(payloadTemplate.text);
|
const templateMessage = nonEmpty(payloadTemplate.message) ?? nonEmpty(payloadTemplate.text);
|
||||||
const message = templateMessage ? appendWakeText(templateMessage, wakeText) : wakeText;
|
const message = templateMessage ? appendWakeText(templateMessage, wakeText) : wakeText;
|
||||||
|
const paperclipPayload = buildStandardPaperclipPayload(ctx, wakePayload, paperclipEnv, payloadTemplate);
|
||||||
|
|
||||||
const agentParams: Record<string, unknown> = {
|
const agentParams: Record<string, unknown> = {
|
||||||
...payloadTemplate,
|
...payloadTemplate,
|
||||||
|
paperclip: paperclipPayload,
|
||||||
message,
|
message,
|
||||||
sessionKey,
|
sessionKey,
|
||||||
idempotencyKey: ctx.runId,
|
idempotencyKey: ctx.runId,
|
||||||
@@ -1188,12 +1331,24 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
null;
|
null;
|
||||||
const summary = summaryFromEvents || summaryFromPayload || null;
|
const summary = summaryFromEvents || summaryFromPayload || null;
|
||||||
|
|
||||||
const meta = asRecord(asRecord(acceptedPayload?.result)?.meta) ?? asRecord(acceptedPayload?.meta);
|
const acceptedResult = asRecord(acceptedPayload?.result);
|
||||||
const agentMeta = asRecord(meta?.agentMeta);
|
const latestPayload = asRecord(latestResultPayload);
|
||||||
const usage = parseUsage(agentMeta?.usage ?? meta?.usage);
|
const latestResult = asRecord(latestPayload?.result);
|
||||||
const provider = nonEmpty(agentMeta?.provider) ?? nonEmpty(meta?.provider) ?? "openclaw";
|
const acceptedMeta = asRecord(acceptedResult?.meta) ?? asRecord(acceptedPayload?.meta);
|
||||||
const model = nonEmpty(agentMeta?.model) ?? nonEmpty(meta?.model) ?? null;
|
const latestMeta = asRecord(latestResult?.meta) ?? asRecord(latestPayload?.meta);
|
||||||
const costUsd = asNumber(agentMeta?.costUsd ?? meta?.costUsd, 0);
|
const mergedMeta = {
|
||||||
|
...(acceptedMeta ?? {}),
|
||||||
|
...(latestMeta ?? {}),
|
||||||
|
};
|
||||||
|
const agentMeta =
|
||||||
|
asRecord(mergedMeta.agentMeta) ??
|
||||||
|
asRecord(acceptedMeta?.agentMeta) ??
|
||||||
|
asRecord(latestMeta?.agentMeta);
|
||||||
|
const usage = parseUsage(agentMeta?.usage ?? mergedMeta.usage);
|
||||||
|
const runtimeServices = extractRuntimeServicesFromMeta(agentMeta ?? mergedMeta);
|
||||||
|
const provider = nonEmpty(agentMeta?.provider) ?? nonEmpty(mergedMeta.provider) ?? "openclaw";
|
||||||
|
const model = nonEmpty(agentMeta?.model) ?? nonEmpty(mergedMeta.model) ?? null;
|
||||||
|
const costUsd = asNumber(agentMeta?.costUsd ?? mergedMeta.costUsd, 0);
|
||||||
|
|
||||||
await ctx.onLog(
|
await ctx.onLog(
|
||||||
"stdout",
|
"stdout",
|
||||||
@@ -1209,6 +1364,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
...(usage ? { usage } : {}),
|
...(usage ? { usage } : {}),
|
||||||
...(costUsd > 0 ? { costUsd } : {}),
|
...(costUsd > 0 ? { costUsd } : {}),
|
||||||
resultJson: asRecord(latestResultPayload),
|
resultJson: asRecord(latestResultPayload),
|
||||||
|
...(runtimeServices.length > 0 ? { runtimeServices } : {}),
|
||||||
...(summary ? { summary } : {}),
|
...(summary ? { summary } : {}),
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
|
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
|
||||||
|
|
||||||
|
function parseJsonObject(text: string): Record<string, unknown> | null {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed);
|
||||||
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
||||||
|
return parsed as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function buildOpenClawGatewayConfig(v: CreateConfigValues): Record<string, unknown> {
|
export function buildOpenClawGatewayConfig(v: CreateConfigValues): Record<string, unknown> {
|
||||||
const ac: Record<string, unknown> = {};
|
const ac: Record<string, unknown> = {};
|
||||||
if (v.url) ac.url = v.url;
|
if (v.url) ac.url = v.url;
|
||||||
@@ -8,5 +20,11 @@ export function buildOpenClawGatewayConfig(v: CreateConfigValues): Record<string
|
|||||||
ac.sessionKeyStrategy = "issue";
|
ac.sessionKeyStrategy = "issue";
|
||||||
ac.role = "operator";
|
ac.role = "operator";
|
||||||
ac.scopes = ["operator.admin"];
|
ac.scopes = ["operator.admin"];
|
||||||
|
const payloadTemplate = parseJsonObject(v.payloadTemplateJson ?? "");
|
||||||
|
if (payloadTemplate) ac.payloadTemplate = payloadTemplate;
|
||||||
|
const runtimeServices = parseJsonObject(v.runtimeServicesJson ?? "");
|
||||||
|
if (runtimeServices && Array.isArray(runtimeServices.services)) {
|
||||||
|
ac.workspaceRuntime = runtimeServices;
|
||||||
|
}
|
||||||
return ac;
|
return ac;
|
||||||
}
|
}
|
||||||
|
|||||||
39
packages/db/src/migrations/0026_lying_pete_wisdom.sql
Normal file
39
packages/db/src/migrations/0026_lying_pete_wisdom.sql
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
CREATE TABLE "workspace_runtime_services" (
|
||||||
|
"id" uuid PRIMARY KEY NOT NULL,
|
||||||
|
"company_id" uuid NOT NULL,
|
||||||
|
"project_id" uuid,
|
||||||
|
"project_workspace_id" uuid,
|
||||||
|
"issue_id" uuid,
|
||||||
|
"scope_type" text NOT NULL,
|
||||||
|
"scope_id" text,
|
||||||
|
"service_name" text NOT NULL,
|
||||||
|
"status" text NOT NULL,
|
||||||
|
"lifecycle" text NOT NULL,
|
||||||
|
"reuse_key" text,
|
||||||
|
"command" text,
|
||||||
|
"cwd" text,
|
||||||
|
"port" integer,
|
||||||
|
"url" text,
|
||||||
|
"provider" text NOT NULL,
|
||||||
|
"provider_ref" text,
|
||||||
|
"owner_agent_id" uuid,
|
||||||
|
"started_by_run_id" uuid,
|
||||||
|
"last_used_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"started_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"stopped_at" timestamp with time zone,
|
||||||
|
"stop_policy" jsonb,
|
||||||
|
"health_status" text DEFAULT 'unknown' NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "workspace_runtime_services" ADD CONSTRAINT "workspace_runtime_services_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "workspace_runtime_services" ADD CONSTRAINT "workspace_runtime_services_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "workspace_runtime_services" ADD CONSTRAINT "workspace_runtime_services_project_workspace_id_project_workspaces_id_fk" FOREIGN KEY ("project_workspace_id") REFERENCES "public"."project_workspaces"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "workspace_runtime_services" ADD CONSTRAINT "workspace_runtime_services_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "workspace_runtime_services" ADD CONSTRAINT "workspace_runtime_services_owner_agent_id_agents_id_fk" FOREIGN KEY ("owner_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "workspace_runtime_services" ADD CONSTRAINT "workspace_runtime_services_started_by_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("started_by_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "workspace_runtime_services_company_workspace_status_idx" ON "workspace_runtime_services" USING btree ("company_id","project_workspace_id","status");--> statement-breakpoint
|
||||||
|
CREATE INDEX "workspace_runtime_services_company_project_status_idx" ON "workspace_runtime_services" USING btree ("company_id","project_id","status");--> statement-breakpoint
|
||||||
|
CREATE INDEX "workspace_runtime_services_run_idx" ON "workspace_runtime_services" USING btree ("started_by_run_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "workspace_runtime_services_company_updated_idx" ON "workspace_runtime_services" USING btree ("company_id","updated_at");
|
||||||
6193
packages/db/src/migrations/meta/0026_snapshot.json
Normal file
6193
packages/db/src/migrations/meta/0026_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -183,6 +183,13 @@
|
|||||||
"when": 1772807461603,
|
"when": 1772807461603,
|
||||||
"tag": "0025_nasty_salo",
|
"tag": "0025_nasty_salo",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 26,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1773089625430,
|
||||||
|
"tag": "0026_lying_pete_wisdom",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -13,6 +13,7 @@ export { agentTaskSessions } from "./agent_task_sessions.js";
|
|||||||
export { agentWakeupRequests } from "./agent_wakeup_requests.js";
|
export { agentWakeupRequests } from "./agent_wakeup_requests.js";
|
||||||
export { projects } from "./projects.js";
|
export { projects } from "./projects.js";
|
||||||
export { projectWorkspaces } from "./project_workspaces.js";
|
export { projectWorkspaces } from "./project_workspaces.js";
|
||||||
|
export { workspaceRuntimeServices } from "./workspace_runtime_services.js";
|
||||||
export { projectGoals } from "./project_goals.js";
|
export { projectGoals } from "./project_goals.js";
|
||||||
export { goals } from "./goals.js";
|
export { goals } from "./goals.js";
|
||||||
export { issues } from "./issues.js";
|
export { issues } from "./issues.js";
|
||||||
|
|||||||
64
packages/db/src/schema/workspace_runtime_services.ts
Normal file
64
packages/db/src/schema/workspace_runtime_services.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import {
|
||||||
|
index,
|
||||||
|
integer,
|
||||||
|
jsonb,
|
||||||
|
pgTable,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
uuid,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
import { companies } from "./companies.js";
|
||||||
|
import { projects } from "./projects.js";
|
||||||
|
import { projectWorkspaces } from "./project_workspaces.js";
|
||||||
|
import { issues } from "./issues.js";
|
||||||
|
import { agents } from "./agents.js";
|
||||||
|
import { heartbeatRuns } from "./heartbeat_runs.js";
|
||||||
|
|
||||||
|
export const workspaceRuntimeServices = pgTable(
|
||||||
|
"workspace_runtime_services",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey(),
|
||||||
|
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||||
|
projectId: uuid("project_id").references(() => projects.id, { onDelete: "set null" }),
|
||||||
|
projectWorkspaceId: uuid("project_workspace_id").references(() => projectWorkspaces.id, { onDelete: "set null" }),
|
||||||
|
issueId: uuid("issue_id").references(() => issues.id, { onDelete: "set null" }),
|
||||||
|
scopeType: text("scope_type").notNull(),
|
||||||
|
scopeId: text("scope_id"),
|
||||||
|
serviceName: text("service_name").notNull(),
|
||||||
|
status: text("status").notNull(),
|
||||||
|
lifecycle: text("lifecycle").notNull(),
|
||||||
|
reuseKey: text("reuse_key"),
|
||||||
|
command: text("command"),
|
||||||
|
cwd: text("cwd"),
|
||||||
|
port: integer("port"),
|
||||||
|
url: text("url"),
|
||||||
|
provider: text("provider").notNull(),
|
||||||
|
providerRef: text("provider_ref"),
|
||||||
|
ownerAgentId: uuid("owner_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||||
|
startedByRunId: uuid("started_by_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
|
||||||
|
lastUsedAt: timestamp("last_used_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
startedAt: timestamp("started_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
stoppedAt: timestamp("stopped_at", { withTimezone: true }),
|
||||||
|
stopPolicy: jsonb("stop_policy").$type<Record<string, unknown>>(),
|
||||||
|
healthStatus: text("health_status").notNull().default("unknown"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
companyWorkspaceStatusIdx: index("workspace_runtime_services_company_workspace_status_idx").on(
|
||||||
|
table.companyId,
|
||||||
|
table.projectWorkspaceId,
|
||||||
|
table.status,
|
||||||
|
),
|
||||||
|
companyProjectStatusIdx: index("workspace_runtime_services_company_project_status_idx").on(
|
||||||
|
table.companyId,
|
||||||
|
table.projectId,
|
||||||
|
table.status,
|
||||||
|
),
|
||||||
|
runIdx: index("workspace_runtime_services_run_idx").on(table.startedByRunId),
|
||||||
|
companyUpdatedIdx: index("workspace_runtime_services_company_updated_idx").on(
|
||||||
|
table.companyId,
|
||||||
|
table.updatedAt,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -77,6 +77,7 @@ export type {
|
|||||||
Project,
|
Project,
|
||||||
ProjectGoalRef,
|
ProjectGoalRef,
|
||||||
ProjectWorkspace,
|
ProjectWorkspace,
|
||||||
|
WorkspaceRuntimeService,
|
||||||
Issue,
|
Issue,
|
||||||
IssueAssigneeAdapterOverrides,
|
IssueAssigneeAdapterOverrides,
|
||||||
IssueComment,
|
IssueComment,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export type {
|
|||||||
} from "./agent.js";
|
} from "./agent.js";
|
||||||
export type { AssetImage } from "./asset.js";
|
export type { AssetImage } from "./asset.js";
|
||||||
export type { Project, ProjectGoalRef, ProjectWorkspace } from "./project.js";
|
export type { Project, ProjectGoalRef, ProjectWorkspace } from "./project.js";
|
||||||
|
export type { WorkspaceRuntimeService } from "./workspace-runtime.js";
|
||||||
export type {
|
export type {
|
||||||
Issue,
|
Issue,
|
||||||
IssueAssigneeAdapterOverrides,
|
IssueAssigneeAdapterOverrides,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ProjectStatus } from "../constants.js";
|
import type { ProjectStatus } from "../constants.js";
|
||||||
|
import type { WorkspaceRuntimeService } from "./workspace-runtime.js";
|
||||||
|
|
||||||
export interface ProjectGoalRef {
|
export interface ProjectGoalRef {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -15,6 +16,7 @@ export interface ProjectWorkspace {
|
|||||||
repoRef: string | null;
|
repoRef: string | null;
|
||||||
metadata: Record<string, unknown> | null;
|
metadata: Record<string, unknown> | null;
|
||||||
isPrimary: boolean;
|
isPrimary: boolean;
|
||||||
|
runtimeServices?: WorkspaceRuntimeService[];
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
28
packages/shared/src/types/workspace-runtime.ts
Normal file
28
packages/shared/src/types/workspace-runtime.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
export interface WorkspaceRuntimeService {
|
||||||
|
id: string;
|
||||||
|
companyId: string;
|
||||||
|
projectId: string | null;
|
||||||
|
projectWorkspaceId: string | null;
|
||||||
|
issueId: string | null;
|
||||||
|
scopeType: "project_workspace" | "execution_workspace" | "run" | "agent";
|
||||||
|
scopeId: string | null;
|
||||||
|
serviceName: string;
|
||||||
|
status: "starting" | "running" | "stopped" | "failed";
|
||||||
|
lifecycle: "shared" | "ephemeral";
|
||||||
|
reuseKey: string | null;
|
||||||
|
command: string | null;
|
||||||
|
cwd: string | null;
|
||||||
|
port: number | null;
|
||||||
|
url: string | null;
|
||||||
|
provider: "local_process" | "adapter_managed";
|
||||||
|
providerRef: string | null;
|
||||||
|
ownerAgentId: string | null;
|
||||||
|
startedByRunId: string | null;
|
||||||
|
lastUsedAt: Date;
|
||||||
|
startedAt: Date;
|
||||||
|
stoppedAt: Date | null;
|
||||||
|
stopPolicy: Record<string, unknown> | null;
|
||||||
|
healthStatus: "unknown" | "healthy" | "unhealthy";
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
@@ -2,7 +2,10 @@ import { afterEach, describe, expect, it } from "vitest";
|
|||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
import { WebSocketServer } from "ws";
|
import { WebSocketServer } from "ws";
|
||||||
import { execute, testEnvironment } from "@paperclipai/adapter-openclaw-gateway/server";
|
import { execute, testEnvironment } from "@paperclipai/adapter-openclaw-gateway/server";
|
||||||
import { parseOpenClawGatewayStdoutLine } from "@paperclipai/adapter-openclaw-gateway/ui";
|
import {
|
||||||
|
buildOpenClawGatewayConfig,
|
||||||
|
parseOpenClawGatewayStdoutLine,
|
||||||
|
} from "@paperclipai/adapter-openclaw-gateway/ui";
|
||||||
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
|
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
|
||||||
|
|
||||||
function buildContext(
|
function buildContext(
|
||||||
@@ -36,7 +39,9 @@ function buildContext(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createMockGatewayServer() {
|
async function createMockGatewayServer(options?: {
|
||||||
|
waitPayload?: Record<string, unknown>;
|
||||||
|
}) {
|
||||||
const server = createServer();
|
const server = createServer();
|
||||||
const wss = new WebSocketServer({ server });
|
const wss = new WebSocketServer({ server });
|
||||||
|
|
||||||
@@ -136,7 +141,7 @@ async function createMockGatewayServer() {
|
|||||||
type: "res",
|
type: "res",
|
||||||
id: frame.id,
|
id: frame.id,
|
||||||
ok: true,
|
ok: true,
|
||||||
payload: {
|
payload: options?.waitPayload ?? {
|
||||||
runId: frame.params?.runId,
|
runId: frame.params?.runId,
|
||||||
status: "ok",
|
status: "ok",
|
||||||
startedAt: 1,
|
startedAt: 1,
|
||||||
@@ -412,6 +417,29 @@ describe("openclaw gateway adapter execute", () => {
|
|||||||
onLog: async (_stream, chunk) => {
|
onLog: async (_stream, chunk) => {
|
||||||
logs.push(chunk);
|
logs.push(chunk);
|
||||||
},
|
},
|
||||||
|
context: {
|
||||||
|
taskId: "task-123",
|
||||||
|
issueId: "issue-123",
|
||||||
|
wakeReason: "issue_assigned",
|
||||||
|
issueIds: ["issue-123"],
|
||||||
|
paperclipWorkspace: {
|
||||||
|
cwd: "/tmp/worktrees/pap-123",
|
||||||
|
strategy: "git_worktree",
|
||||||
|
branchName: "pap-123-test",
|
||||||
|
},
|
||||||
|
paperclipWorkspaces: [
|
||||||
|
{
|
||||||
|
id: "workspace-1",
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
paperclipRuntimeServiceIntents: [
|
||||||
|
{
|
||||||
|
name: "preview",
|
||||||
|
lifecycle: "ephemeral",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -428,6 +456,33 @@ describe("openclaw gateway adapter execute", () => {
|
|||||||
expect(String(payload?.message ?? "")).toContain("wake now");
|
expect(String(payload?.message ?? "")).toContain("wake now");
|
||||||
expect(String(payload?.message ?? "")).toContain("PAPERCLIP_RUN_ID=run-123");
|
expect(String(payload?.message ?? "")).toContain("PAPERCLIP_RUN_ID=run-123");
|
||||||
expect(String(payload?.message ?? "")).toContain("PAPERCLIP_TASK_ID=task-123");
|
expect(String(payload?.message ?? "")).toContain("PAPERCLIP_TASK_ID=task-123");
|
||||||
|
expect(payload?.paperclip).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
runId: "run-123",
|
||||||
|
companyId: "company-123",
|
||||||
|
agentId: "agent-123",
|
||||||
|
taskId: "task-123",
|
||||||
|
issueId: "issue-123",
|
||||||
|
workspace: expect.objectContaining({
|
||||||
|
cwd: "/tmp/worktrees/pap-123",
|
||||||
|
strategy: "git_worktree",
|
||||||
|
}),
|
||||||
|
workspaces: [
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "workspace-1",
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
workspaceRuntime: expect.objectContaining({
|
||||||
|
services: [
|
||||||
|
expect.objectContaining({
|
||||||
|
name: "preview",
|
||||||
|
lifecycle: "ephemeral",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
expect(logs.some((entry) => entry.includes("[openclaw-gateway:event] run=run-123 stream=assistant"))).toBe(true);
|
expect(logs.some((entry) => entry.includes("[openclaw-gateway:event] run=run-123 stream=assistant"))).toBe(true);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -441,6 +496,54 @@ describe("openclaw gateway adapter execute", () => {
|
|||||||
expect(result.errorCode).toBe("openclaw_gateway_url_missing");
|
expect(result.errorCode).toBe("openclaw_gateway_url_missing");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns adapter-managed runtime services from gateway result meta", async () => {
|
||||||
|
const gateway = await createMockGatewayServer({
|
||||||
|
waitPayload: {
|
||||||
|
runId: "run-123",
|
||||||
|
status: "ok",
|
||||||
|
startedAt: 1,
|
||||||
|
endedAt: 2,
|
||||||
|
meta: {
|
||||||
|
runtimeServices: [
|
||||||
|
{
|
||||||
|
name: "preview",
|
||||||
|
scopeType: "run",
|
||||||
|
url: "https://preview.example/run-123",
|
||||||
|
providerRef: "sandbox-123",
|
||||||
|
lifecycle: "ephemeral",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await execute(
|
||||||
|
buildContext({
|
||||||
|
url: gateway.url,
|
||||||
|
headers: {
|
||||||
|
"x-openclaw-token": "gateway-token",
|
||||||
|
},
|
||||||
|
waitTimeoutMs: 2000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
expect(result.runtimeServices).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
serviceName: "preview",
|
||||||
|
scopeType: "run",
|
||||||
|
url: "https://preview.example/run-123",
|
||||||
|
providerRef: "sandbox-123",
|
||||||
|
lifecycle: "ephemeral",
|
||||||
|
status: "running",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
await gateway.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("auto-approves pairing once and retries the run", async () => {
|
it("auto-approves pairing once and retries the run", async () => {
|
||||||
const gateway = await createMockGatewayServerWithPairing();
|
const gateway = await createMockGatewayServerWithPairing();
|
||||||
const logs: string[] = [];
|
const logs: string[] = [];
|
||||||
@@ -479,6 +582,62 @@ describe("openclaw gateway adapter execute", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("openclaw gateway ui build config", () => {
|
||||||
|
it("parses payload template and runtime services json", () => {
|
||||||
|
const config = buildOpenClawGatewayConfig({
|
||||||
|
adapterType: "openclaw_gateway",
|
||||||
|
cwd: "",
|
||||||
|
promptTemplate: "",
|
||||||
|
model: "",
|
||||||
|
thinkingEffort: "",
|
||||||
|
chrome: false,
|
||||||
|
dangerouslySkipPermissions: false,
|
||||||
|
search: false,
|
||||||
|
dangerouslyBypassSandbox: false,
|
||||||
|
command: "",
|
||||||
|
args: "",
|
||||||
|
extraArgs: "",
|
||||||
|
envVars: "",
|
||||||
|
envBindings: {},
|
||||||
|
url: "wss://gateway.example/ws",
|
||||||
|
payloadTemplateJson: JSON.stringify({
|
||||||
|
agentId: "remote-agent-123",
|
||||||
|
metadata: { team: "platform" },
|
||||||
|
}),
|
||||||
|
runtimeServicesJson: JSON.stringify({
|
||||||
|
services: [
|
||||||
|
{
|
||||||
|
name: "preview",
|
||||||
|
lifecycle: "shared",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
bootstrapPrompt: "",
|
||||||
|
maxTurnsPerRun: 0,
|
||||||
|
heartbeatEnabled: true,
|
||||||
|
intervalSec: 300,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(config).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
url: "wss://gateway.example/ws",
|
||||||
|
payloadTemplate: {
|
||||||
|
agentId: "remote-agent-123",
|
||||||
|
metadata: { team: "platform" },
|
||||||
|
},
|
||||||
|
workspaceRuntime: {
|
||||||
|
services: [
|
||||||
|
{
|
||||||
|
name: "preview",
|
||||||
|
lifecycle: "shared",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("openclaw gateway testEnvironment", () => {
|
describe("openclaw gateway testEnvironment", () => {
|
||||||
it("reports missing url as failure", async () => {
|
it("reports missing url as failure", async () => {
|
||||||
const result = await testEnvironment({
|
const result = await testEnvironment({
|
||||||
|
|||||||
300
server/src/__tests__/workspace-runtime.test.ts
Normal file
300
server/src/__tests__/workspace-runtime.test.ts
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
ensureRuntimeServicesForRun,
|
||||||
|
normalizeAdapterManagedRuntimeServices,
|
||||||
|
realizeExecutionWorkspace,
|
||||||
|
releaseRuntimeServicesForRun,
|
||||||
|
type RealizedExecutionWorkspace,
|
||||||
|
} from "../services/workspace-runtime.ts";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
const leasedRunIds = new Set<string>();
|
||||||
|
|
||||||
|
async function runGit(cwd: string, args: string[]) {
|
||||||
|
await execFileAsync("git", args, { cwd });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTempRepo() {
|
||||||
|
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-repo-"));
|
||||||
|
await runGit(repoRoot, ["init"]);
|
||||||
|
await runGit(repoRoot, ["config", "user.email", "paperclip@example.com"]);
|
||||||
|
await runGit(repoRoot, ["config", "user.name", "Paperclip Test"]);
|
||||||
|
await fs.writeFile(path.join(repoRoot, "README.md"), "hello\n", "utf8");
|
||||||
|
await runGit(repoRoot, ["add", "README.md"]);
|
||||||
|
await runGit(repoRoot, ["commit", "-m", "Initial commit"]);
|
||||||
|
await runGit(repoRoot, ["checkout", "-B", "main"]);
|
||||||
|
return repoRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWorkspace(cwd: string): RealizedExecutionWorkspace {
|
||||||
|
return {
|
||||||
|
baseCwd: cwd,
|
||||||
|
source: "project_primary",
|
||||||
|
projectId: "project-1",
|
||||||
|
workspaceId: "workspace-1",
|
||||||
|
repoUrl: null,
|
||||||
|
repoRef: "HEAD",
|
||||||
|
strategy: "project_primary",
|
||||||
|
cwd,
|
||||||
|
branchName: null,
|
||||||
|
worktreePath: null,
|
||||||
|
warnings: [],
|
||||||
|
created: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
Array.from(leasedRunIds).map(async (runId) => {
|
||||||
|
await releaseRuntimeServicesForRun(runId);
|
||||||
|
leasedRunIds.delete(runId);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("realizeExecutionWorkspace", () => {
|
||||||
|
it("creates and reuses a git worktree for an issue-scoped branch", async () => {
|
||||||
|
const repoRoot = await createTempRepo();
|
||||||
|
|
||||||
|
const first = await realizeExecutionWorkspace({
|
||||||
|
base: {
|
||||||
|
baseCwd: repoRoot,
|
||||||
|
source: "project_primary",
|
||||||
|
projectId: "project-1",
|
||||||
|
workspaceId: "workspace-1",
|
||||||
|
repoUrl: null,
|
||||||
|
repoRef: "HEAD",
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
workspaceStrategy: {
|
||||||
|
type: "git_worktree",
|
||||||
|
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
issue: {
|
||||||
|
id: "issue-1",
|
||||||
|
identifier: "PAP-447",
|
||||||
|
title: "Add Worktree Support",
|
||||||
|
},
|
||||||
|
agent: {
|
||||||
|
id: "agent-1",
|
||||||
|
name: "Codex Coder",
|
||||||
|
companyId: "company-1",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(first.strategy).toBe("git_worktree");
|
||||||
|
expect(first.created).toBe(true);
|
||||||
|
expect(first.branchName).toBe("PAP-447-add-worktree-support");
|
||||||
|
expect(first.cwd).toContain(path.join(".paperclip", "worktrees"));
|
||||||
|
await expect(fs.stat(path.join(first.cwd, ".git"))).resolves.toBeTruthy();
|
||||||
|
|
||||||
|
const second = await realizeExecutionWorkspace({
|
||||||
|
base: {
|
||||||
|
baseCwd: repoRoot,
|
||||||
|
source: "project_primary",
|
||||||
|
projectId: "project-1",
|
||||||
|
workspaceId: "workspace-1",
|
||||||
|
repoUrl: null,
|
||||||
|
repoRef: "HEAD",
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
workspaceStrategy: {
|
||||||
|
type: "git_worktree",
|
||||||
|
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
issue: {
|
||||||
|
id: "issue-1",
|
||||||
|
identifier: "PAP-447",
|
||||||
|
title: "Add Worktree Support",
|
||||||
|
},
|
||||||
|
agent: {
|
||||||
|
id: "agent-1",
|
||||||
|
name: "Codex Coder",
|
||||||
|
companyId: "company-1",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(second.created).toBe(false);
|
||||||
|
expect(second.cwd).toBe(first.cwd);
|
||||||
|
expect(second.branchName).toBe(first.branchName);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ensureRuntimeServicesForRun", () => {
|
||||||
|
it("reuses shared runtime services across runs and starts a new service after release", async () => {
|
||||||
|
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-workspace-"));
|
||||||
|
const workspace = buildWorkspace(workspaceRoot);
|
||||||
|
const serviceCommand =
|
||||||
|
"node -e \"require('node:http').createServer((req,res)=>res.end('ok')).listen(Number(process.env.PORT), '127.0.0.1')\"";
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
workspaceRuntime: {
|
||||||
|
services: [
|
||||||
|
{
|
||||||
|
name: "web",
|
||||||
|
command: serviceCommand,
|
||||||
|
port: { type: "auto" },
|
||||||
|
readiness: {
|
||||||
|
type: "http",
|
||||||
|
urlTemplate: "http://127.0.0.1:{{port}}",
|
||||||
|
timeoutSec: 10,
|
||||||
|
intervalMs: 100,
|
||||||
|
},
|
||||||
|
expose: {
|
||||||
|
type: "url",
|
||||||
|
urlTemplate: "http://127.0.0.1:{{port}}",
|
||||||
|
},
|
||||||
|
lifecycle: "shared",
|
||||||
|
reuseScope: "project_workspace",
|
||||||
|
stopPolicy: {
|
||||||
|
type: "on_run_finish",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const run1 = "run-1";
|
||||||
|
const run2 = "run-2";
|
||||||
|
leasedRunIds.add(run1);
|
||||||
|
leasedRunIds.add(run2);
|
||||||
|
|
||||||
|
const first = await ensureRuntimeServicesForRun({
|
||||||
|
runId: run1,
|
||||||
|
agent: {
|
||||||
|
id: "agent-1",
|
||||||
|
name: "Codex Coder",
|
||||||
|
companyId: "company-1",
|
||||||
|
},
|
||||||
|
issue: null,
|
||||||
|
workspace,
|
||||||
|
config,
|
||||||
|
adapterEnv: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(first).toHaveLength(1);
|
||||||
|
expect(first[0]?.reused).toBe(false);
|
||||||
|
expect(first[0]?.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
|
||||||
|
const response = await fetch(first[0]!.url!);
|
||||||
|
expect(await response.text()).toBe("ok");
|
||||||
|
|
||||||
|
const second = await ensureRuntimeServicesForRun({
|
||||||
|
runId: run2,
|
||||||
|
agent: {
|
||||||
|
id: "agent-1",
|
||||||
|
name: "Codex Coder",
|
||||||
|
companyId: "company-1",
|
||||||
|
},
|
||||||
|
issue: null,
|
||||||
|
workspace,
|
||||||
|
config,
|
||||||
|
adapterEnv: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(second).toHaveLength(1);
|
||||||
|
expect(second[0]?.reused).toBe(true);
|
||||||
|
expect(second[0]?.id).toBe(first[0]?.id);
|
||||||
|
|
||||||
|
await releaseRuntimeServicesForRun(run1);
|
||||||
|
leasedRunIds.delete(run1);
|
||||||
|
await releaseRuntimeServicesForRun(run2);
|
||||||
|
leasedRunIds.delete(run2);
|
||||||
|
|
||||||
|
const run3 = "run-3";
|
||||||
|
leasedRunIds.add(run3);
|
||||||
|
const third = await ensureRuntimeServicesForRun({
|
||||||
|
runId: run3,
|
||||||
|
agent: {
|
||||||
|
id: "agent-1",
|
||||||
|
name: "Codex Coder",
|
||||||
|
companyId: "company-1",
|
||||||
|
},
|
||||||
|
issue: null,
|
||||||
|
workspace,
|
||||||
|
config,
|
||||||
|
adapterEnv: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(third).toHaveLength(1);
|
||||||
|
expect(third[0]?.reused).toBe(false);
|
||||||
|
expect(third[0]?.id).not.toBe(first[0]?.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("normalizeAdapterManagedRuntimeServices", () => {
|
||||||
|
it("fills workspace defaults and derives stable ids for adapter-managed services", () => {
|
||||||
|
const workspace = buildWorkspace("/tmp/project");
|
||||||
|
const now = new Date("2026-03-09T12:00:00.000Z");
|
||||||
|
|
||||||
|
const first = normalizeAdapterManagedRuntimeServices({
|
||||||
|
adapterType: "openclaw_gateway",
|
||||||
|
runId: "run-1",
|
||||||
|
agent: {
|
||||||
|
id: "agent-1",
|
||||||
|
name: "Gateway Agent",
|
||||||
|
companyId: "company-1",
|
||||||
|
},
|
||||||
|
issue: {
|
||||||
|
id: "issue-1",
|
||||||
|
identifier: "PAP-447",
|
||||||
|
title: "Worktree support",
|
||||||
|
},
|
||||||
|
workspace,
|
||||||
|
reports: [
|
||||||
|
{
|
||||||
|
serviceName: "preview",
|
||||||
|
url: "https://preview.example/run-1",
|
||||||
|
providerRef: "sandbox-123",
|
||||||
|
scopeType: "run",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
now,
|
||||||
|
});
|
||||||
|
|
||||||
|
const second = normalizeAdapterManagedRuntimeServices({
|
||||||
|
adapterType: "openclaw_gateway",
|
||||||
|
runId: "run-1",
|
||||||
|
agent: {
|
||||||
|
id: "agent-1",
|
||||||
|
name: "Gateway Agent",
|
||||||
|
companyId: "company-1",
|
||||||
|
},
|
||||||
|
issue: {
|
||||||
|
id: "issue-1",
|
||||||
|
identifier: "PAP-447",
|
||||||
|
title: "Worktree support",
|
||||||
|
},
|
||||||
|
workspace,
|
||||||
|
reports: [
|
||||||
|
{
|
||||||
|
serviceName: "preview",
|
||||||
|
url: "https://preview.example/run-1",
|
||||||
|
providerRef: "sandbox-123",
|
||||||
|
scopeType: "run",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
now,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(first).toHaveLength(1);
|
||||||
|
expect(first[0]).toMatchObject({
|
||||||
|
companyId: "company-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
projectWorkspaceId: "workspace-1",
|
||||||
|
issueId: "issue-1",
|
||||||
|
serviceName: "preview",
|
||||||
|
provider: "adapter_managed",
|
||||||
|
status: "running",
|
||||||
|
healthStatus: "healthy",
|
||||||
|
startedByRunId: "run-1",
|
||||||
|
});
|
||||||
|
expect(first[0]?.id).toBe(second[0]?.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -25,7 +25,7 @@ import { createApp } from "./app.js";
|
|||||||
import { loadConfig } from "./config.js";
|
import { loadConfig } from "./config.js";
|
||||||
import { logger } from "./middleware/logger.js";
|
import { logger } from "./middleware/logger.js";
|
||||||
import { setupLiveEventsWebSocketServer } from "./realtime/live-events-ws.js";
|
import { setupLiveEventsWebSocketServer } from "./realtime/live-events-ws.js";
|
||||||
import { heartbeatService } from "./services/index.js";
|
import { heartbeatService, reconcilePersistedRuntimeServicesOnStartup } from "./services/index.js";
|
||||||
import { createStorageServiceFromConfig } from "./storage/index.js";
|
import { createStorageServiceFromConfig } from "./storage/index.js";
|
||||||
import { printStartupBanner } from "./startup-banner.js";
|
import { printStartupBanner } from "./startup-banner.js";
|
||||||
import { getBoardClaimWarningUrl, initializeBoardClaimChallenge } from "./board-claim.js";
|
import { getBoardClaimWarningUrl, initializeBoardClaimChallenge } from "./board-claim.js";
|
||||||
@@ -496,6 +496,19 @@ export async function startServer(): Promise<StartedServer> {
|
|||||||
resolveSessionFromHeaders,
|
resolveSessionFromHeaders,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void reconcilePersistedRuntimeServicesOnStartup(db as any)
|
||||||
|
.then((result) => {
|
||||||
|
if (result.reconciled > 0) {
|
||||||
|
logger.warn(
|
||||||
|
{ reconciled: result.reconciled },
|
||||||
|
"reconciled persisted runtime services from a previous server process",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
logger.error({ err }, "startup reconciliation of persisted runtime services failed");
|
||||||
|
});
|
||||||
|
|
||||||
if (config.heartbeatSchedulerEnabled) {
|
if (config.heartbeatSchedulerEnabled) {
|
||||||
const heartbeat = heartbeatService(db as any);
|
const heartbeat = heartbeatService(db as any);
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,14 @@ import { createLocalAgentJwt } from "../agent-auth-jwt.js";
|
|||||||
import { parseObject, asBoolean, asNumber, appendWithCap, MAX_EXCERPT_BYTES } from "../adapters/utils.js";
|
import { parseObject, asBoolean, asNumber, appendWithCap, MAX_EXCERPT_BYTES } from "../adapters/utils.js";
|
||||||
import { secretService } from "./secrets.js";
|
import { secretService } from "./secrets.js";
|
||||||
import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js";
|
import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js";
|
||||||
|
import {
|
||||||
|
buildWorkspaceReadyComment,
|
||||||
|
ensureRuntimeServicesForRun,
|
||||||
|
persistAdapterManagedRuntimeServices,
|
||||||
|
realizeExecutionWorkspace,
|
||||||
|
releaseRuntimeServicesForRun,
|
||||||
|
} from "./workspace-runtime.js";
|
||||||
|
import { issueService } from "./issues.js";
|
||||||
|
|
||||||
const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024;
|
const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024;
|
||||||
const HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT = 1;
|
const HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT = 1;
|
||||||
@@ -406,6 +414,7 @@ function resolveNextSessionState(input: {
|
|||||||
export function heartbeatService(db: Db) {
|
export function heartbeatService(db: Db) {
|
||||||
const runLogStore = getRunLogStore();
|
const runLogStore = getRunLogStore();
|
||||||
const secretsSvc = secretService(db);
|
const secretsSvc = secretService(db);
|
||||||
|
const issuesSvc = issueService(db);
|
||||||
|
|
||||||
async function getAgent(agentId: string) {
|
async function getAgent(agentId: string) {
|
||||||
return db
|
return db
|
||||||
@@ -1099,14 +1108,54 @@ export function heartbeatService(db: Db) {
|
|||||||
previousSessionParams,
|
previousSessionParams,
|
||||||
{ useProjectWorkspace: issueAssigneeOverrides?.useProjectWorkspace ?? null },
|
{ useProjectWorkspace: issueAssigneeOverrides?.useProjectWorkspace ?? null },
|
||||||
);
|
);
|
||||||
|
const config = parseObject(agent.adapterConfig);
|
||||||
|
const mergedConfig = issueAssigneeOverrides?.adapterConfig
|
||||||
|
? { ...config, ...issueAssigneeOverrides.adapterConfig }
|
||||||
|
: config;
|
||||||
|
const { config: resolvedConfig, secretKeys } = await secretsSvc.resolveAdapterConfigForRuntime(
|
||||||
|
agent.companyId,
|
||||||
|
mergedConfig,
|
||||||
|
);
|
||||||
|
const issueRef = issueId
|
||||||
|
? await db
|
||||||
|
.select({
|
||||||
|
id: issues.id,
|
||||||
|
identifier: issues.identifier,
|
||||||
|
title: issues.title,
|
||||||
|
})
|
||||||
|
.from(issues)
|
||||||
|
.where(and(eq(issues.id, issueId), eq(issues.companyId, agent.companyId)))
|
||||||
|
.then((rows) => rows[0] ?? null)
|
||||||
|
: 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: resolvedConfig,
|
||||||
|
issue: issueRef,
|
||||||
|
agent: {
|
||||||
|
id: agent.id,
|
||||||
|
name: agent.name,
|
||||||
|
companyId: agent.companyId,
|
||||||
|
},
|
||||||
|
});
|
||||||
const runtimeSessionResolution = resolveRuntimeSessionParamsForWorkspace({
|
const runtimeSessionResolution = resolveRuntimeSessionParamsForWorkspace({
|
||||||
agentId: agent.id,
|
agentId: agent.id,
|
||||||
previousSessionParams,
|
previousSessionParams,
|
||||||
resolvedWorkspace,
|
resolvedWorkspace: {
|
||||||
|
...resolvedWorkspace,
|
||||||
|
cwd: executionWorkspace.cwd,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const runtimeSessionParams = runtimeSessionResolution.sessionParams;
|
const runtimeSessionParams = runtimeSessionResolution.sessionParams;
|
||||||
const runtimeWorkspaceWarnings = [
|
const runtimeWorkspaceWarnings = [
|
||||||
...resolvedWorkspace.warnings,
|
...resolvedWorkspace.warnings,
|
||||||
|
...executionWorkspace.warnings,
|
||||||
...(runtimeSessionResolution.warning ? [runtimeSessionResolution.warning] : []),
|
...(runtimeSessionResolution.warning ? [runtimeSessionResolution.warning] : []),
|
||||||
...(resetTaskSession && sessionResetReason
|
...(resetTaskSession && sessionResetReason
|
||||||
? [
|
? [
|
||||||
@@ -1117,16 +1166,32 @@ export function heartbeatService(db: Db) {
|
|||||||
: []),
|
: []),
|
||||||
];
|
];
|
||||||
context.paperclipWorkspace = {
|
context.paperclipWorkspace = {
|
||||||
cwd: resolvedWorkspace.cwd,
|
cwd: executionWorkspace.cwd,
|
||||||
source: resolvedWorkspace.source,
|
source: executionWorkspace.source,
|
||||||
projectId: resolvedWorkspace.projectId,
|
strategy: executionWorkspace.strategy,
|
||||||
workspaceId: resolvedWorkspace.workspaceId,
|
projectId: executionWorkspace.projectId,
|
||||||
repoUrl: resolvedWorkspace.repoUrl,
|
workspaceId: executionWorkspace.workspaceId,
|
||||||
repoRef: resolvedWorkspace.repoRef,
|
repoUrl: executionWorkspace.repoUrl,
|
||||||
|
repoRef: executionWorkspace.repoRef,
|
||||||
|
branchName: executionWorkspace.branchName,
|
||||||
|
worktreePath: executionWorkspace.worktreePath,
|
||||||
};
|
};
|
||||||
context.paperclipWorkspaces = resolvedWorkspace.workspaceHints;
|
context.paperclipWorkspaces = resolvedWorkspace.workspaceHints;
|
||||||
if (resolvedWorkspace.projectId && !readNonEmptyString(context.projectId)) {
|
const runtimeServiceIntents = (() => {
|
||||||
context.projectId = resolvedWorkspace.projectId;
|
const runtimeConfig = parseObject(resolvedConfig.workspaceRuntime);
|
||||||
|
return Array.isArray(runtimeConfig.services)
|
||||||
|
? runtimeConfig.services.filter(
|
||||||
|
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
})();
|
||||||
|
if (runtimeServiceIntents.length > 0) {
|
||||||
|
context.paperclipRuntimeServiceIntents = runtimeServiceIntents;
|
||||||
|
} else {
|
||||||
|
delete context.paperclipRuntimeServiceIntents;
|
||||||
|
}
|
||||||
|
if (executionWorkspace.projectId && !readNonEmptyString(context.projectId)) {
|
||||||
|
context.projectId = executionWorkspace.projectId;
|
||||||
}
|
}
|
||||||
const runtimeSessionFallback = taskKey || resetTaskSession ? null : runtime.sessionId;
|
const runtimeSessionFallback = taskKey || resetTaskSession ? null : runtime.sessionId;
|
||||||
const previousSessionDisplayId = truncateDisplayId(
|
const previousSessionDisplayId = truncateDisplayId(
|
||||||
@@ -1146,7 +1211,6 @@ export function heartbeatService(db: Db) {
|
|||||||
let handle: RunLogHandle | null = null;
|
let handle: RunLogHandle | null = null;
|
||||||
let stdoutExcerpt = "";
|
let stdoutExcerpt = "";
|
||||||
let stderrExcerpt = "";
|
let stderrExcerpt = "";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const startedAt = run.startedAt ?? new Date();
|
const startedAt = run.startedAt ?? new Date();
|
||||||
const runningWithSession = await db
|
const runningWithSession = await db
|
||||||
@@ -1154,6 +1218,7 @@ export function heartbeatService(db: Db) {
|
|||||||
.set({
|
.set({
|
||||||
startedAt,
|
startedAt,
|
||||||
sessionIdBefore: runtimeForAdapter.sessionDisplayId ?? runtimeForAdapter.sessionId,
|
sessionIdBefore: runtimeForAdapter.sessionDisplayId ?? runtimeForAdapter.sessionId,
|
||||||
|
contextSnapshot: context,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(heartbeatRuns.id, run.id))
|
.where(eq(heartbeatRuns.id, run.id))
|
||||||
@@ -1235,15 +1300,54 @@ export function heartbeatService(db: Db) {
|
|||||||
for (const warning of runtimeWorkspaceWarnings) {
|
for (const warning of runtimeWorkspaceWarnings) {
|
||||||
await onLog("stderr", `[paperclip] ${warning}\n`);
|
await onLog("stderr", `[paperclip] ${warning}\n`);
|
||||||
}
|
}
|
||||||
|
const adapterEnv = Object.fromEntries(
|
||||||
const config = parseObject(agent.adapterConfig);
|
Object.entries(parseObject(resolvedConfig.env)).filter(
|
||||||
const mergedConfig = issueAssigneeOverrides?.adapterConfig
|
(entry): entry is [string, string] => typeof entry[0] === "string" && typeof entry[1] === "string",
|
||||||
? { ...config, ...issueAssigneeOverrides.adapterConfig }
|
),
|
||||||
: config;
|
|
||||||
const { config: resolvedConfig, secretKeys } = await secretsSvc.resolveAdapterConfigForRuntime(
|
|
||||||
agent.companyId,
|
|
||||||
mergedConfig,
|
|
||||||
);
|
);
|
||||||
|
const runtimeServices = await ensureRuntimeServicesForRun({
|
||||||
|
db,
|
||||||
|
runId: run.id,
|
||||||
|
agent: {
|
||||||
|
id: agent.id,
|
||||||
|
name: agent.name,
|
||||||
|
companyId: agent.companyId,
|
||||||
|
},
|
||||||
|
issue: issueRef,
|
||||||
|
workspace: executionWorkspace,
|
||||||
|
config: resolvedConfig,
|
||||||
|
adapterEnv,
|
||||||
|
onLog,
|
||||||
|
});
|
||||||
|
if (runtimeServices.length > 0) {
|
||||||
|
context.paperclipRuntimeServices = runtimeServices;
|
||||||
|
context.paperclipRuntimePrimaryUrl =
|
||||||
|
runtimeServices.find((service) => readNonEmptyString(service.url))?.url ?? null;
|
||||||
|
await db
|
||||||
|
.update(heartbeatRuns)
|
||||||
|
.set({
|
||||||
|
contextSnapshot: context,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(heartbeatRuns.id, run.id));
|
||||||
|
}
|
||||||
|
if (issueId && (executionWorkspace.created || runtimeServices.some((service) => !service.reused))) {
|
||||||
|
try {
|
||||||
|
await issuesSvc.addComment(
|
||||||
|
issueId,
|
||||||
|
buildWorkspaceReadyComment({
|
||||||
|
workspace: executionWorkspace,
|
||||||
|
runtimeServices,
|
||||||
|
}),
|
||||||
|
{ agentId: agent.id },
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
await onLog(
|
||||||
|
"stderr",
|
||||||
|
`[paperclip] Failed to post workspace-ready comment: ${err instanceof Error ? err.message : String(err)}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
const onAdapterMeta = async (meta: AdapterInvocationMeta) => {
|
const onAdapterMeta = async (meta: AdapterInvocationMeta) => {
|
||||||
if (meta.env && secretKeys.size > 0) {
|
if (meta.env && secretKeys.size > 0) {
|
||||||
for (const key of secretKeys) {
|
for (const key of secretKeys) {
|
||||||
@@ -1284,6 +1388,54 @@ export function heartbeatService(db: Db) {
|
|||||||
onMeta: onAdapterMeta,
|
onMeta: onAdapterMeta,
|
||||||
authToken: authToken ?? undefined,
|
authToken: authToken ?? undefined,
|
||||||
});
|
});
|
||||||
|
const adapterManagedRuntimeServices = adapterResult.runtimeServices
|
||||||
|
? await persistAdapterManagedRuntimeServices({
|
||||||
|
db,
|
||||||
|
adapterType: agent.adapterType,
|
||||||
|
runId: run.id,
|
||||||
|
agent: {
|
||||||
|
id: agent.id,
|
||||||
|
name: agent.name,
|
||||||
|
companyId: agent.companyId,
|
||||||
|
},
|
||||||
|
issue: issueRef,
|
||||||
|
workspace: executionWorkspace,
|
||||||
|
reports: adapterResult.runtimeServices,
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
if (adapterManagedRuntimeServices.length > 0) {
|
||||||
|
const combinedRuntimeServices = [
|
||||||
|
...runtimeServices,
|
||||||
|
...adapterManagedRuntimeServices,
|
||||||
|
];
|
||||||
|
context.paperclipRuntimeServices = combinedRuntimeServices;
|
||||||
|
context.paperclipRuntimePrimaryUrl =
|
||||||
|
combinedRuntimeServices.find((service) => readNonEmptyString(service.url))?.url ?? null;
|
||||||
|
await db
|
||||||
|
.update(heartbeatRuns)
|
||||||
|
.set({
|
||||||
|
contextSnapshot: context,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(heartbeatRuns.id, run.id));
|
||||||
|
if (issueId) {
|
||||||
|
try {
|
||||||
|
await issuesSvc.addComment(
|
||||||
|
issueId,
|
||||||
|
buildWorkspaceReadyComment({
|
||||||
|
workspace: executionWorkspace,
|
||||||
|
runtimeServices: adapterManagedRuntimeServices,
|
||||||
|
}),
|
||||||
|
{ agentId: agent.id },
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
await onLog(
|
||||||
|
"stderr",
|
||||||
|
`[paperclip] Failed to post adapter-managed runtime comment: ${err instanceof Error ? err.message : String(err)}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
const nextSessionState = resolveNextSessionState({
|
const nextSessionState = resolveNextSessionState({
|
||||||
codec: sessionCodec,
|
codec: sessionCodec,
|
||||||
adapterResult,
|
adapterResult,
|
||||||
@@ -1460,6 +1612,7 @@ export function heartbeatService(db: Db) {
|
|||||||
|
|
||||||
await finalizeAgentStatus(agent.id, "failed");
|
await finalizeAgentStatus(agent.id, "failed");
|
||||||
} finally {
|
} finally {
|
||||||
|
await releaseRuntimeServicesForRun(run.id);
|
||||||
await startNextQueuedRunForAgent(agent.id);
|
await startNextQueuedRunForAgent(agent.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,4 +17,5 @@ export { companyPortabilityService } from "./company-portability.js";
|
|||||||
export { logActivity, type LogActivityInput } from "./activity-log.js";
|
export { logActivity, type LogActivityInput } from "./activity-log.js";
|
||||||
export { notifyHireApproved, type NotifyHireApprovedInput } from "./hire-hook.js";
|
export { notifyHireApproved, type NotifyHireApprovedInput } from "./hire-hook.js";
|
||||||
export { publishLiveEvent, subscribeCompanyLiveEvents } from "./live-events.js";
|
export { publishLiveEvent, subscribeCompanyLiveEvents } from "./live-events.js";
|
||||||
|
export { reconcilePersistedRuntimeServicesOnStartup } from "./workspace-runtime.js";
|
||||||
export { createStorageServiceFromConfig, getStorageService } from "../storage/index.js";
|
export { createStorageServiceFromConfig, getStorageService } from "../storage/index.js";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { and, asc, desc, eq, inArray } from "drizzle-orm";
|
import { and, asc, desc, eq, inArray } from "drizzle-orm";
|
||||||
import type { Db } from "@paperclipai/db";
|
import type { Db } from "@paperclipai/db";
|
||||||
import { projects, projectGoals, goals, projectWorkspaces } from "@paperclipai/db";
|
import { projects, projectGoals, goals, projectWorkspaces, workspaceRuntimeServices } from "@paperclipai/db";
|
||||||
import {
|
import {
|
||||||
PROJECT_COLORS,
|
PROJECT_COLORS,
|
||||||
deriveProjectUrlKey,
|
deriveProjectUrlKey,
|
||||||
@@ -8,10 +8,13 @@ import {
|
|||||||
normalizeProjectUrlKey,
|
normalizeProjectUrlKey,
|
||||||
type ProjectGoalRef,
|
type ProjectGoalRef,
|
||||||
type ProjectWorkspace,
|
type ProjectWorkspace,
|
||||||
|
type WorkspaceRuntimeService,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
|
import { listWorkspaceRuntimeServicesForProjectWorkspaces } from "./workspace-runtime.js";
|
||||||
|
|
||||||
type ProjectRow = typeof projects.$inferSelect;
|
type ProjectRow = typeof projects.$inferSelect;
|
||||||
type ProjectWorkspaceRow = typeof projectWorkspaces.$inferSelect;
|
type ProjectWorkspaceRow = typeof projectWorkspaces.$inferSelect;
|
||||||
|
type WorkspaceRuntimeServiceRow = typeof workspaceRuntimeServices.$inferSelect;
|
||||||
const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__";
|
const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__";
|
||||||
type CreateWorkspaceInput = {
|
type CreateWorkspaceInput = {
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
@@ -78,7 +81,41 @@ async function attachGoals(db: Db, rows: ProjectRow[]): Promise<ProjectWithGoals
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function toWorkspace(row: ProjectWorkspaceRow): ProjectWorkspace {
|
function toRuntimeService(row: WorkspaceRuntimeServiceRow): WorkspaceRuntimeService {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
companyId: row.companyId,
|
||||||
|
projectId: row.projectId ?? null,
|
||||||
|
projectWorkspaceId: row.projectWorkspaceId ?? 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 toWorkspace(
|
||||||
|
row: ProjectWorkspaceRow,
|
||||||
|
runtimeServices: WorkspaceRuntimeService[] = [],
|
||||||
|
): ProjectWorkspace {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
companyId: row.companyId,
|
companyId: row.companyId,
|
||||||
@@ -89,15 +126,20 @@ function toWorkspace(row: ProjectWorkspaceRow): ProjectWorkspace {
|
|||||||
repoRef: row.repoRef ?? null,
|
repoRef: row.repoRef ?? null,
|
||||||
metadata: (row.metadata as Record<string, unknown> | null) ?? null,
|
metadata: (row.metadata as Record<string, unknown> | null) ?? null,
|
||||||
isPrimary: row.isPrimary,
|
isPrimary: row.isPrimary,
|
||||||
|
runtimeServices,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
updatedAt: row.updatedAt,
|
updatedAt: row.updatedAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickPrimaryWorkspace(rows: ProjectWorkspaceRow[]): ProjectWorkspace | null {
|
function pickPrimaryWorkspace(
|
||||||
|
rows: ProjectWorkspaceRow[],
|
||||||
|
runtimeServicesByWorkspaceId?: Map<string, WorkspaceRuntimeService[]>,
|
||||||
|
): ProjectWorkspace | null {
|
||||||
if (rows.length === 0) return null;
|
if (rows.length === 0) return null;
|
||||||
const explicitPrimary = rows.find((row) => row.isPrimary);
|
const explicitPrimary = rows.find((row) => row.isPrimary);
|
||||||
return toWorkspace(explicitPrimary ?? rows[0]);
|
const primary = explicitPrimary ?? rows[0];
|
||||||
|
return toWorkspace(primary, runtimeServicesByWorkspaceId?.get(primary.id) ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Batch-load workspace refs for a set of projects. */
|
/** Batch-load workspace refs for a set of projects. */
|
||||||
@@ -110,6 +152,17 @@ async function attachWorkspaces(db: Db, rows: ProjectWithGoals[]): Promise<Proje
|
|||||||
.from(projectWorkspaces)
|
.from(projectWorkspaces)
|
||||||
.where(inArray(projectWorkspaces.projectId, projectIds))
|
.where(inArray(projectWorkspaces.projectId, projectIds))
|
||||||
.orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id));
|
.orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id));
|
||||||
|
const runtimeServicesByWorkspaceId = await listWorkspaceRuntimeServicesForProjectWorkspaces(
|
||||||
|
db,
|
||||||
|
rows[0]!.companyId,
|
||||||
|
workspaceRows.map((workspace) => workspace.id),
|
||||||
|
);
|
||||||
|
const sharedRuntimeServicesByWorkspaceId = new Map(
|
||||||
|
Array.from(runtimeServicesByWorkspaceId.entries()).map(([workspaceId, services]) => [
|
||||||
|
workspaceId,
|
||||||
|
services.map(toRuntimeService),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
const map = new Map<string, ProjectWorkspaceRow[]>();
|
const map = new Map<string, ProjectWorkspaceRow[]>();
|
||||||
for (const row of workspaceRows) {
|
for (const row of workspaceRows) {
|
||||||
@@ -123,11 +176,16 @@ async function attachWorkspaces(db: Db, rows: ProjectWithGoals[]): Promise<Proje
|
|||||||
|
|
||||||
return rows.map((row) => {
|
return rows.map((row) => {
|
||||||
const projectWorkspaceRows = map.get(row.id) ?? [];
|
const projectWorkspaceRows = map.get(row.id) ?? [];
|
||||||
const workspaces = projectWorkspaceRows.map(toWorkspace);
|
const workspaces = projectWorkspaceRows.map((workspace) =>
|
||||||
|
toWorkspace(
|
||||||
|
workspace,
|
||||||
|
sharedRuntimeServicesByWorkspaceId.get(workspace.id) ?? [],
|
||||||
|
),
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
workspaces,
|
workspaces,
|
||||||
primaryWorkspace: pickPrimaryWorkspace(projectWorkspaceRows),
|
primaryWorkspace: pickPrimaryWorkspace(projectWorkspaceRows, sharedRuntimeServicesByWorkspaceId),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -402,7 +460,18 @@ export function projectService(db: Db) {
|
|||||||
.from(projectWorkspaces)
|
.from(projectWorkspaces)
|
||||||
.where(eq(projectWorkspaces.projectId, projectId))
|
.where(eq(projectWorkspaces.projectId, projectId))
|
||||||
.orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id));
|
.orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id));
|
||||||
return rows.map(toWorkspace);
|
if (rows.length === 0) return [];
|
||||||
|
const runtimeServicesByWorkspaceId = await listWorkspaceRuntimeServicesForProjectWorkspaces(
|
||||||
|
db,
|
||||||
|
rows[0]!.companyId,
|
||||||
|
rows.map((workspace) => workspace.id),
|
||||||
|
);
|
||||||
|
return rows.map((row) =>
|
||||||
|
toWorkspace(
|
||||||
|
row,
|
||||||
|
(runtimeServicesByWorkspaceId.get(row.id) ?? []).map(toRuntimeService),
|
||||||
|
),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
createWorkspace: async (
|
createWorkspace: async (
|
||||||
|
|||||||
962
server/src/services/workspace-runtime.ts
Normal file
962
server/src/services/workspace-runtime.ts
Normal file
@@ -0,0 +1,962 @@
|
|||||||
|
import { spawn, type ChildProcess } from "node:child_process";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import net from "node:net";
|
||||||
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
|
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 { and, desc, eq, inArray } from "drizzle-orm";
|
||||||
|
import { asNumber, asString, parseObject, renderTemplate } from "../adapters/utils.js";
|
||||||
|
import { resolveHomeAwarePath } from "../home-paths.js";
|
||||||
|
|
||||||
|
export interface ExecutionWorkspaceInput {
|
||||||
|
baseCwd: string;
|
||||||
|
source: "project_primary" | "task_session" | "agent_home";
|
||||||
|
projectId: string | null;
|
||||||
|
workspaceId: string | null;
|
||||||
|
repoUrl: string | null;
|
||||||
|
repoRef: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutionWorkspaceIssueRef {
|
||||||
|
id: string;
|
||||||
|
identifier: string | null;
|
||||||
|
title: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutionWorkspaceAgentRef {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
companyId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RealizedExecutionWorkspace extends ExecutionWorkspaceInput {
|
||||||
|
strategy: "project_primary" | "git_worktree";
|
||||||
|
cwd: string;
|
||||||
|
branchName: string | null;
|
||||||
|
worktreePath: string | null;
|
||||||
|
warnings: string[];
|
||||||
|
created: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeServiceRef {
|
||||||
|
id: string;
|
||||||
|
companyId: string;
|
||||||
|
projectId: string | null;
|
||||||
|
projectWorkspaceId: string | null;
|
||||||
|
issueId: string | null;
|
||||||
|
serviceName: string;
|
||||||
|
status: "starting" | "running" | "stopped" | "failed";
|
||||||
|
lifecycle: "shared" | "ephemeral";
|
||||||
|
scopeType: "project_workspace" | "execution_workspace" | "run" | "agent";
|
||||||
|
scopeId: string | null;
|
||||||
|
reuseKey: string | null;
|
||||||
|
command: string | null;
|
||||||
|
cwd: string | null;
|
||||||
|
port: number | null;
|
||||||
|
url: string | null;
|
||||||
|
provider: "local_process" | "adapter_managed";
|
||||||
|
providerRef: string | null;
|
||||||
|
ownerAgentId: string | null;
|
||||||
|
startedByRunId: string | null;
|
||||||
|
lastUsedAt: string;
|
||||||
|
startedAt: string;
|
||||||
|
stoppedAt: string | null;
|
||||||
|
stopPolicy: Record<string, unknown> | null;
|
||||||
|
healthStatus: "unknown" | "healthy" | "unhealthy";
|
||||||
|
reused: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RuntimeServiceRecord extends RuntimeServiceRef {
|
||||||
|
db?: Db;
|
||||||
|
child: ChildProcess | null;
|
||||||
|
leaseRunIds: Set<string>;
|
||||||
|
idleTimer: ReturnType<typeof globalThis.setTimeout> | null;
|
||||||
|
envFingerprint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtimeServicesById = new Map<string, RuntimeServiceRecord>();
|
||||||
|
const runtimeServicesByReuseKey = new Map<string, string>();
|
||||||
|
const runtimeServiceLeasesByRun = new Map<string, string[]>();
|
||||||
|
|
||||||
|
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 stableRuntimeServiceId(input: {
|
||||||
|
adapterType: string;
|
||||||
|
runId: string;
|
||||||
|
scopeType: RuntimeServiceRef["scopeType"];
|
||||||
|
scopeId: string | null;
|
||||||
|
serviceName: string;
|
||||||
|
reportId: string | null;
|
||||||
|
providerRef: string | null;
|
||||||
|
reuseKey: string | null;
|
||||||
|
}) {
|
||||||
|
if (input.reportId) return input.reportId;
|
||||||
|
const digest = createHash("sha256")
|
||||||
|
.update(
|
||||||
|
stableStringify({
|
||||||
|
adapterType: input.adapterType,
|
||||||
|
runId: input.runId,
|
||||||
|
scopeType: input.scopeType,
|
||||||
|
scopeId: input.scopeId,
|
||||||
|
serviceName: input.serviceName,
|
||||||
|
providerRef: input.providerRef,
|
||||||
|
reuseKey: input.reuseKey,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.digest("hex")
|
||||||
|
.slice(0, 32);
|
||||||
|
return `${input.adapterType}-${digest}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRuntimeServiceRef(record: RuntimeServiceRecord, overrides?: Partial<RuntimeServiceRef>): RuntimeServiceRef {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
companyId: record.companyId,
|
||||||
|
projectId: record.projectId,
|
||||||
|
projectWorkspaceId: record.projectWorkspaceId,
|
||||||
|
issueId: record.issueId,
|
||||||
|
serviceName: record.serviceName,
|
||||||
|
status: record.status,
|
||||||
|
lifecycle: record.lifecycle,
|
||||||
|
scopeType: record.scopeType,
|
||||||
|
scopeId: record.scopeId,
|
||||||
|
reuseKey: record.reuseKey,
|
||||||
|
command: record.command,
|
||||||
|
cwd: record.cwd,
|
||||||
|
port: record.port,
|
||||||
|
url: record.url,
|
||||||
|
provider: record.provider,
|
||||||
|
providerRef: record.providerRef,
|
||||||
|
ownerAgentId: record.ownerAgentId,
|
||||||
|
startedByRunId: record.startedByRunId,
|
||||||
|
lastUsedAt: record.lastUsedAt,
|
||||||
|
startedAt: record.startedAt,
|
||||||
|
stoppedAt: record.stoppedAt,
|
||||||
|
stopPolicy: record.stopPolicy,
|
||||||
|
healthStatus: record.healthStatus,
|
||||||
|
reused: record.reused,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeSlugPart(value: string | null | undefined, fallback: string): string {
|
||||||
|
const raw = (value ?? "").trim().toLowerCase();
|
||||||
|
const normalized = raw
|
||||||
|
.replace(/[^a-z0-9/_-]+/g, "-")
|
||||||
|
.replace(/-+/g, "-")
|
||||||
|
.replace(/^[-/]+|[-/]+$/g, "");
|
||||||
|
return normalized.length > 0 ? normalized : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWorkspaceTemplate(template: string, input: {
|
||||||
|
issue: ExecutionWorkspaceIssueRef | null;
|
||||||
|
agent: ExecutionWorkspaceAgentRef;
|
||||||
|
projectId: string | null;
|
||||||
|
repoRef: string | null;
|
||||||
|
}) {
|
||||||
|
const issueIdentifier = input.issue?.identifier ?? input.issue?.id ?? "issue";
|
||||||
|
const slug = sanitizeSlugPart(input.issue?.title, sanitizeSlugPart(issueIdentifier, "issue"));
|
||||||
|
return renderTemplate(template, {
|
||||||
|
issue: {
|
||||||
|
id: input.issue?.id ?? "",
|
||||||
|
identifier: input.issue?.identifier ?? "",
|
||||||
|
title: input.issue?.title ?? "",
|
||||||
|
},
|
||||||
|
agent: {
|
||||||
|
id: input.agent.id,
|
||||||
|
name: input.agent.name,
|
||||||
|
},
|
||||||
|
project: {
|
||||||
|
id: input.projectId ?? "",
|
||||||
|
},
|
||||||
|
workspace: {
|
||||||
|
repoRef: input.repoRef ?? "",
|
||||||
|
},
|
||||||
|
slug,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeBranchName(value: string): string {
|
||||||
|
return value
|
||||||
|
.trim()
|
||||||
|
.replace(/[^A-Za-z0-9._/-]+/g, "-")
|
||||||
|
.replace(/-+/g, "-")
|
||||||
|
.replace(/^[-/.]+|[-/.]+$/g, "")
|
||||||
|
.slice(0, 120) || "paperclip-work";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAbsolutePath(value: string) {
|
||||||
|
return path.isAbsolute(value) || value.startsWith("~");
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveConfiguredPath(value: string, baseDir: string): string {
|
||||||
|
if (isAbsolutePath(value)) {
|
||||||
|
return resolveHomeAwarePath(value);
|
||||||
|
}
|
||||||
|
return path.resolve(baseDir, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runGit(args: string[], cwd: string): Promise<string> {
|
||||||
|
const proc = await new Promise<{ stdout: string; stderr: string; code: number | null }>((resolve, reject) => {
|
||||||
|
const child = spawn("git", args, {
|
||||||
|
cwd,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
env: process.env,
|
||||||
|
});
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
child.stdout?.on("data", (chunk) => {
|
||||||
|
stdout += String(chunk);
|
||||||
|
});
|
||||||
|
child.stderr?.on("data", (chunk) => {
|
||||||
|
stderr += String(chunk);
|
||||||
|
});
|
||||||
|
child.on("error", reject);
|
||||||
|
child.on("close", (code) => resolve({ stdout, stderr, code }));
|
||||||
|
});
|
||||||
|
if (proc.code !== 0) {
|
||||||
|
throw new Error(proc.stderr.trim() || proc.stdout.trim() || `git ${args.join(" ")} failed`);
|
||||||
|
}
|
||||||
|
return proc.stdout.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function directoryExists(value: string) {
|
||||||
|
return fs.stat(value).then((stats) => stats.isDirectory()).catch(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function realizeExecutionWorkspace(input: {
|
||||||
|
base: ExecutionWorkspaceInput;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
issue: ExecutionWorkspaceIssueRef | null;
|
||||||
|
agent: ExecutionWorkspaceAgentRef;
|
||||||
|
}): Promise<RealizedExecutionWorkspace> {
|
||||||
|
const rawStrategy = parseObject(input.config.workspaceStrategy);
|
||||||
|
const strategyType = asString(rawStrategy.type, "project_primary");
|
||||||
|
if (strategyType !== "git_worktree") {
|
||||||
|
return {
|
||||||
|
...input.base,
|
||||||
|
strategy: "project_primary",
|
||||||
|
cwd: input.base.baseCwd,
|
||||||
|
branchName: null,
|
||||||
|
worktreePath: null,
|
||||||
|
warnings: [],
|
||||||
|
created: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const repoRoot = await runGit(["rev-parse", "--show-toplevel"], input.base.baseCwd);
|
||||||
|
const branchTemplate = asString(rawStrategy.branchTemplate, "{{issue.identifier}}-{{slug}}");
|
||||||
|
const renderedBranch = renderWorkspaceTemplate(branchTemplate, {
|
||||||
|
issue: input.issue,
|
||||||
|
agent: input.agent,
|
||||||
|
projectId: input.base.projectId,
|
||||||
|
repoRef: input.base.repoRef,
|
||||||
|
});
|
||||||
|
const branchName = sanitizeBranchName(renderedBranch);
|
||||||
|
const configuredParentDir = asString(rawStrategy.worktreeParentDir, "");
|
||||||
|
const worktreeParentDir = configuredParentDir
|
||||||
|
? resolveConfiguredPath(configuredParentDir, repoRoot)
|
||||||
|
: path.join(repoRoot, ".paperclip", "worktrees");
|
||||||
|
const worktreePath = path.join(worktreeParentDir, branchName);
|
||||||
|
const baseRef = asString(rawStrategy.baseRef, input.base.repoRef ?? "HEAD");
|
||||||
|
|
||||||
|
await fs.mkdir(worktreeParentDir, { recursive: true });
|
||||||
|
|
||||||
|
const existingWorktree = await directoryExists(worktreePath);
|
||||||
|
if (existingWorktree) {
|
||||||
|
const existingGitDir = await runGit(["rev-parse", "--git-dir"], worktreePath).catch(() => null);
|
||||||
|
if (existingGitDir) {
|
||||||
|
return {
|
||||||
|
...input.base,
|
||||||
|
strategy: "git_worktree",
|
||||||
|
cwd: worktreePath,
|
||||||
|
branchName,
|
||||||
|
worktreePath,
|
||||||
|
warnings: [],
|
||||||
|
created: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new Error(`Configured worktree path "${worktreePath}" already exists and is not a git worktree.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await runGit(["worktree", "add", "-B", branchName, worktreePath, baseRef], repoRoot);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...input.base,
|
||||||
|
strategy: "git_worktree",
|
||||||
|
cwd: worktreePath,
|
||||||
|
branchName,
|
||||||
|
worktreePath,
|
||||||
|
warnings: [],
|
||||||
|
created: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function allocatePort(): Promise<number> {
|
||||||
|
return await new Promise<number>((resolve, reject) => {
|
||||||
|
const server = net.createServer();
|
||||||
|
server.listen(0, "127.0.0.1", () => {
|
||||||
|
const address = server.address();
|
||||||
|
server.close((err) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
reject(new Error("Failed to allocate port"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(address.port);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
server.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTemplateData(input: {
|
||||||
|
workspace: RealizedExecutionWorkspace;
|
||||||
|
agent: ExecutionWorkspaceAgentRef;
|
||||||
|
issue: ExecutionWorkspaceIssueRef | null;
|
||||||
|
adapterEnv: Record<string, string>;
|
||||||
|
port: number | null;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
workspace: {
|
||||||
|
cwd: input.workspace.cwd,
|
||||||
|
branchName: input.workspace.branchName ?? "",
|
||||||
|
worktreePath: input.workspace.worktreePath ?? "",
|
||||||
|
repoUrl: input.workspace.repoUrl ?? "",
|
||||||
|
repoRef: input.workspace.repoRef ?? "",
|
||||||
|
env: input.adapterEnv,
|
||||||
|
},
|
||||||
|
issue: {
|
||||||
|
id: input.issue?.id ?? "",
|
||||||
|
identifier: input.issue?.identifier ?? "",
|
||||||
|
title: input.issue?.title ?? "",
|
||||||
|
},
|
||||||
|
agent: {
|
||||||
|
id: input.agent.id,
|
||||||
|
name: input.agent.name,
|
||||||
|
},
|
||||||
|
port: input.port ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveServiceScopeId(input: {
|
||||||
|
service: Record<string, unknown>;
|
||||||
|
workspace: RealizedExecutionWorkspace;
|
||||||
|
issue: ExecutionWorkspaceIssueRef | null;
|
||||||
|
runId: string;
|
||||||
|
agent: ExecutionWorkspaceAgentRef;
|
||||||
|
}): {
|
||||||
|
scopeType: "project_workspace" | "execution_workspace" | "run" | "agent";
|
||||||
|
scopeId: string | null;
|
||||||
|
} {
|
||||||
|
const scopeTypeRaw = asString(input.service.reuseScope, input.service.lifecycle === "shared" ? "project_workspace" : "run");
|
||||||
|
const scopeType =
|
||||||
|
scopeTypeRaw === "project_workspace" ||
|
||||||
|
scopeTypeRaw === "execution_workspace" ||
|
||||||
|
scopeTypeRaw === "agent"
|
||||||
|
? scopeTypeRaw
|
||||||
|
: "run";
|
||||||
|
if (scopeType === "project_workspace") return { scopeType, scopeId: input.workspace.workspaceId ?? input.workspace.projectId };
|
||||||
|
if (scopeType === "execution_workspace") return { scopeType, scopeId: input.workspace.cwd };
|
||||||
|
if (scopeType === "agent") return { scopeType, scopeId: input.agent.id };
|
||||||
|
return { scopeType: "run" as const, scopeId: input.runId };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForReadiness(input: {
|
||||||
|
service: Record<string, unknown>;
|
||||||
|
url: string | null;
|
||||||
|
}) {
|
||||||
|
const readiness = parseObject(input.service.readiness);
|
||||||
|
const readinessType = asString(readiness.type, "");
|
||||||
|
if (readinessType !== "http" || !input.url) return;
|
||||||
|
const timeoutSec = Math.max(1, asNumber(readiness.timeoutSec, 30));
|
||||||
|
const intervalMs = Math.max(100, asNumber(readiness.intervalMs, 500));
|
||||||
|
const deadline = Date.now() + timeoutSec * 1000;
|
||||||
|
let lastError = "service did not become ready";
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(input.url);
|
||||||
|
if (response.ok) return;
|
||||||
|
lastError = `received HTTP ${response.status}`;
|
||||||
|
} catch (err) {
|
||||||
|
lastError = err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
|
await delay(intervalMs);
|
||||||
|
}
|
||||||
|
throw new Error(`Readiness check failed for ${input.url}: ${lastError}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPersistedWorkspaceRuntimeService(record: RuntimeServiceRecord): typeof workspaceRuntimeServices.$inferInsert {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
companyId: record.companyId,
|
||||||
|
projectId: record.projectId,
|
||||||
|
projectWorkspaceId: record.projectWorkspaceId,
|
||||||
|
issueId: record.issueId,
|
||||||
|
scopeType: record.scopeType,
|
||||||
|
scopeId: record.scopeId,
|
||||||
|
serviceName: record.serviceName,
|
||||||
|
status: record.status,
|
||||||
|
lifecycle: record.lifecycle,
|
||||||
|
reuseKey: record.reuseKey,
|
||||||
|
command: record.command,
|
||||||
|
cwd: record.cwd,
|
||||||
|
port: record.port,
|
||||||
|
url: record.url,
|
||||||
|
provider: record.provider,
|
||||||
|
providerRef: record.providerRef,
|
||||||
|
ownerAgentId: record.ownerAgentId,
|
||||||
|
startedByRunId: record.startedByRunId,
|
||||||
|
lastUsedAt: new Date(record.lastUsedAt),
|
||||||
|
startedAt: new Date(record.startedAt),
|
||||||
|
stoppedAt: record.stoppedAt ? new Date(record.stoppedAt) : null,
|
||||||
|
stopPolicy: record.stopPolicy,
|
||||||
|
healthStatus: record.healthStatus,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistRuntimeServiceRecord(db: Db | undefined, record: RuntimeServiceRecord) {
|
||||||
|
if (!db) return;
|
||||||
|
const values = toPersistedWorkspaceRuntimeService(record);
|
||||||
|
await db
|
||||||
|
.insert(workspaceRuntimeServices)
|
||||||
|
.values(values)
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: workspaceRuntimeServices.id,
|
||||||
|
set: {
|
||||||
|
projectId: values.projectId,
|
||||||
|
projectWorkspaceId: values.projectWorkspaceId,
|
||||||
|
issueId: values.issueId,
|
||||||
|
scopeType: values.scopeType,
|
||||||
|
scopeId: values.scopeId,
|
||||||
|
serviceName: values.serviceName,
|
||||||
|
status: values.status,
|
||||||
|
lifecycle: values.lifecycle,
|
||||||
|
reuseKey: values.reuseKey,
|
||||||
|
command: values.command,
|
||||||
|
cwd: values.cwd,
|
||||||
|
port: values.port,
|
||||||
|
url: values.url,
|
||||||
|
provider: values.provider,
|
||||||
|
providerRef: values.providerRef,
|
||||||
|
ownerAgentId: values.ownerAgentId,
|
||||||
|
startedByRunId: values.startedByRunId,
|
||||||
|
lastUsedAt: values.lastUsedAt,
|
||||||
|
startedAt: values.startedAt,
|
||||||
|
stoppedAt: values.stoppedAt,
|
||||||
|
stopPolicy: values.stopPolicy,
|
||||||
|
healthStatus: values.healthStatus,
|
||||||
|
updatedAt: values.updatedAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearIdleTimer(record: RuntimeServiceRecord) {
|
||||||
|
if (!record.idleTimer) return;
|
||||||
|
clearTimeout(record.idleTimer);
|
||||||
|
record.idleTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeAdapterManagedRuntimeServices(input: {
|
||||||
|
adapterType: string;
|
||||||
|
runId: string;
|
||||||
|
agent: ExecutionWorkspaceAgentRef;
|
||||||
|
issue: ExecutionWorkspaceIssueRef | null;
|
||||||
|
workspace: RealizedExecutionWorkspace;
|
||||||
|
reports: AdapterRuntimeServiceReport[];
|
||||||
|
now?: Date;
|
||||||
|
}): RuntimeServiceRef[] {
|
||||||
|
const nowIso = (input.now ?? new Date()).toISOString();
|
||||||
|
return input.reports.map((report) => {
|
||||||
|
const scopeType = report.scopeType ?? "run";
|
||||||
|
const scopeId =
|
||||||
|
report.scopeId ??
|
||||||
|
(scopeType === "project_workspace"
|
||||||
|
? input.workspace.workspaceId
|
||||||
|
: scopeType === "execution_workspace"
|
||||||
|
? input.workspace.cwd
|
||||||
|
: scopeType === "agent"
|
||||||
|
? input.agent.id
|
||||||
|
: input.runId) ??
|
||||||
|
null;
|
||||||
|
const serviceName = asString(report.serviceName, "").trim() || "service";
|
||||||
|
const status = report.status ?? "running";
|
||||||
|
const lifecycle = report.lifecycle ?? "ephemeral";
|
||||||
|
const healthStatus =
|
||||||
|
report.healthStatus ??
|
||||||
|
(status === "running" ? "healthy" : status === "failed" ? "unhealthy" : "unknown");
|
||||||
|
return {
|
||||||
|
id: stableRuntimeServiceId({
|
||||||
|
adapterType: input.adapterType,
|
||||||
|
runId: input.runId,
|
||||||
|
scopeType,
|
||||||
|
scopeId,
|
||||||
|
serviceName,
|
||||||
|
reportId: report.id ?? null,
|
||||||
|
providerRef: report.providerRef ?? null,
|
||||||
|
reuseKey: report.reuseKey ?? null,
|
||||||
|
}),
|
||||||
|
companyId: input.agent.companyId,
|
||||||
|
projectId: report.projectId ?? input.workspace.projectId,
|
||||||
|
projectWorkspaceId: report.projectWorkspaceId ?? input.workspace.workspaceId,
|
||||||
|
issueId: report.issueId ?? input.issue?.id ?? null,
|
||||||
|
serviceName,
|
||||||
|
status,
|
||||||
|
lifecycle,
|
||||||
|
scopeType,
|
||||||
|
scopeId,
|
||||||
|
reuseKey: report.reuseKey ?? null,
|
||||||
|
command: report.command ?? null,
|
||||||
|
cwd: report.cwd ?? null,
|
||||||
|
port: report.port ?? null,
|
||||||
|
url: report.url ?? null,
|
||||||
|
provider: "adapter_managed",
|
||||||
|
providerRef: report.providerRef ?? null,
|
||||||
|
ownerAgentId: report.ownerAgentId ?? input.agent.id,
|
||||||
|
startedByRunId: input.runId,
|
||||||
|
lastUsedAt: nowIso,
|
||||||
|
startedAt: nowIso,
|
||||||
|
stoppedAt: status === "running" || status === "starting" ? null : nowIso,
|
||||||
|
stopPolicy: report.stopPolicy ?? null,
|
||||||
|
healthStatus,
|
||||||
|
reused: false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startLocalRuntimeService(input: {
|
||||||
|
db?: Db;
|
||||||
|
runId: string;
|
||||||
|
agent: ExecutionWorkspaceAgentRef;
|
||||||
|
issue: ExecutionWorkspaceIssueRef | null;
|
||||||
|
workspace: RealizedExecutionWorkspace;
|
||||||
|
adapterEnv: Record<string, string>;
|
||||||
|
service: Record<string, unknown>;
|
||||||
|
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||||
|
reuseKey: string | null;
|
||||||
|
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, "");
|
||||||
|
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 templateData = buildTemplateData({
|
||||||
|
workspace: input.workspace,
|
||||||
|
agent: input.agent,
|
||||||
|
issue: input.issue,
|
||||||
|
adapterEnv: input.adapterEnv,
|
||||||
|
port,
|
||||||
|
});
|
||||||
|
const serviceCwd = resolveConfiguredPath(renderTemplate(serviceCwdTemplate, templateData), input.workspace.cwd);
|
||||||
|
const env: Record<string, string> = { ...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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (port) {
|
||||||
|
const portEnvKey = asString(portConfig.envKey, "PORT");
|
||||||
|
env[portEnvKey] = String(port);
|
||||||
|
}
|
||||||
|
const shell = process.env.SHELL?.trim() || "/bin/sh";
|
||||||
|
const child = spawn(shell, ["-lc", command], {
|
||||||
|
cwd: serviceCwd,
|
||||||
|
env,
|
||||||
|
detached: false,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
let stderrExcerpt = "";
|
||||||
|
let stdoutExcerpt = "";
|
||||||
|
child.stdout?.on("data", async (chunk) => {
|
||||||
|
const text = String(chunk);
|
||||||
|
stdoutExcerpt = (stdoutExcerpt + text).slice(-4096);
|
||||||
|
if (input.onLog) await input.onLog("stdout", `[service:${serviceName}] ${text}`);
|
||||||
|
});
|
||||||
|
child.stderr?.on("data", async (chunk) => {
|
||||||
|
const text = String(chunk);
|
||||||
|
stderrExcerpt = (stderrExcerpt + text).slice(-4096);
|
||||||
|
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) {
|
||||||
|
child.kill("SIGTERM");
|
||||||
|
throw new Error(
|
||||||
|
`Failed to start runtime service "${serviceName}": ${err instanceof Error ? err.message : String(err)}${stderrExcerpt ? ` | stderr: ${stderrExcerpt.trim()}` : ""}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const envFingerprint = createHash("sha256").update(stableStringify(envConfig)).digest("hex");
|
||||||
|
return {
|
||||||
|
id: randomUUID(),
|
||||||
|
companyId: input.agent.companyId,
|
||||||
|
projectId: input.workspace.projectId,
|
||||||
|
projectWorkspaceId: input.workspace.workspaceId,
|
||||||
|
issueId: input.issue?.id ?? null,
|
||||||
|
serviceName,
|
||||||
|
status: "running",
|
||||||
|
lifecycle,
|
||||||
|
scopeType: input.scopeType,
|
||||||
|
scopeId: input.scopeId,
|
||||||
|
reuseKey: input.reuseKey,
|
||||||
|
command,
|
||||||
|
cwd: serviceCwd,
|
||||||
|
port,
|
||||||
|
url,
|
||||||
|
provider: "local_process",
|
||||||
|
providerRef: child.pid ? String(child.pid) : null,
|
||||||
|
ownerAgentId: input.agent.id,
|
||||||
|
startedByRunId: input.runId,
|
||||||
|
lastUsedAt: new Date().toISOString(),
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
stoppedAt: null,
|
||||||
|
stopPolicy: parseObject(input.service.stopPolicy),
|
||||||
|
healthStatus: "healthy",
|
||||||
|
reused: false,
|
||||||
|
db: input.db,
|
||||||
|
child,
|
||||||
|
leaseRunIds: new Set([input.runId]),
|
||||||
|
idleTimer: null,
|
||||||
|
envFingerprint,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleIdleStop(record: RuntimeServiceRecord) {
|
||||||
|
clearIdleTimer(record);
|
||||||
|
const stopType = asString(record.stopPolicy?.type, "manual");
|
||||||
|
if (stopType !== "idle_timeout") return;
|
||||||
|
const idleSeconds = Math.max(1, asNumber(record.stopPolicy?.idleSeconds, 1800));
|
||||||
|
record.idleTimer = setTimeout(() => {
|
||||||
|
stopRuntimeService(record.id).catch(() => undefined);
|
||||||
|
}, idleSeconds * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopRuntimeService(serviceId: string) {
|
||||||
|
const record = runtimeServicesById.get(serviceId);
|
||||||
|
if (!record) return;
|
||||||
|
clearIdleTimer(record);
|
||||||
|
record.status = "stopped";
|
||||||
|
record.lastUsedAt = new Date().toISOString();
|
||||||
|
record.stoppedAt = new Date().toISOString();
|
||||||
|
if (record.child && !record.child.killed) {
|
||||||
|
record.child.kill("SIGTERM");
|
||||||
|
}
|
||||||
|
runtimeServicesById.delete(serviceId);
|
||||||
|
if (record.reuseKey) {
|
||||||
|
runtimeServicesByReuseKey.delete(record.reuseKey);
|
||||||
|
}
|
||||||
|
await persistRuntimeServiceRecord(record.db, record);
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerRuntimeService(db: Db | undefined, record: RuntimeServiceRecord) {
|
||||||
|
record.db = db;
|
||||||
|
runtimeServicesById.set(record.id, record);
|
||||||
|
if (record.reuseKey) {
|
||||||
|
runtimeServicesByReuseKey.set(record.reuseKey, record.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
record.child?.on("exit", (code, signal) => {
|
||||||
|
const current = runtimeServicesById.get(record.id);
|
||||||
|
if (!current) return;
|
||||||
|
clearIdleTimer(current);
|
||||||
|
current.status = code === 0 || signal === "SIGTERM" ? "stopped" : "failed";
|
||||||
|
current.healthStatus = current.status === "failed" ? "unhealthy" : "unknown";
|
||||||
|
current.lastUsedAt = new Date().toISOString();
|
||||||
|
current.stoppedAt = new Date().toISOString();
|
||||||
|
runtimeServicesById.delete(current.id);
|
||||||
|
if (current.reuseKey && runtimeServicesByReuseKey.get(current.reuseKey) === current.id) {
|
||||||
|
runtimeServicesByReuseKey.delete(current.reuseKey);
|
||||||
|
}
|
||||||
|
void persistRuntimeServiceRecord(db, current);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureRuntimeServicesForRun(input: {
|
||||||
|
db?: Db;
|
||||||
|
runId: string;
|
||||||
|
agent: ExecutionWorkspaceAgentRef;
|
||||||
|
issue: ExecutionWorkspaceIssueRef | null;
|
||||||
|
workspace: RealizedExecutionWorkspace;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
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 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,
|
||||||
|
issue: input.issue,
|
||||||
|
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;
|
||||||
|
|
||||||
|
if (reuseKey) {
|
||||||
|
const existingId = runtimeServicesByReuseKey.get(reuseKey);
|
||||||
|
const existing = existingId ? runtimeServicesById.get(existingId) : null;
|
||||||
|
if (existing && existing.status === "running") {
|
||||||
|
existing.leaseRunIds.add(input.runId);
|
||||||
|
existing.lastUsedAt = new Date().toISOString();
|
||||||
|
existing.stoppedAt = null;
|
||||||
|
clearIdleTimer(existing);
|
||||||
|
await persistRuntimeServiceRecord(input.db, existing);
|
||||||
|
acquiredServiceIds.push(existing.id);
|
||||||
|
refs.push(toRuntimeServiceRef(existing, { reused: true }));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = await startLocalRuntimeService({
|
||||||
|
db: input.db,
|
||||||
|
runId: input.runId,
|
||||||
|
agent: input.agent,
|
||||||
|
issue: input.issue,
|
||||||
|
workspace: input.workspace,
|
||||||
|
adapterEnv: input.adapterEnv,
|
||||||
|
service,
|
||||||
|
onLog: input.onLog,
|
||||||
|
reuseKey,
|
||||||
|
scopeType,
|
||||||
|
scopeId,
|
||||||
|
});
|
||||||
|
registerRuntimeService(input.db, record);
|
||||||
|
await persistRuntimeServiceRecord(input.db, record);
|
||||||
|
acquiredServiceIds.push(record.id);
|
||||||
|
refs.push(toRuntimeServiceRef(record));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
await releaseRuntimeServicesForRun(input.runId);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
return refs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function releaseRuntimeServicesForRun(runId: string) {
|
||||||
|
const acquired = runtimeServiceLeasesByRun.get(runId) ?? [];
|
||||||
|
runtimeServiceLeasesByRun.delete(runId);
|
||||||
|
for (const serviceId of acquired) {
|
||||||
|
const record = runtimeServicesById.get(serviceId);
|
||||||
|
if (!record) continue;
|
||||||
|
record.leaseRunIds.delete(runId);
|
||||||
|
record.lastUsedAt = new Date().toISOString();
|
||||||
|
const stopType = asString(record.stopPolicy?.type, record.lifecycle === "ephemeral" ? "on_run_finish" : "manual");
|
||||||
|
await persistRuntimeServiceRecord(record.db, record);
|
||||||
|
if (record.leaseRunIds.size === 0) {
|
||||||
|
if (record.lifecycle === "ephemeral" || stopType === "on_run_finish") {
|
||||||
|
await stopRuntimeService(serviceId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
scheduleIdleStop(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listWorkspaceRuntimeServicesForProjectWorkspaces(
|
||||||
|
db: Db,
|
||||||
|
companyId: string,
|
||||||
|
projectWorkspaceIds: string[],
|
||||||
|
) {
|
||||||
|
if (projectWorkspaceIds.length === 0) return new Map<string, typeof workspaceRuntimeServices.$inferSelect[]>();
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(workspaceRuntimeServices)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(workspaceRuntimeServices.companyId, companyId),
|
||||||
|
inArray(workspaceRuntimeServices.projectWorkspaceId, projectWorkspaceIds),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt));
|
||||||
|
|
||||||
|
const grouped = new Map<string, typeof workspaceRuntimeServices.$inferSelect[]>();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!row.projectWorkspaceId) continue;
|
||||||
|
const existing = grouped.get(row.projectWorkspaceId);
|
||||||
|
if (existing) existing.push(row);
|
||||||
|
else grouped.set(row.projectWorkspaceId, [row]);
|
||||||
|
}
|
||||||
|
return grouped;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reconcilePersistedRuntimeServicesOnStartup(db: Db) {
|
||||||
|
const staleRows = await db
|
||||||
|
.select({ id: workspaceRuntimeServices.id })
|
||||||
|
.from(workspaceRuntimeServices)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(workspaceRuntimeServices.provider, "local_process"),
|
||||||
|
inArray(workspaceRuntimeServices.status, ["starting", "running"]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (staleRows.length === 0) return { reconciled: 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"]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { reconciled: staleRows.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function persistAdapterManagedRuntimeServices(input: {
|
||||||
|
db: Db;
|
||||||
|
adapterType: string;
|
||||||
|
runId: string;
|
||||||
|
agent: ExecutionWorkspaceAgentRef;
|
||||||
|
issue: ExecutionWorkspaceIssueRef | null;
|
||||||
|
workspace: RealizedExecutionWorkspace;
|
||||||
|
reports: AdapterRuntimeServiceReport[];
|
||||||
|
}) {
|
||||||
|
const refs = normalizeAdapterManagedRuntimeServices(input);
|
||||||
|
if (refs.length === 0) return refs;
|
||||||
|
|
||||||
|
const existingRows = await input.db
|
||||||
|
.select()
|
||||||
|
.from(workspaceRuntimeServices)
|
||||||
|
.where(inArray(workspaceRuntimeServices.id, refs.map((ref) => ref.id)));
|
||||||
|
const existingById = new Map(existingRows.map((row) => [row.id, row]));
|
||||||
|
|
||||||
|
for (const ref of refs) {
|
||||||
|
const existing = existingById.get(ref.id);
|
||||||
|
const startedAt = existing?.startedAt ?? new Date(ref.startedAt);
|
||||||
|
const createdAt = existing?.createdAt ?? new Date();
|
||||||
|
await input.db
|
||||||
|
.insert(workspaceRuntimeServices)
|
||||||
|
.values({
|
||||||
|
id: ref.id,
|
||||||
|
companyId: ref.companyId,
|
||||||
|
projectId: ref.projectId,
|
||||||
|
projectWorkspaceId: ref.projectWorkspaceId,
|
||||||
|
issueId: ref.issueId,
|
||||||
|
scopeType: ref.scopeType,
|
||||||
|
scopeId: ref.scopeId,
|
||||||
|
serviceName: ref.serviceName,
|
||||||
|
status: ref.status,
|
||||||
|
lifecycle: ref.lifecycle,
|
||||||
|
reuseKey: ref.reuseKey,
|
||||||
|
command: ref.command,
|
||||||
|
cwd: ref.cwd,
|
||||||
|
port: ref.port,
|
||||||
|
url: ref.url,
|
||||||
|
provider: ref.provider,
|
||||||
|
providerRef: ref.providerRef,
|
||||||
|
ownerAgentId: ref.ownerAgentId,
|
||||||
|
startedByRunId: ref.startedByRunId,
|
||||||
|
lastUsedAt: new Date(ref.lastUsedAt),
|
||||||
|
startedAt,
|
||||||
|
stoppedAt: ref.stoppedAt ? new Date(ref.stoppedAt) : null,
|
||||||
|
stopPolicy: ref.stopPolicy,
|
||||||
|
healthStatus: ref.healthStatus,
|
||||||
|
createdAt,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: workspaceRuntimeServices.id,
|
||||||
|
set: {
|
||||||
|
projectId: ref.projectId,
|
||||||
|
projectWorkspaceId: ref.projectWorkspaceId,
|
||||||
|
issueId: ref.issueId,
|
||||||
|
scopeType: ref.scopeType,
|
||||||
|
scopeId: ref.scopeId,
|
||||||
|
serviceName: ref.serviceName,
|
||||||
|
status: ref.status,
|
||||||
|
lifecycle: ref.lifecycle,
|
||||||
|
reuseKey: ref.reuseKey,
|
||||||
|
command: ref.command,
|
||||||
|
cwd: ref.cwd,
|
||||||
|
port: ref.port,
|
||||||
|
url: ref.url,
|
||||||
|
provider: ref.provider,
|
||||||
|
providerRef: ref.providerRef,
|
||||||
|
ownerAgentId: ref.ownerAgentId,
|
||||||
|
startedByRunId: ref.startedByRunId,
|
||||||
|
lastUsedAt: new Date(ref.lastUsedAt),
|
||||||
|
startedAt,
|
||||||
|
stoppedAt: ref.stoppedAt ? new Date(ref.stoppedAt) : null,
|
||||||
|
stopPolicy: ref.stopPolicy,
|
||||||
|
healthStatus: ref.healthStatus,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return refs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildWorkspaceReadyComment(input: {
|
||||||
|
workspace: RealizedExecutionWorkspace;
|
||||||
|
runtimeServices: RuntimeServiceRef[];
|
||||||
|
}) {
|
||||||
|
const lines = ["## Workspace Ready", ""];
|
||||||
|
lines.push(`- Strategy: \`${input.workspace.strategy}\``);
|
||||||
|
if (input.workspace.branchName) lines.push(`- Branch: \`${input.workspace.branchName}\``);
|
||||||
|
lines.push(`- CWD: \`${input.workspace.cwd}\``);
|
||||||
|
if (input.workspace.worktreePath && input.workspace.worktreePath !== input.workspace.cwd) {
|
||||||
|
lines.push(`- Worktree: \`${input.workspace.worktreePath}\``);
|
||||||
|
}
|
||||||
|
for (const service of input.runtimeServices) {
|
||||||
|
const detail = service.url ? `${service.serviceName}: ${service.url}` : `${service.serviceName}: running`;
|
||||||
|
const suffix = service.reused ? " (reused)" : "";
|
||||||
|
lines.push(`- Service: ${detail}${suffix}`);
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
help,
|
help,
|
||||||
} from "../../components/agent-config-primitives";
|
} from "../../components/agent-config-primitives";
|
||||||
import { ChoosePathButton } from "../../components/PathInstructionsModal";
|
import { ChoosePathButton } from "../../components/PathInstructionsModal";
|
||||||
|
import { LocalWorkspaceRuntimeFields } from "../local-workspace-runtime-fields";
|
||||||
|
|
||||||
const inputClass =
|
const inputClass =
|
||||||
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
|
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
|
||||||
@@ -15,38 +16,54 @@ const instructionsFileHint =
|
|||||||
"Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime.";
|
"Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime.";
|
||||||
|
|
||||||
export function ClaudeLocalConfigFields({
|
export function ClaudeLocalConfigFields({
|
||||||
|
mode,
|
||||||
isCreate,
|
isCreate,
|
||||||
|
adapterType,
|
||||||
values,
|
values,
|
||||||
set,
|
set,
|
||||||
config,
|
config,
|
||||||
eff,
|
eff,
|
||||||
mark,
|
mark,
|
||||||
|
models,
|
||||||
}: AdapterConfigFieldsProps) {
|
}: AdapterConfigFieldsProps) {
|
||||||
return (
|
return (
|
||||||
<Field label="Agent instructions file" hint={instructionsFileHint}>
|
<>
|
||||||
<div className="flex items-center gap-2">
|
<Field label="Agent instructions file" hint={instructionsFileHint}>
|
||||||
<DraftInput
|
<div className="flex items-center gap-2">
|
||||||
value={
|
<DraftInput
|
||||||
isCreate
|
value={
|
||||||
? values!.instructionsFilePath ?? ""
|
isCreate
|
||||||
: eff(
|
? values!.instructionsFilePath ?? ""
|
||||||
"adapterConfig",
|
: eff(
|
||||||
"instructionsFilePath",
|
"adapterConfig",
|
||||||
String(config.instructionsFilePath ?? ""),
|
"instructionsFilePath",
|
||||||
)
|
String(config.instructionsFilePath ?? ""),
|
||||||
}
|
)
|
||||||
onCommit={(v) =>
|
}
|
||||||
isCreate
|
onCommit={(v) =>
|
||||||
? set!({ instructionsFilePath: v })
|
isCreate
|
||||||
: mark("adapterConfig", "instructionsFilePath", v || undefined)
|
? set!({ instructionsFilePath: v })
|
||||||
}
|
: mark("adapterConfig", "instructionsFilePath", v || undefined)
|
||||||
immediate
|
}
|
||||||
className={inputClass}
|
immediate
|
||||||
placeholder="/absolute/path/to/AGENTS.md"
|
className={inputClass}
|
||||||
/>
|
placeholder="/absolute/path/to/AGENTS.md"
|
||||||
<ChoosePathButton />
|
/>
|
||||||
</div>
|
<ChoosePathButton />
|
||||||
</Field>
|
</div>
|
||||||
|
</Field>
|
||||||
|
<LocalWorkspaceRuntimeFields
|
||||||
|
isCreate={isCreate}
|
||||||
|
values={values}
|
||||||
|
set={set}
|
||||||
|
config={config}
|
||||||
|
mark={mark}
|
||||||
|
eff={eff}
|
||||||
|
mode={mode}
|
||||||
|
adapterType={adapterType}
|
||||||
|
models={models}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
help,
|
help,
|
||||||
} from "../../components/agent-config-primitives";
|
} from "../../components/agent-config-primitives";
|
||||||
import { ChoosePathButton } from "../../components/PathInstructionsModal";
|
import { ChoosePathButton } from "../../components/PathInstructionsModal";
|
||||||
|
import { LocalWorkspaceRuntimeFields } from "../local-workspace-runtime-fields";
|
||||||
|
|
||||||
const inputClass =
|
const inputClass =
|
||||||
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
|
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
|
||||||
@@ -13,12 +14,15 @@ const instructionsFileHint =
|
|||||||
"Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime.";
|
"Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime.";
|
||||||
|
|
||||||
export function CodexLocalConfigFields({
|
export function CodexLocalConfigFields({
|
||||||
|
mode,
|
||||||
isCreate,
|
isCreate,
|
||||||
|
adapterType,
|
||||||
values,
|
values,
|
||||||
set,
|
set,
|
||||||
config,
|
config,
|
||||||
eff,
|
eff,
|
||||||
mark,
|
mark,
|
||||||
|
models,
|
||||||
}: AdapterConfigFieldsProps) {
|
}: AdapterConfigFieldsProps) {
|
||||||
const bypassEnabled =
|
const bypassEnabled =
|
||||||
config.dangerouslyBypassApprovalsAndSandbox === true || config.dangerouslyBypassSandbox === true;
|
config.dangerouslyBypassApprovalsAndSandbox === true || config.dangerouslyBypassSandbox === true;
|
||||||
@@ -81,6 +85,17 @@ export function CodexLocalConfigFields({
|
|||||||
: mark("adapterConfig", "search", v)
|
: mark("adapterConfig", "search", v)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<LocalWorkspaceRuntimeFields
|
||||||
|
isCreate={isCreate}
|
||||||
|
values={values}
|
||||||
|
set={set}
|
||||||
|
config={config}
|
||||||
|
mark={mark}
|
||||||
|
eff={eff}
|
||||||
|
mode={mode}
|
||||||
|
adapterType={adapterType}
|
||||||
|
models={models}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
136
ui/src/adapters/local-workspace-runtime-fields.tsx
Normal file
136
ui/src/adapters/local-workspace-runtime-fields.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import type { AdapterConfigFieldsProps } from "./types";
|
||||||
|
import { DraftInput, Field, help } from "../components/agent-config-primitives";
|
||||||
|
import { RuntimeServicesJsonField } from "./runtime-json-fields";
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function asString(value: unknown): string {
|
||||||
|
return typeof value === "string" ? value : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function readWorkspaceStrategy(config: Record<string, unknown>) {
|
||||||
|
const strategy = asRecord(config.workspaceStrategy);
|
||||||
|
const type = asString(strategy.type) || "project_primary";
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
baseRef: asString(strategy.baseRef),
|
||||||
|
branchTemplate: asString(strategy.branchTemplate),
|
||||||
|
worktreeParentDir: asString(strategy.worktreeParentDir),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWorkspaceStrategyPatch(input: {
|
||||||
|
type: string;
|
||||||
|
baseRef?: string;
|
||||||
|
branchTemplate?: string;
|
||||||
|
worktreeParentDir?: string;
|
||||||
|
}) {
|
||||||
|
if (input.type !== "git_worktree") return undefined;
|
||||||
|
return {
|
||||||
|
type: "git_worktree",
|
||||||
|
...(input.baseRef ? { baseRef: input.baseRef } : {}),
|
||||||
|
...(input.branchTemplate ? { branchTemplate: input.branchTemplate } : {}),
|
||||||
|
...(input.worktreeParentDir ? { worktreeParentDir: input.worktreeParentDir } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LocalWorkspaceRuntimeFields({
|
||||||
|
isCreate,
|
||||||
|
values,
|
||||||
|
set,
|
||||||
|
config,
|
||||||
|
mark,
|
||||||
|
}: AdapterConfigFieldsProps) {
|
||||||
|
const existing = readWorkspaceStrategy(config);
|
||||||
|
const strategyType = isCreate ? values!.workspaceStrategyType ?? "project_primary" : existing.type;
|
||||||
|
const updateEditWorkspaceStrategy = (patch: Partial<typeof existing>) => {
|
||||||
|
const next = {
|
||||||
|
...existing,
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
mark(
|
||||||
|
"adapterConfig",
|
||||||
|
"workspaceStrategy",
|
||||||
|
buildWorkspaceStrategyPatch(next),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Field label="Workspace strategy" hint={help.workspaceStrategy}>
|
||||||
|
<select
|
||||||
|
className={inputClass}
|
||||||
|
value={strategyType}
|
||||||
|
onChange={(e) => {
|
||||||
|
const nextType = e.target.value;
|
||||||
|
if (isCreate) {
|
||||||
|
set!({ workspaceStrategyType: nextType });
|
||||||
|
} else {
|
||||||
|
updateEditWorkspaceStrategy({ type: nextType });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="project_primary">Project primary workspace</option>
|
||||||
|
<option value="git_worktree">Git worktree</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{strategyType === "git_worktree" && (
|
||||||
|
<>
|
||||||
|
<Field label="Base ref" hint={help.workspaceBaseRef}>
|
||||||
|
<DraftInput
|
||||||
|
value={isCreate ? values!.workspaceBaseRef ?? "" : existing.baseRef}
|
||||||
|
onCommit={(v) =>
|
||||||
|
isCreate
|
||||||
|
? set!({ workspaceBaseRef: v })
|
||||||
|
: updateEditWorkspaceStrategy({ baseRef: v || "" })
|
||||||
|
}
|
||||||
|
immediate
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="origin/main"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Branch template" hint={help.workspaceBranchTemplate}>
|
||||||
|
<DraftInput
|
||||||
|
value={isCreate ? values!.workspaceBranchTemplate ?? "" : existing.branchTemplate}
|
||||||
|
onCommit={(v) =>
|
||||||
|
isCreate
|
||||||
|
? set!({ workspaceBranchTemplate: v })
|
||||||
|
: updateEditWorkspaceStrategy({ branchTemplate: v || "" })
|
||||||
|
}
|
||||||
|
immediate
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="{{issue.identifier}}-{{slug}}"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Worktree parent dir" hint={help.worktreeParentDir}>
|
||||||
|
<DraftInput
|
||||||
|
value={isCreate ? values!.worktreeParentDir ?? "" : existing.worktreeParentDir}
|
||||||
|
onCommit={(v) =>
|
||||||
|
isCreate
|
||||||
|
? set!({ worktreeParentDir: v })
|
||||||
|
: updateEditWorkspaceStrategy({ worktreeParentDir: v || "" })
|
||||||
|
}
|
||||||
|
immediate
|
||||||
|
className={inputClass}
|
||||||
|
placeholder=".paperclip/worktrees"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<RuntimeServicesJsonField
|
||||||
|
isCreate={isCreate}
|
||||||
|
values={values}
|
||||||
|
set={set}
|
||||||
|
config={config}
|
||||||
|
mark={mark}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,10 @@ import {
|
|||||||
DraftInput,
|
DraftInput,
|
||||||
help,
|
help,
|
||||||
} from "../../components/agent-config-primitives";
|
} from "../../components/agent-config-primitives";
|
||||||
|
import {
|
||||||
|
PayloadTemplateJsonField,
|
||||||
|
RuntimeServicesJsonField,
|
||||||
|
} from "../runtime-json-fields";
|
||||||
|
|
||||||
const inputClass =
|
const inputClass =
|
||||||
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
|
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
|
||||||
@@ -112,6 +116,22 @@ export function OpenClawGatewayConfigFields({
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
<PayloadTemplateJsonField
|
||||||
|
isCreate={isCreate}
|
||||||
|
values={values}
|
||||||
|
set={set}
|
||||||
|
config={config}
|
||||||
|
mark={mark}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RuntimeServicesJsonField
|
||||||
|
isCreate={isCreate}
|
||||||
|
values={values}
|
||||||
|
set={set}
|
||||||
|
config={config}
|
||||||
|
mark={mark}
|
||||||
|
/>
|
||||||
|
|
||||||
{!isCreate && (
|
{!isCreate && (
|
||||||
<>
|
<>
|
||||||
<Field label="Paperclip API URL override">
|
<Field label="Paperclip API URL override">
|
||||||
|
|||||||
115
ui/src/adapters/runtime-json-fields.tsx
Normal file
115
ui/src/adapters/runtime-json-fields.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { AdapterConfigFieldsProps } from "./types";
|
||||||
|
import { Field, help } from "../components/agent-config-primitives";
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatJsonObject(value: unknown): string {
|
||||||
|
const record = asRecord(value);
|
||||||
|
return Object.keys(record).length > 0 ? JSON.stringify(record, null, 2) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateJsonConfig(
|
||||||
|
isCreate: boolean,
|
||||||
|
key: "runtimeServicesJson" | "payloadTemplateJson",
|
||||||
|
next: string,
|
||||||
|
set: AdapterConfigFieldsProps["set"],
|
||||||
|
mark: AdapterConfigFieldsProps["mark"],
|
||||||
|
configKey: string,
|
||||||
|
) {
|
||||||
|
if (isCreate) {
|
||||||
|
set?.({ [key]: next });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = next.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
mark("adapterConfig", configKey, undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed);
|
||||||
|
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
||||||
|
mark("adapterConfig", configKey, parsed);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Keep local draft until JSON is valid.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type JsonFieldProps = Pick<
|
||||||
|
AdapterConfigFieldsProps,
|
||||||
|
"isCreate" | "values" | "set" | "config" | "mark"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function RuntimeServicesJsonField({
|
||||||
|
isCreate,
|
||||||
|
values,
|
||||||
|
set,
|
||||||
|
config,
|
||||||
|
mark,
|
||||||
|
}: JsonFieldProps) {
|
||||||
|
const existing = formatJsonObject(config.workspaceRuntime);
|
||||||
|
const [draft, setDraft] = useState(existing);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isCreate) setDraft(existing);
|
||||||
|
}, [existing, isCreate]);
|
||||||
|
|
||||||
|
const value = isCreate ? values?.runtimeServicesJson ?? "" : draft;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Field label="Runtime services JSON" hint={help.runtimeServicesJson}>
|
||||||
|
<textarea
|
||||||
|
className={`${inputClass} min-h-[148px]`}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = e.target.value;
|
||||||
|
if (!isCreate) setDraft(next);
|
||||||
|
updateJsonConfig(isCreate, "runtimeServicesJson", next, set, mark, "workspaceRuntime");
|
||||||
|
}}
|
||||||
|
placeholder={`{\n "services": [\n {\n "name": "preview",\n "lifecycle": "ephemeral",\n "metadata": {\n "purpose": "remote preview"\n }\n }\n ]\n}`}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PayloadTemplateJsonField({
|
||||||
|
isCreate,
|
||||||
|
values,
|
||||||
|
set,
|
||||||
|
config,
|
||||||
|
mark,
|
||||||
|
}: JsonFieldProps) {
|
||||||
|
const existing = formatJsonObject(config.payloadTemplate);
|
||||||
|
const [draft, setDraft] = useState(existing);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isCreate) setDraft(existing);
|
||||||
|
}, [existing, isCreate]);
|
||||||
|
|
||||||
|
const value = isCreate ? values?.payloadTemplateJson ?? "" : draft;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Field label="Payload template JSON" hint={help.payloadTemplateJson}>
|
||||||
|
<textarea
|
||||||
|
className={`${inputClass} min-h-[132px]`}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = e.target.value;
|
||||||
|
if (!isCreate) setDraft(next);
|
||||||
|
updateJsonConfig(isCreate, "payloadTemplateJson", next, set, mark, "payloadTemplate");
|
||||||
|
}}
|
||||||
|
placeholder={`{\n "agentId": "remote-agent-123",\n "metadata": {\n "team": "platform"\n }\n}`}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -407,6 +407,51 @@ export function ProjectProperties({ project, onUpdate }: ProjectPropertiesProps)
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{workspace.runtimeServices && workspace.runtimeServices.length > 0 ? (
|
||||||
|
<div className="space-y-1 pl-2">
|
||||||
|
{workspace.runtimeServices.map((service) => (
|
||||||
|
<div
|
||||||
|
key={service.id}
|
||||||
|
className="flex items-center justify-between gap-2 rounded-md border border-border/60 px-2 py-1"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 space-y-0.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[11px] font-medium">{service.serviceName}</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"rounded-full px-1.5 py-0.5 text-[10px] uppercase tracking-wide",
|
||||||
|
service.status === "running"
|
||||||
|
? "bg-green-500/15 text-green-700 dark:text-green-300"
|
||||||
|
: service.status === "failed"
|
||||||
|
? "bg-red-500/15 text-red-700 dark:text-red-300"
|
||||||
|
: "bg-muted text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{service.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-muted-foreground">
|
||||||
|
{service.url ? (
|
||||||
|
<a
|
||||||
|
href={service.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="hover:text-foreground hover:underline"
|
||||||
|
>
|
||||||
|
{service.url}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
service.command ?? "No URL"
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground whitespace-nowrap">
|
||||||
|
{service.lifecycle}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ export const defaultCreateValues: CreateConfigValues = {
|
|||||||
envBindings: {},
|
envBindings: {},
|
||||||
url: "",
|
url: "",
|
||||||
bootstrapPrompt: "",
|
bootstrapPrompt: "",
|
||||||
|
payloadTemplateJson: "",
|
||||||
|
workspaceStrategyType: "project_primary",
|
||||||
|
workspaceBaseRef: "",
|
||||||
|
workspaceBranchTemplate: "",
|
||||||
|
worktreeParentDir: "",
|
||||||
|
runtimeServicesJson: "",
|
||||||
maxTurnsPerRun: 80,
|
maxTurnsPerRun: 80,
|
||||||
heartbeatEnabled: false,
|
heartbeatEnabled: false,
|
||||||
intervalSec: 300,
|
intervalSec: 300,
|
||||||
|
|||||||
@@ -33,12 +33,19 @@ export const help: Record<string, string> = {
|
|||||||
dangerouslySkipPermissions: "Run Claude without permission prompts. Required for unattended operation.",
|
dangerouslySkipPermissions: "Run Claude without permission prompts. Required for unattended operation.",
|
||||||
dangerouslyBypassSandbox: "Run Codex without sandbox restrictions. Required for filesystem/network access.",
|
dangerouslyBypassSandbox: "Run Codex without sandbox restrictions. Required for filesystem/network access.",
|
||||||
search: "Enable Codex web search capability during runs.",
|
search: "Enable Codex web search capability during runs.",
|
||||||
|
workspaceStrategy: "How Paperclip should realize an execution workspace for this agent. Keep project_primary for normal cwd execution, or use git_worktree for issue-scoped isolated checkouts.",
|
||||||
|
workspaceBaseRef: "Base git ref used when creating a worktree branch. Leave blank to use the resolved workspace ref or HEAD.",
|
||||||
|
workspaceBranchTemplate: "Template for naming derived branches. Supports {{issue.identifier}}, {{issue.title}}, {{agent.name}}, {{project.id}}, {{workspace.repoRef}}, and {{slug}}.",
|
||||||
|
worktreeParentDir: "Directory where derived worktrees should be created. Absolute, ~-prefixed, and repo-relative paths are supported.",
|
||||||
|
runtimeServicesJson: "Optional workspace runtime service definitions. Use this for shared app servers, workers, or other long-lived companion processes attached to the workspace.",
|
||||||
maxTurnsPerRun: "Maximum number of agentic turns (tool calls) per heartbeat run.",
|
maxTurnsPerRun: "Maximum number of agentic turns (tool calls) per heartbeat run.",
|
||||||
command: "The command to execute (e.g. node, python).",
|
command: "The command to execute (e.g. node, python).",
|
||||||
localCommand: "Override the path to the CLI command you want the adapter to call (e.g. /usr/local/bin/claude, codex, opencode).",
|
localCommand: "Override the path to the CLI command you want the adapter to call (e.g. /usr/local/bin/claude, codex, opencode).",
|
||||||
args: "Command-line arguments, comma-separated.",
|
args: "Command-line arguments, comma-separated.",
|
||||||
extraArgs: "Extra CLI arguments for local adapters, comma-separated.",
|
extraArgs: "Extra CLI arguments for local adapters, comma-separated.",
|
||||||
envVars: "Environment variables injected into the adapter process. Use plain values or secret references.",
|
envVars: "Environment variables injected into the adapter process. Use plain values or secret references.",
|
||||||
|
bootstrapPrompt: "Optional prompt prepended on the first run to bootstrap the agent's environment or habits.",
|
||||||
|
payloadTemplateJson: "Optional JSON merged into remote adapter request payloads before Paperclip adds its standard wake and workspace fields.",
|
||||||
webhookUrl: "The URL that receives POST requests when the agent is invoked.",
|
webhookUrl: "The URL that receives POST requests when the agent is invoked.",
|
||||||
heartbeatInterval: "Run this agent automatically on a timer. Useful for periodic tasks like checking for new work.",
|
heartbeatInterval: "Run this agent automatically on a timer. Useful for periodic tasks like checking for new work.",
|
||||||
intervalSec: "Seconds between automatic heartbeat invocations.",
|
intervalSec: "Seconds between automatic heartbeat invocations.",
|
||||||
|
|||||||
Reference in New Issue
Block a user