diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 5fc28503..02488ec4 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -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/` + +This path honors `PAPERCLIP_HOME` and `PAPERCLIP_INSTANCE_ID` in non-default setups. + ## Quick Health Checks In another terminal: diff --git a/packages/adapters/claude-local/src/index.ts b/packages/adapters/claude-local/src/index.ts index e359b384..57a9b0c7 100644 --- a/packages/adapters/claude-local/src/index.ts +++ b/packages/adapters/claude-local/src/index.ts @@ -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 diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 573fb24c..4e115c51 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -63,6 +63,9 @@ interface ClaudeExecutionInput { interface ClaudeRuntimeConfig { command: string; cwd: string; + workspaceId: string | null; + workspaceRepoUrl: string | null; + workspaceRepoRef: string | null; env: Record; timeoutSec: number; graceSec: number; @@ -87,7 +90,13 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise 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) + ? ({ + sessionId: resolvedSessionId, + cwd, + ...(workspaceId ? { workspaceId } : {}), + ...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}), + ...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}), + } as Record) : null; return { diff --git a/packages/adapters/claude-local/src/server/index.ts b/packages/adapters/claude-local/src/server/index.ts index 6b6decd3..3cc14742 100644 --- a/packages/adapters/claude-local/src/server/index.ts +++ b/packages/adapters/claude-local/src/server/index.ts @@ -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 | 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 | null) { if (!params) return null; diff --git a/packages/adapters/codex-local/src/index.ts b/packages/adapters/codex-local/src/index.ts index a01b1346..dc943cc4 100644 --- a/packages/adapters/codex-local/src/index.ts +++ b/packages/adapters/codex-local/src/index.ts @@ -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 diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index 7b9a71a5..fa75c689 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -108,7 +108,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise 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) + ? ({ + sessionId: resolvedSessionId, + cwd, + ...(workspaceId ? { workspaceId } : {}), + ...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}), + ...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}), + } as Record) : null; const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : ""; const stderrLine = firstNonEmptyLine(attempt.proc.stderr); diff --git a/packages/adapters/codex-local/src/server/index.ts b/packages/adapters/codex-local/src/server/index.ts index 59560069..160db87c 100644 --- a/packages/adapters/codex-local/src/server/index.ts +++ b/packages/adapters/codex-local/src/server/index.ts @@ -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 | 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 | null) { if (!params) return null; diff --git a/server/src/home-paths.ts b/server/src/home-paths.ts index 3d370707..a1bb8f3f 100644 --- a/server/src/home-paths.ts +++ b/server/src/home-paths.ts @@ -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)); } diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 4629f49f..a12b799c 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -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, + previousSessionParams: Record | 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 ?? diff --git a/ui/src/components/agent-config-primitives.tsx b/ui/src/components/agent-config-primitives.tsx index c5e78f3b..b809a724 100644 --- a/ui/src/components/agent-config-primitives.tsx +++ b/ui/src/components/agent-config-primitives.tsx @@ -15,7 +15,7 @@ export const help: Record = { 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.",