feat: resolve agent workspace from session/project/fallback
Heartbeat service resolves cwd from task session, project primary workspace, or agent home directory (~/.paperclip/instances/.../workspaces/). Adapters receive workspace context and forward it as env vars and session params. cwd is now optional in adapter config. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -84,6 +84,14 @@ Configure storage provider/settings:
|
||||
pnpm paperclip configure --section storage
|
||||
```
|
||||
|
||||
## Default Agent Workspaces
|
||||
|
||||
When a local agent run has no resolved project/session workspace, Paperclip falls back to an agent home workspace under the instance root:
|
||||
|
||||
- `~/.paperclip/instances/default/workspaces/<agent-id>`
|
||||
|
||||
This path honors `PAPERCLIP_HOME` and `PAPERCLIP_INSTANCE_ID` in non-default setups.
|
||||
|
||||
## Quick Health Checks
|
||||
|
||||
In another terminal:
|
||||
|
||||
@@ -12,7 +12,7 @@ export const agentConfigurationDoc = `# claude_local agent configuration
|
||||
Adapter: claude_local
|
||||
|
||||
Core fields:
|
||||
- cwd (string, required): absolute working directory for the agent process
|
||||
- cwd (string, optional): default absolute working directory fallback for the agent process
|
||||
- model (string, optional): Claude model id
|
||||
- effort (string, optional): reasoning effort passed via --effort (low|medium|high)
|
||||
- promptTemplate (string, optional): run prompt template
|
||||
|
||||
@@ -63,6 +63,9 @@ interface ClaudeExecutionInput {
|
||||
interface ClaudeRuntimeConfig {
|
||||
command: string;
|
||||
cwd: string;
|
||||
workspaceId: string | null;
|
||||
workspaceRepoUrl: string | null;
|
||||
workspaceRepoRef: string | null;
|
||||
env: Record<string, string>;
|
||||
timeoutSec: number;
|
||||
graceSec: number;
|
||||
@@ -87,7 +90,13 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
|
||||
const { runId, agent, config, context, authToken } = input;
|
||||
|
||||
const command = asString(config.command, "claude");
|
||||
const cwd = asString(config.cwd, process.cwd());
|
||||
const workspaceContext = parseObject(context.paperclipWorkspace);
|
||||
const workspaceCwd = asString(workspaceContext.cwd, "");
|
||||
const workspaceSource = asString(workspaceContext.source, "");
|
||||
const workspaceId = asString(workspaceContext.workspaceId, "") || null;
|
||||
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "") || null;
|
||||
const workspaceRepoRef = asString(workspaceContext.repoRef, "") || null;
|
||||
const cwd = workspaceCwd || asString(config.cwd, process.cwd());
|
||||
await ensureAbsoluteDirectory(cwd);
|
||||
|
||||
const envConfig = parseObject(config.env);
|
||||
@@ -138,6 +147,21 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
|
||||
if (linkedIssueIds.length > 0) {
|
||||
env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
||||
}
|
||||
if (workspaceCwd) {
|
||||
env.PAPERCLIP_WORKSPACE_CWD = workspaceCwd;
|
||||
}
|
||||
if (workspaceSource) {
|
||||
env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
|
||||
}
|
||||
if (workspaceId) {
|
||||
env.PAPERCLIP_WORKSPACE_ID = workspaceId;
|
||||
}
|
||||
if (workspaceRepoUrl) {
|
||||
env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl;
|
||||
}
|
||||
if (workspaceRepoRef) {
|
||||
env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(envConfig)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
@@ -161,6 +185,9 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
|
||||
return {
|
||||
command,
|
||||
cwd,
|
||||
workspaceId,
|
||||
workspaceRepoUrl,
|
||||
workspaceRepoRef,
|
||||
env,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
@@ -225,7 +252,17 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
context,
|
||||
authToken,
|
||||
});
|
||||
const { command, cwd, env, timeoutSec, graceSec, extraArgs } = runtimeConfig;
|
||||
const {
|
||||
command,
|
||||
cwd,
|
||||
workspaceId,
|
||||
workspaceRepoUrl,
|
||||
workspaceRepoRef,
|
||||
env,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
extraArgs,
|
||||
} = runtimeConfig;
|
||||
const skillsDir = await buildSkillsDir();
|
||||
|
||||
const runtimeSessionParams = parseObject(runtime.sessionParams);
|
||||
@@ -372,7 +409,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
parsedStream.sessionId ??
|
||||
(asString(parsed.session_id, opts.fallbackSessionId ?? "") || opts.fallbackSessionId);
|
||||
const resolvedSessionParams = resolvedSessionId
|
||||
? ({ sessionId: resolvedSessionId, cwd } as Record<string, unknown>)
|
||||
? ({
|
||||
sessionId: resolvedSessionId,
|
||||
cwd,
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
|
||||
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
||||
} as Record<string, unknown>)
|
||||
: null;
|
||||
|
||||
return {
|
||||
|
||||
@@ -17,7 +17,16 @@ export const sessionCodec: AdapterSessionCodec = {
|
||||
readNonEmptyString(record.cwd) ??
|
||||
readNonEmptyString(record.workdir) ??
|
||||
readNonEmptyString(record.folder);
|
||||
return cwd ? { sessionId, cwd } : { sessionId };
|
||||
const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id);
|
||||
const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url);
|
||||
const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref);
|
||||
return {
|
||||
sessionId,
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(repoUrl ? { repoUrl } : {}),
|
||||
...(repoRef ? { repoRef } : {}),
|
||||
};
|
||||
},
|
||||
serialize(params: Record<string, unknown> | null) {
|
||||
if (!params) return null;
|
||||
@@ -27,7 +36,16 @@ export const sessionCodec: AdapterSessionCodec = {
|
||||
readNonEmptyString(params.cwd) ??
|
||||
readNonEmptyString(params.workdir) ??
|
||||
readNonEmptyString(params.folder);
|
||||
return cwd ? { sessionId, cwd } : { sessionId };
|
||||
const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id);
|
||||
const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url);
|
||||
const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref);
|
||||
return {
|
||||
sessionId,
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(repoUrl ? { repoUrl } : {}),
|
||||
...(repoRef ? { repoRef } : {}),
|
||||
};
|
||||
},
|
||||
getDisplayId(params: Record<string, unknown> | null) {
|
||||
if (!params) return null;
|
||||
|
||||
@@ -18,7 +18,7 @@ export const agentConfigurationDoc = `# codex_local agent configuration
|
||||
Adapter: codex_local
|
||||
|
||||
Core fields:
|
||||
- cwd (string, required): absolute working directory for the agent process
|
||||
- cwd (string, optional): default absolute working directory fallback for the agent process
|
||||
- model (string, optional): Codex model id
|
||||
- modelReasoningEffort (string, optional): reasoning effort override (minimal|low|medium|high) passed via -c model_reasoning_effort=...
|
||||
- promptTemplate (string, optional): run prompt template
|
||||
|
||||
@@ -108,7 +108,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
asBoolean(config.dangerouslyBypassSandbox, false),
|
||||
);
|
||||
|
||||
const cwd = asString(config.cwd, process.cwd());
|
||||
const workspaceContext = parseObject(context.paperclipWorkspace);
|
||||
const workspaceCwd = asString(workspaceContext.cwd, "");
|
||||
const workspaceSource = asString(workspaceContext.source, "");
|
||||
const workspaceId = asString(workspaceContext.workspaceId, "");
|
||||
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
|
||||
const workspaceRepoRef = asString(workspaceContext.repoRef, "");
|
||||
const cwd = workspaceCwd || asString(config.cwd, process.cwd());
|
||||
await ensureAbsoluteDirectory(cwd);
|
||||
await ensureCodexSkillsInjected(onLog);
|
||||
const envConfig = parseObject(config.env);
|
||||
@@ -157,6 +163,21 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
if (linkedIssueIds.length > 0) {
|
||||
env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
||||
}
|
||||
if (workspaceCwd) {
|
||||
env.PAPERCLIP_WORKSPACE_CWD = workspaceCwd;
|
||||
}
|
||||
if (workspaceSource) {
|
||||
env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
|
||||
}
|
||||
if (workspaceId) {
|
||||
env.PAPERCLIP_WORKSPACE_ID = workspaceId;
|
||||
}
|
||||
if (workspaceRepoUrl) {
|
||||
env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl;
|
||||
}
|
||||
if (workspaceRepoRef) {
|
||||
env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
|
||||
}
|
||||
for (const [k, v] of Object.entries(envConfig)) {
|
||||
if (typeof v === "string") env[k] = v;
|
||||
}
|
||||
@@ -270,7 +291,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
|
||||
const resolvedSessionId = attempt.parsed.sessionId ?? runtimeSessionId ?? runtime.sessionId ?? null;
|
||||
const resolvedSessionParams = resolvedSessionId
|
||||
? ({ sessionId: resolvedSessionId, cwd } as Record<string, unknown>)
|
||||
? ({
|
||||
sessionId: resolvedSessionId,
|
||||
cwd,
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
|
||||
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
||||
} as Record<string, unknown>)
|
||||
: null;
|
||||
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
|
||||
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
|
||||
|
||||
@@ -17,7 +17,16 @@ export const sessionCodec: AdapterSessionCodec = {
|
||||
readNonEmptyString(record.cwd) ??
|
||||
readNonEmptyString(record.workdir) ??
|
||||
readNonEmptyString(record.folder);
|
||||
return cwd ? { sessionId, cwd } : { sessionId };
|
||||
const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id);
|
||||
const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url);
|
||||
const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref);
|
||||
return {
|
||||
sessionId,
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(repoUrl ? { repoUrl } : {}),
|
||||
...(repoRef ? { repoRef } : {}),
|
||||
};
|
||||
},
|
||||
serialize(params: Record<string, unknown> | null) {
|
||||
if (!params) return null;
|
||||
@@ -27,7 +36,16 @@ export const sessionCodec: AdapterSessionCodec = {
|
||||
readNonEmptyString(params.cwd) ??
|
||||
readNonEmptyString(params.workdir) ??
|
||||
readNonEmptyString(params.folder);
|
||||
return cwd ? { sessionId, cwd } : { sessionId };
|
||||
const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id);
|
||||
const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url);
|
||||
const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref);
|
||||
return {
|
||||
sessionId,
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(repoUrl ? { repoUrl } : {}),
|
||||
...(repoRef ? { repoRef } : {}),
|
||||
};
|
||||
},
|
||||
getDisplayId(params: Record<string, unknown> | null) {
|
||||
if (!params) return null;
|
||||
|
||||
@@ -3,6 +3,7 @@ import path from "node:path";
|
||||
|
||||
const DEFAULT_INSTANCE_ID = "default";
|
||||
const INSTANCE_ID_RE = /^[a-zA-Z0-9_-]+$/;
|
||||
const PATH_SEGMENT_RE = /^[a-zA-Z0-9_-]+$/;
|
||||
|
||||
function expandHomePrefix(value: string): string {
|
||||
if (value === "~") return os.homedir();
|
||||
@@ -48,6 +49,14 @@ export function resolveDefaultStorageDir(): string {
|
||||
return path.resolve(resolvePaperclipInstanceRoot(), "data", "storage");
|
||||
}
|
||||
|
||||
export function resolveDefaultAgentWorkspaceDir(agentId: string): string {
|
||||
const trimmed = agentId.trim();
|
||||
if (!PATH_SEGMENT_RE.test(trimmed)) {
|
||||
throw new Error(`Invalid agent id for workspace path '${agentId}'.`);
|
||||
}
|
||||
return path.resolve(resolvePaperclipInstanceRoot(), "workspaces", trimmed);
|
||||
}
|
||||
|
||||
export function resolveHomeAwarePath(value: string): string {
|
||||
return path.resolve(expandHomePrefix(value));
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { and, asc, desc, eq, gt, inArray, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclip/db";
|
||||
import {
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
heartbeatRuns,
|
||||
costEvents,
|
||||
issues,
|
||||
projectWorkspaces,
|
||||
} from "@paperclip/db";
|
||||
import { conflict, notFound } from "../errors.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
@@ -19,6 +21,7 @@ import type { AdapterExecutionResult, AdapterInvocationMeta, AdapterSessionCodec
|
||||
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
|
||||
import { parseObject, asBoolean, asNumber, appendWithCap, MAX_EXCERPT_BYTES } from "../adapters/utils.js";
|
||||
import { secretService } from "./secrets.js";
|
||||
import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js";
|
||||
|
||||
const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024;
|
||||
const HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT = 1;
|
||||
@@ -336,6 +339,74 @@ export function heartbeatService(db: Db) {
|
||||
return runtimeForRun?.sessionId ?? null;
|
||||
}
|
||||
|
||||
async function resolveWorkspaceForRun(
|
||||
agent: typeof agents.$inferSelect,
|
||||
context: Record<string, unknown>,
|
||||
previousSessionParams: Record<string, unknown> | null,
|
||||
) {
|
||||
const sessionCwd = readNonEmptyString(previousSessionParams?.cwd);
|
||||
if (sessionCwd) {
|
||||
const sessionCwdExists = await fs
|
||||
.stat(sessionCwd)
|
||||
.then((stats) => stats.isDirectory())
|
||||
.catch(() => false);
|
||||
if (sessionCwdExists) {
|
||||
return {
|
||||
cwd: sessionCwd,
|
||||
source: "task_session" as const,
|
||||
projectId: readNonEmptyString(context.projectId),
|
||||
workspaceId: readNonEmptyString(previousSessionParams?.workspaceId),
|
||||
repoUrl: readNonEmptyString(previousSessionParams?.repoUrl),
|
||||
repoRef: readNonEmptyString(previousSessionParams?.repoRef),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const issueId = readNonEmptyString(context.issueId);
|
||||
if (issueId) {
|
||||
const issue = await db
|
||||
.select({ id: issues.id, projectId: issues.projectId })
|
||||
.from(issues)
|
||||
.where(and(eq(issues.id, issueId), eq(issues.companyId, agent.companyId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (issue?.projectId) {
|
||||
const workspace = await db
|
||||
.select()
|
||||
.from(projectWorkspaces)
|
||||
.where(
|
||||
and(
|
||||
eq(projectWorkspaces.companyId, agent.companyId),
|
||||
eq(projectWorkspaces.projectId, issue.projectId),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (workspace) {
|
||||
return {
|
||||
cwd: workspace.cwd,
|
||||
source: "project_primary" as const,
|
||||
projectId: issue.projectId,
|
||||
workspaceId: workspace.id,
|
||||
repoUrl: workspace.repoUrl,
|
||||
repoRef: workspace.repoRef,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cwd = resolveDefaultAgentWorkspaceDir(agent.id);
|
||||
await fs.mkdir(cwd, { recursive: true });
|
||||
return {
|
||||
cwd,
|
||||
source: "agent_home" as const,
|
||||
projectId: readNonEmptyString(context.projectId),
|
||||
workspaceId: null,
|
||||
repoUrl: null,
|
||||
repoRef: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function upsertTaskSession(input: {
|
||||
companyId: string;
|
||||
agentId: string;
|
||||
@@ -786,6 +857,18 @@ export function heartbeatService(db: Db) {
|
||||
const previousSessionParams = normalizeSessionParams(
|
||||
sessionCodec.deserialize(taskSession?.sessionParamsJson ?? null),
|
||||
);
|
||||
const resolvedWorkspace = await resolveWorkspaceForRun(agent, context, previousSessionParams);
|
||||
context.paperclipWorkspace = {
|
||||
cwd: resolvedWorkspace.cwd,
|
||||
source: resolvedWorkspace.source,
|
||||
projectId: resolvedWorkspace.projectId,
|
||||
workspaceId: resolvedWorkspace.workspaceId,
|
||||
repoUrl: resolvedWorkspace.repoUrl,
|
||||
repoRef: resolvedWorkspace.repoRef,
|
||||
};
|
||||
if (resolvedWorkspace.projectId && !readNonEmptyString(context.projectId)) {
|
||||
context.projectId = resolvedWorkspace.projectId;
|
||||
}
|
||||
const runtimeSessionFallback = taskKey ? null : runtime.sessionId;
|
||||
const previousSessionDisplayId = truncateDisplayId(
|
||||
taskSession?.sessionDisplayId ??
|
||||
|
||||
@@ -15,7 +15,7 @@ export const help: Record<string, string> = {
|
||||
reportsTo: "The agent this one reports to in the org hierarchy.",
|
||||
capabilities: "Describes what this agent can do. Shown in the org chart and used for task routing.",
|
||||
adapterType: "How this agent runs: local CLI (Claude/Codex), spawned process, or HTTP webhook.",
|
||||
cwd: "The working directory where the agent operates. Use an absolute path on the machine running Paperclip.",
|
||||
cwd: "Default working directory fallback for local adapters. Use an absolute path on the machine running Paperclip.",
|
||||
promptTemplate: "The prompt sent to the agent on each heartbeat. Supports {{ agent.id }}, {{ agent.name }}, {{ agent.role }} variables.",
|
||||
model: "Override the default model used by the adapter.",
|
||||
thinkingEffort: "Control model reasoning depth. Supported values vary by adapter/model.",
|
||||
|
||||
Reference in New Issue
Block a user