diff --git a/packages/shared/src/types/workspace-runtime.ts b/packages/shared/src/types/workspace-runtime.ts index 04e3c1e07f..42d1f29616 100644 --- a/packages/shared/src/types/workspace-runtime.ts +++ b/packages/shared/src/types/workspace-runtime.ts @@ -330,6 +330,19 @@ export interface WorkspaceRealizationRequest { branchName: string | null; worktreePath: string | null; }; + /** + * Read-only referenced (mentioned) project sources for this run, one per authorized additional + * project. Additive and backward-compatible: it defaults to an empty array for legacy payloads + * and for the anchor-only path. Additional sources are plain trees; they never get git-worktree + * realization (that stays the anchor-only path). + */ + additionalSources?: Array<{ + localPath: string; + projectId: string | null; + projectWorkspaceId: string | null; + repoUrl: string | null; + repoRef: string | null; + }>; runtimeOverlay: { provisionCommand: string | null; teardownCommand: string | null; @@ -360,6 +373,20 @@ export interface WorkspaceRealizationRecord { branchName: string | null; worktreePath: string | null; }; + /** + * Realized read-only referenced (mentioned) project workspaces for this run, one per authorized + * additional source in the request. This field carries each resolved path to the execution + * target so the target can expose the referenced trees to the agent. It is additive and + * backward-compatible: it defaults to an empty array for legacy records and for the anchor-only + * path. + */ + additional?: Array<{ + path: string; + projectId: string | null; + projectWorkspaceId: string | null; + repoUrl: string | null; + repoRef: string | null; + }>; remote: { path: string | null; host?: string | null; diff --git a/server/src/__tests__/heartbeat-project-env.test.ts b/server/src/__tests__/heartbeat-project-env.test.ts index b1f3848c0a..def19721e0 100644 --- a/server/src/__tests__/heartbeat-project-env.test.ts +++ b/server/src/__tests__/heartbeat-project-env.test.ts @@ -6,9 +6,18 @@ import { buildSkillMentionHref } from "@paperclipai/shared"; import { LOW_TRUST_REVIEW_PRESET, applyRunScopedMentionedSkillKeys, + buildRunWorkspaceHints, extractMentionedSkillIdsFromSources, + resolveAdditionalProjectWorkspace, + resolveAdditionalRunWorkspaces, resolveExecutionRunAdapterConfig, + type ResolveAdditionalProjectWorkspaceDeps, + type ResolveAdditionalRunWorkspacesOptions, + type ResolvedAdditionalWorkspace, + type RunReferencedProject, } from "../services/heartbeat.ts"; +import type { AuthorizationActor, AuthorizationDecision } from "../services/authorization.ts"; +import { resolveManagedProjectWorkspaceDir } from "../home-paths.ts"; describe("resolveExecutionRunAdapterConfig", () => { it("overlays environment, project, and routine env on top of agent env and unions secret keys", async () => { @@ -720,3 +729,394 @@ describe("applyRunScopedMentionedSkillKeys", () => { }); }); }); + +describe("resolveAdditionalRunWorkspaces", () => { + const companyId = "company-1"; + const issueId = "issue-1"; + + const allowDecision: AuthorizationDecision = { + allowed: true, + action: "project:read", + reason: "allow_company_agent", + explanation: "test allow", + }; + + // Build injectable dependencies. Every mentioned project is available and authorized by default. + function buildOptions( + overrides: Partial & { mentionedIds?: string[] }, + ): ResolveAdditionalRunWorkspacesOptions { + const mentionedIds = overrides.mentionedIds ?? []; + const actor: AuthorizationActor = { + type: "agent", + agentId: "agent-1", + companyId, + source: "agent_key", + }; + const issues: ResolveAdditionalRunWorkspacesOptions["issues"] = { + findMentionedProjectIds: async () => mentionedIds, + }; + const projects: ResolveAdditionalRunWorkspacesOptions["projects"] = { + listByIds: async (_companyId, ids) => + ids.map((id) => ({ id })) as unknown as Awaited< + ReturnType + >, + }; + const access: ResolveAdditionalRunWorkspacesOptions["access"] = { + decide: async () => allowDecision, + }; + const resolveProjectWorkspace = async ( + project: RunReferencedProject, + ): Promise => ({ + cwd: `/managed/${project.projectId}`, + projectId: project.projectId, + workspaceId: `${project.projectId}-ws`, + repoUrl: null, + repoRef: null, + }); + return { + enabled: true, + companyId, + actor, + issues, + projects, + access, + resolveProjectWorkspace, + ...overrides, + }; + } + + it("resolves additionalWorkspaces for each mentioned project when the sync flag is ON", async () => { + const options = buildOptions({ mentionedIds: ["project-a", "project-b"] }); + + const result = await resolveAdditionalRunWorkspaces(issueId, null, options); + + expect(result.additionalWorkspaces).toEqual([ + { + cwd: "/managed/project-a", + projectId: "project-a", + workspaceId: "project-a-ws", + repoUrl: null, + repoRef: null, + }, + { + cwd: "/managed/project-b", + projectId: "project-b", + workspaceId: "project-b-ws", + repoUrl: null, + repoRef: null, + }, + ]); + expect(result.warnings).toEqual([]); + }); + + it("returns empty additionalWorkspaces when the sync flag is OFF (anchor-only, unchanged)", async () => { + let mentionLookups = 0; + let workspaceResolves = 0; + const options = buildOptions({ + enabled: false, + mentionedIds: ["project-a", "project-b"], + issues: { + findMentionedProjectIds: async () => { + mentionLookups += 1; + return ["project-a", "project-b"]; + }, + }, + resolveProjectWorkspace: async (project) => { + workspaceResolves += 1; + return { + cwd: `/managed/${project.projectId}`, + projectId: project.projectId, + workspaceId: null, + repoUrl: null, + repoRef: null, + }; + }, + }); + + const result = await resolveAdditionalRunWorkspaces(issueId, null, options); + + expect(result).toEqual({ additionalWorkspaces: [], warnings: [] }); + // The flag-off path must be inert: no mention lookup and no workspace resolution run. + expect(mentionLookups).toBe(0); + expect(workspaceResolves).toBe(0); + }); + + it("isolates a failing mentioned-project clone with a warning, run still resolves", async () => { + const options = buildOptions({ + mentionedIds: ["project-a", "project-b"], + resolveProjectWorkspace: async (project) => { + if (project.projectId === "project-a") { + throw new Error("clone exploded"); + } + return { + cwd: `/managed/${project.projectId}`, + projectId: project.projectId, + workspaceId: null, + repoUrl: null, + repoRef: null, + }; + }, + }); + + const result = await resolveAdditionalRunWorkspaces(issueId, null, options); + + expect(result.additionalWorkspaces).toEqual([ + { + cwd: "/managed/project-b", + projectId: "project-b", + workspaceId: null, + repoUrl: null, + repoRef: null, + }, + ]); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain("project-a"); + expect(result.warnings[0]).toContain("clone exploded"); + }); + + it("skips referenced-project work on a remote target and warns when the issue mentions a project", async () => { + let mentionLookups = 0; + let workspaceResolves = 0; + const options = buildOptions({ + executionTargetIsRemote: true, + mentionedIds: ["project-a", "project-b"], + issues: { + findMentionedProjectIds: async () => { + mentionLookups += 1; + return ["project-a", "project-b"]; + }, + }, + resolveProjectWorkspace: async (project) => { + workspaceResolves += 1; + return { + cwd: `/managed/${project.projectId}`, + projectId: project.projectId, + workspaceId: null, + repoUrl: null, + repoRef: null, + }; + }, + }); + + const result = await resolveAdditionalRunWorkspaces(issueId, null, options); + + expect(result.additionalWorkspaces).toEqual([]); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain("local execution target"); + // The remote path must skip the per-project clone work whose result the target cannot receive. + expect(mentionLookups).toBe(1); + expect(workspaceResolves).toBe(0); + }); + + it("stays silent on a remote target when the issue mentions no referenced project", async () => { + const options = buildOptions({ + executionTargetIsRemote: true, + mentionedIds: [], + }); + + const result = await resolveAdditionalRunWorkspaces(issueId, null, options); + + expect(result).toEqual({ additionalWorkspaces: [], warnings: [] }); + }); +}); + +describe("buildRunWorkspaceHints", () => { + const anchorHints = [ + { workspaceId: "anchor-ws", cwd: "/anchor", repoUrl: "https://example.test/anchor.git", repoRef: "main" }, + ]; + + it("returns only the anchor hints when no referenced project resolves (flag OFF, inert)", () => { + const hints = buildRunWorkspaceHints({ workspaceHints: anchorHints, additionalWorkspaces: [] }); + + expect(hints).toEqual(anchorHints); + }); + + it("appends each referenced project workspace so the agent sees its path", () => { + const hints = buildRunWorkspaceHints({ + workspaceHints: anchorHints, + additionalWorkspaces: [ + { + cwd: "/managed/project-b", + projectId: "project-b", + workspaceId: "project-b-ws", + repoUrl: "https://example.test/b.git", + repoRef: "main", + }, + // The hint builder passes each field through unchanged, including a null workspaceId. + { cwd: "/managed/project-c", projectId: "project-c", workspaceId: null, repoUrl: null, repoRef: null }, + ], + }); + + expect(hints).toEqual([ + { workspaceId: "anchor-ws", cwd: "/anchor", repoUrl: "https://example.test/anchor.git", repoRef: "main" }, + { + workspaceId: "project-b-ws", + cwd: "/managed/project-b", + repoUrl: "https://example.test/b.git", + repoRef: "main", + projectId: "project-b", + }, + { workspaceId: null, cwd: "/managed/project-c", repoUrl: null, repoRef: null, projectId: "project-c" }, + ]); + }); +}); + +describe("resolveAdditionalProjectWorkspace", () => { + const companyId = "company-1"; + + function referencedProject(projectId: string): RunReferencedProject { + return { projectId, project: { id: projectId } as RunReferencedProject["project"] }; + } + + type WorkspaceRow = Awaited>[number]; + + function workspaceRow(overrides: Partial): WorkspaceRow { + return { id: "workspace-x", cwd: null, repoUrl: null, repoRef: null, ...overrides } as WorkspaceRow; + } + + // Build deps whose managed and configured resolvers are pure, so no database or filesystem runs. + function buildDeps( + overrides: Partial, + ): ResolveAdditionalProjectWorkspaceDeps { + return { + loadProjectWorkspaceRows: async () => [], + resolveConfiguredOrManagedProjectCwd: async (input) => ({ cwd: input.cwd ?? "/unset", warning: null }), + ensureManagedProjectWorkspace: async (input) => ({ + cwd: `/managed/${input.projectId}`, + warning: null, + }), + directoryHasContents: async () => true, + ...overrides, + }; + } + + it("throws instead of creating an empty managed directory when the project has no workspace rows", async () => { + let ensureCalls = 0; + const deps = buildDeps({ + loadProjectWorkspaceRows: async () => [], + ensureManagedProjectWorkspace: async (input) => { + ensureCalls += 1; + return { cwd: `/managed/${input.projectId}`, warning: null }; + }, + }); + + await expect( + resolveAdditionalProjectWorkspace({ companyId, project: referencedProject("project-b") }, deps), + ).rejects.toThrow(/project-b/); + // The fallback must not fabricate an empty managed directory for a project with no real source. + expect(ensureCalls).toBe(0); + }); + + it("throws when a workspace row supplies neither a checkout directory nor a repository URL", async () => { + let ensureCalls = 0; + const deps = buildDeps({ + loadProjectWorkspaceRows: async () => [workspaceRow({ id: "ws-empty", cwd: null, repoUrl: null })], + resolveConfiguredOrManagedProjectCwd: async (input) => ({ cwd: input.cwd ?? "/unset", warning: null }), + // directoryHasContents returns true for any path; the row must still be skipped before this runs. + directoryHasContents: async () => true, + ensureManagedProjectWorkspace: async (input) => { + ensureCalls += 1; + return { cwd: `/managed/${input.projectId}`, warning: null }; + }, + }); + + await expect( + resolveAdditionalProjectWorkspace({ companyId, project: referencedProject("project-c") }, deps), + ).rejects.toThrow(/project-c/); + // The row without a real source neither resolves a checkout nor triggers a managed fallback. + expect(ensureCalls).toBe(0); + }); + + it("throws when a configured checkout directory exists but has no content", async () => { + let ensureCalls = 0; + const deps = buildDeps({ + loadProjectWorkspaceRows: async () => [ + workspaceRow({ id: "ws-empty-dir", cwd: "/checkout/empty", repoUrl: null }), + ], + resolveConfiguredOrManagedProjectCwd: async (input) => ({ cwd: input.cwd ?? "/unset", warning: null }), + // The configured directory exists but holds no content, so it is not a realized workspace. + directoryHasContents: async () => false, + ensureManagedProjectWorkspace: async (input) => { + ensureCalls += 1; + return { cwd: `/managed/${input.projectId}`, warning: null }; + }, + }); + + await expect( + resolveAdditionalProjectWorkspace({ companyId, project: referencedProject("project-d") }, deps), + ).rejects.toThrow(/project-d/); + // An empty configured directory must not mask a missing workspace, and the row has no + // repository URL, so the managed fallback never runs. + expect(ensureCalls).toBe(0); + }); + + it("returns the first workspace row whose directory has content", async () => { + const deps = buildDeps({ + loadProjectWorkspaceRows: async () => [ + workspaceRow({ id: "ws-1", cwd: "/checkout/a", repoUrl: "https://example.test/a.git", repoRef: "main" }), + ], + resolveConfiguredOrManagedProjectCwd: async (input) => ({ cwd: input.cwd ?? "/unset", warning: null }), + directoryHasContents: async (cwd) => cwd === "/checkout/a", + }); + + const result = await resolveAdditionalProjectWorkspace({ companyId, project: referencedProject("project-a") }, deps); + + expect(result).toEqual({ + cwd: "/checkout/a", + projectId: "project-a", + workspaceId: "ws-1", + repoUrl: "https://example.test/a.git", + repoRef: "main", + }); + }); + + it("clones into the managed directory when configured rows point at missing paths", async () => { + let ensuredRepoUrl: string | null | undefined; + const deps = buildDeps({ + loadProjectWorkspaceRows: async () => [ + workspaceRow({ id: "ws-1", cwd: "/checkout/missing", repoUrl: "https://example.test/a.git", repoRef: "release" }), + ], + resolveConfiguredOrManagedProjectCwd: async (input) => ({ cwd: input.cwd ?? "/unset", warning: null }), + directoryHasContents: async () => false, + ensureManagedProjectWorkspace: async (input) => { + ensuredRepoUrl = input.repoUrl; + return { cwd: `/managed/${input.projectId}`, warning: null }; + }, + }); + + const result = await resolveAdditionalProjectWorkspace({ companyId, project: referencedProject("project-a") }, deps); + + expect(result).toEqual({ + cwd: "/managed/project-a", + projectId: "project-a", + workspaceId: "ws-1", + repoUrl: "https://example.test/a.git", + repoRef: "release", + }); + // The fallback clone reuses the repository URL from the first configured workspace row. + expect(ensuredRepoUrl).toBe("https://example.test/a.git"); + }); +}); + +describe("resolveManagedProjectWorkspaceDir isolation", () => { + it("resolves distinct, non-nested managed dirs for two projects", () => { + const companyId = "company-1"; + const dirA = resolveManagedProjectWorkspaceDir({ companyId, projectId: "project-a", repoName: null }); + const dirB = resolveManagedProjectWorkspaceDir({ companyId, projectId: "project-b", repoName: null }); + + expect(dirA).not.toBe(dirB); + // Neither directory is a path prefix of the other: append a separator so a shared leading + // string (for example "project-a" vs "project-ab") never reads as nesting. + expect(`${dirB}${path.sep}`.startsWith(`${dirA}${path.sep}`)).toBe(false); + expect(`${dirA}${path.sep}`.startsWith(`${dirB}${path.sep}`)).toBe(false); + }); + + it("keeps a project id that prefixes another project id in a sibling, non-nested dir", () => { + const companyId = "company-1"; + const dir = resolveManagedProjectWorkspaceDir({ companyId, projectId: "project", repoName: null }); + const dirLonger = resolveManagedProjectWorkspaceDir({ companyId, projectId: "project-extra", repoName: null }); + + expect(`${dirLonger}${path.sep}`.startsWith(`${dir}${path.sep}`)).toBe(false); + expect(`${dir}${path.sep}`.startsWith(`${dirLonger}${path.sep}`)).toBe(false); + }); +}); diff --git a/server/src/__tests__/heartbeat-referenced-projects.test.ts b/server/src/__tests__/heartbeat-referenced-projects.test.ts index 13864d19d7..111501b74d 100644 --- a/server/src/__tests__/heartbeat-referenced-projects.test.ts +++ b/server/src/__tests__/heartbeat-referenced-projects.test.ts @@ -19,6 +19,7 @@ import { issueService } from "../services/issues.ts"; import { projectService } from "../services/projects.ts"; import { isMultiProjectWorkspaceSyncEnabled, + isRemoteExecutionEnvironmentDriver, MAX_RUN_REFERENCED_ADDITIONAL_PROJECTS, MAX_RUN_REFERENCED_CANDIDATE_EVALUATIONS, MULTI_PROJECT_WORKSPACE_SYNC_ENV, @@ -41,6 +42,15 @@ describe("multi-project workspace sync kill-switch", () => { expect(isMultiProjectWorkspaceSyncEnabled({ [MULTI_PROJECT_WORKSPACE_SYNC_ENV]: "1" })).toBe(true); expect(isMultiProjectWorkspaceSyncEnabled({ [MULTI_PROJECT_WORKSPACE_SYNC_ENV]: "on" })).toBe(true); }); + + it("classifies ssh, sandbox, and plugin drivers as remote and local/unknown as local", () => { + expect(isRemoteExecutionEnvironmentDriver("ssh")).toBe(true); + expect(isRemoteExecutionEnvironmentDriver("sandbox")).toBe(true); + expect(isRemoteExecutionEnvironmentDriver("plugin")).toBe(true); + expect(isRemoteExecutionEnvironmentDriver("local")).toBe(false); + expect(isRemoteExecutionEnvironmentDriver(null)).toBe(false); + expect(isRemoteExecutionEnvironmentDriver(undefined)).toBe(false); + }); }); describeEmbeddedPostgres("resolveRunReferencedProjects", () => { diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index a804ee831a..8a00355965 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -49,6 +49,12 @@ import { readLocalServicePortOwner, writeLocalServiceRegistryRecord, } from "../services/local-service-supervisor.ts"; +import { + buildWorkspaceRealizationRecord, + buildWorkspaceRealizationRequest, + readWorkspaceRealizationRequest, +} from "../services/workspace-realization.ts"; +import type { Environment, EnvironmentLease } from "@paperclipai/shared"; import { resolvePaperclipConfigPath } from "../paths.ts"; import type { WorkspaceOperation } from "@paperclipai/shared"; import type { WorkspaceOperationRecorder } from "../services/workspace-operations.ts"; @@ -6191,3 +6197,180 @@ describe("normalizeAdapterManagedRuntimeServices", () => { }); }); }); + +describe("workspace realization request additionalSources", () => { + function buildRealizedWorkspace( + overrides: Partial = {}, + ): RealizedExecutionWorkspace { + return { + baseCwd: "/anchor", + source: "project_primary", + projectId: "project-anchor", + workspaceId: "workspace-anchor", + repoUrl: "https://example.test/anchor.git", + repoRef: "main", + strategy: "project_primary", + cwd: "/anchor", + branchName: null, + worktreePath: null, + warnings: [], + created: false, + ...overrides, + }; + } + + it("round-trips additionalSources through build/read realization request", () => { + const workspace = buildRealizedWorkspace({ + additionalWorkspaces: [ + { + cwd: "/managed/project-b", + projectId: "project-b", + workspaceId: "workspace-b", + repoUrl: "https://example.test/b.git", + repoRef: "release", + }, + ], + }); + + const request = buildWorkspaceRealizationRequest({ + adapterType: "codex", + companyId: "company-1", + environmentId: "environment-1", + executionWorkspaceId: "execution-workspace-1", + issueId: "issue-1", + heartbeatRunId: "run-1", + requestedMode: "shared_workspace", + workspace, + workspaceConfig: null, + }); + + expect(request.additionalSources).toEqual([ + { + localPath: "/managed/project-b", + projectId: "project-b", + projectWorkspaceId: "workspace-b", + repoUrl: "https://example.test/b.git", + repoRef: "release", + }, + ]); + // The anchor source stays scalar and unchanged alongside the new plural field. + expect(request.source.localPath).toBe("/anchor"); + + // A serialize/deserialize round-trip preserves additionalSources. + const roundTripped = readWorkspaceRealizationRequest( + JSON.parse(JSON.stringify(request)), + ); + expect(roundTripped?.additionalSources).toEqual(request.additionalSources); + }); + + it("exposes additionalSources on the realization record so targets receive the paths", () => { + const workspace = buildRealizedWorkspace({ + additionalWorkspaces: [ + { + cwd: "/managed/project-b", + projectId: "project-b", + workspaceId: "workspace-b", + repoUrl: "https://example.test/b.git", + repoRef: "release", + }, + ], + }); + + const request = buildWorkspaceRealizationRequest({ + adapterType: "codex", + companyId: "company-1", + environmentId: "environment-1", + executionWorkspaceId: "execution-workspace-1", + issueId: "issue-1", + heartbeatRunId: "run-1", + requestedMode: "shared_workspace", + workspace, + workspaceConfig: null, + }); + + const now = new Date(0); + const environment: Environment = { + id: "environment-1", + name: "local", + description: null, + driver: "local", + status: "active", + config: {}, + envVars: {}, + metadata: null, + createdAt: now, + updatedAt: now, + }; + const lease: EnvironmentLease = { + id: "lease-1", + companyId: "company-1", + environmentId: "environment-1", + executionWorkspaceId: "execution-workspace-1", + issueId: "issue-1", + heartbeatRunId: "run-1", + status: "active", + leasePolicy: "ephemeral", + provider: "local", + providerLeaseId: null, + acquiredAt: now, + lastUsedAt: now, + expiresAt: null, + releasedAt: null, + failureReason: null, + cleanupStatus: null, + metadata: null, + createdAt: now, + updatedAt: now, + }; + + const record = buildWorkspaceRealizationRecord({ environment, lease, request }); + + // The record carries the resolved referenced-project path so the execution target can expose it. + expect(record.additional).toEqual([ + { + path: "/managed/project-b", + projectId: "project-b", + projectWorkspaceId: "workspace-b", + repoUrl: "https://example.test/b.git", + repoRef: "release", + }, + ]); + // The anchor stays scalar and unchanged alongside the new plural field. + expect(record.local.path).toBe("/anchor"); + }); + + it("reads a legacy request without additionalSources as an empty array", () => { + const legacyRequest = { + version: 1, + adapterType: "codex", + companyId: "company-1", + environmentId: "environment-1", + executionWorkspaceId: null, + issueId: null, + heartbeatRunId: "run-1", + requestedMode: null, + source: { + kind: "project_primary", + localPath: "/anchor", + projectId: null, + projectWorkspaceId: null, + repoUrl: null, + repoRef: null, + strategy: "project_primary", + branchName: null, + worktreePath: null, + }, + runtimeOverlay: { + provisionCommand: null, + teardownCommand: null, + cleanupCommand: null, + workspaceRuntime: null, + }, + }; + + const parsed = readWorkspaceRealizationRequest(legacyRequest); + + expect(parsed).not.toBeNull(); + expect(parsed?.additionalSources).toEqual([]); + }); +}); diff --git a/server/src/home-paths.ts b/server/src/home-paths.ts index cf274a2a7b..4ec29915a4 100644 --- a/server/src/home-paths.ts +++ b/server/src/home-paths.ts @@ -64,6 +64,16 @@ function sanitizeFriendlyPathSegment(value: string | null | undefined, fallback return sanitized || fallback; } +/** + * Resolve the managed checkout directory for one project: + * `/projects///`. + * + * Per-project directory isolation invariant: the `projectId` is a distinct path segment, so two + * different projects always resolve to sibling directories under `/`. One project's + * directory can never nest inside, or be a path prefix of, another project's directory. A run that + * materializes several referenced projects can therefore place each in its own directory without + * collision. See the "distinct, non-nested managed dirs" test in `heartbeat-project-env.test.ts`. + */ export function resolveManagedProjectWorkspaceDir(input: { companyId: string; projectId: string; diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 968f1ffe7e..6886d54939 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -1402,6 +1402,142 @@ async function ensureManagedProjectWorkspace(input: { } } +/** + * Resolve one project workspace row to a usable cwd. The anchor path and each additional + * referenced project share this step: use the configured cwd when present, otherwise clone or + * create the managed checkout directory for the project. It throws only when the managed + * checkout cannot be prepared (for example, a clone failure). + */ +async function resolveConfiguredOrManagedProjectCwd(input: { + companyId: string; + projectId: string; + cwd: string | null; + repoUrl: string | null; +}): Promise<{ cwd: string; warning: string | null }> { + const configuredCwd = readNonEmptyString(input.cwd); + if (configuredCwd && configuredCwd !== REPO_ONLY_CWD_SENTINEL) { + return { cwd: configuredCwd, warning: null }; + } + return ensureManagedProjectWorkspace({ + companyId: input.companyId, + projectId: input.projectId, + repoUrl: readNonEmptyString(input.repoUrl), + }); +} + +/** + * Side-effecting dependencies for {@link resolveAdditionalProjectWorkspace}. The caller injects + * the real database, filesystem, and managed-checkout helpers. A test injects fakes to exercise + * the resolution logic without a database or filesystem. + */ +export interface ResolveAdditionalProjectWorkspaceDeps { + loadProjectWorkspaceRows: ( + companyId: string, + projectId: string, + ) => Promise>; + resolveConfiguredOrManagedProjectCwd: typeof resolveConfiguredOrManagedProjectCwd; + ensureManagedProjectWorkspace: typeof ensureManagedProjectWorkspace; + directoryHasContents: (cwd: string) => Promise; +} + +/** Build the real dependencies for {@link resolveAdditionalProjectWorkspace}. */ +function defaultAdditionalProjectWorkspaceDeps(db: Db): ResolveAdditionalProjectWorkspaceDeps { + return { + loadProjectWorkspaceRows: (companyId, projectId) => + db + .select() + .from(projectWorkspaces) + .where(and(eq(projectWorkspaces.companyId, companyId), eq(projectWorkspaces.projectId, projectId))) + .orderBy(asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id)), + resolveConfiguredOrManagedProjectCwd, + ensureManagedProjectWorkspace, + // A realized workspace must hold real content. An empty directory gives the agent an empty + // referenced workspace, so treat an empty directory the same as a missing one. + directoryHasContents: async (cwd) => { + const stats = await fs.stat(cwd).catch(() => null); + if (!stats || !stats.isDirectory()) { + return false; + } + const entries = await fs.readdir(cwd).catch(() => [] as string[]); + return entries.length > 0; + }, + }; +} + +/** + * Resolve one authorized referenced project to its own workspace cwd. Each additional project + * lands in its own managed checkout directory, never nested inside the anchor's worktree (the + * directory isolation invariant lives in {@link resolveManagedProjectWorkspaceDir}). + * + * A referenced project must resolve to a directory with real content. The function uses a + * configured checkout directory that exists, or clones a managed checkout from a workspace row + * that supplies a repository URL. When no row offers either, the function throws instead of + * creating an empty managed directory. An empty directory gives the agent an empty referenced + * workspace and hides the real cause. The caller catches the error and drops only that project. + * The function also throws when the managed checkout cannot be prepared (for example, a clone + * failure), so the caller can drop only that project. + */ +export async function resolveAdditionalProjectWorkspace( + input: { + companyId: string; + project: RunReferencedProject; + }, + deps: ResolveAdditionalProjectWorkspaceDeps, +): Promise { + const { companyId } = input; + const projectId = input.project.projectId; + const workspaceRows = await deps.loadProjectWorkspaceRows(companyId, projectId); + for (const workspace of workspaceRows) { + // A row realizes real content only through a configured checkout directory or a repository URL + // to clone. A row with neither can produce only an empty managed directory, so skip it here. + const configuredCwd = readNonEmptyString(workspace.cwd); + const hasConfiguredCwd = Boolean(configuredCwd) && configuredCwd !== REPO_ONLY_CWD_SENTINEL; + if (!hasConfiguredCwd && !readNonEmptyString(workspace.repoUrl)) { + continue; + } + const { cwd } = await deps.resolveConfiguredOrManagedProjectCwd({ + companyId, + projectId, + cwd: workspace.cwd, + repoUrl: workspace.repoUrl, + }); + // A directory that exists but holds no content is not a realized workspace. Accept the row only + // when the resolved directory has real content, so an empty directory never masks a missing one. + if (await deps.directoryHasContents(cwd)) { + return { + cwd, + projectId, + workspaceId: workspace.id, + repoUrl: workspace.repoUrl, + repoRef: workspace.repoRef, + }; + } + } + // No configured checkout resolved to a directory with content. Clone a managed checkout only from a + // real source: the first workspace row that supplies a repository URL. Without a real source, do + // not fabricate an empty managed directory and report success. Throw instead, so the caller drops + // only this referenced project and adds a clear warning. + const fallbackRow = workspaceRows.find((row) => readNonEmptyString(row.repoUrl)) ?? null; + const fallbackRepoUrl = fallbackRow ? readNonEmptyString(fallbackRow.repoUrl) : null; + if (!fallbackRow || !fallbackRepoUrl) { + throw new Error( + `Referenced project ${projectId} has no workspace checkout or repository URL to realize.`, + ); + } + const managed = await deps.ensureManagedProjectWorkspace({ + companyId, + projectId, + repoUrl: fallbackRepoUrl, + }); + return { + cwd: managed.cwd, + projectId, + workspaceId: fallbackRow.id, + repoUrl: fallbackRow.repoUrl, + repoRef: fallbackRow.repoRef, + }; +} + type WorkspaceValidationFailureLike = WorkspaceValidationFailure | { code: typeof WORKSPACE_VALIDATION_FAILURE_CODE; resultJson: Record; @@ -2102,6 +2238,19 @@ export interface ModelProfileApplication { adapterConfig: Record | null; } +/** + * A single read-only referenced (mentioned) project workspace resolved for a run. + * The run materializes one entry per authorized additional project, each in its own + * managed checkout directory. See {@link resolveAdditionalRunWorkspaces}. + */ +export type ResolvedAdditionalWorkspace = { + cwd: string; + projectId: string; + workspaceId: string | null; + repoUrl: string | null; + repoRef: string | null; +}; + export type ResolvedWorkspaceForRun = { cwd: string; source: "project_primary" | "task_session" | "agent_home"; @@ -2116,8 +2265,45 @@ export type ResolvedWorkspaceForRun = { repoRef: string | null; }>; warnings: string[]; + /** + * Read-only referenced (mentioned) project workspaces for this run, one per authorized + * additional project. The array is empty unless the multi-project workspace-sync flag is on + * ({@link isMultiProjectWorkspaceSyncEnabled}); with the flag off the run resolves the anchor + * workspace only, exactly as before. + */ + additionalWorkspaces: ResolvedAdditionalWorkspace[]; }; +/** The anchor workspace shape, before the additional referenced workspaces are attached. */ +type ResolvedAnchorWorkspaceForRun = Omit; + +/** + * Build the plural workspace list that a run exposes to the agent through the + * `PAPERCLIP_WORKSPACES_JSON` environment variable. The list joins the anchor + * project's alternative workspace rows with the read-only referenced (mentioned) + * project workspaces, so every execution target receives the referenced project + * paths through the same channel the run already uses for the anchor project. + * + * Each referenced entry carries its `projectId` so the agent can tell which + * mentioned project a path belongs to. The referenced set is empty unless the + * multi-project workspace-sync flag is on, so the exposed list is byte-for-byte + * unchanged in the production default. + */ +export function buildRunWorkspaceHints( + resolved: Pick, +): Array> { + return [ + ...resolved.workspaceHints, + ...resolved.additionalWorkspaces.map((additional) => ({ + workspaceId: additional.workspaceId, + cwd: additional.cwd, + repoUrl: additional.repoUrl, + repoRef: additional.repoRef, + projectId: additional.projectId, + })), + ]; +} + type ProjectWorkspaceCandidate = { id: string; }; @@ -2146,6 +2332,17 @@ export function isMultiProjectWorkspaceSyncEnabled( return isTruthyRuntimeEnvValue(env[MULTI_PROJECT_WORKSPACE_SYNC_ENV]); } +/** + * True when an environment driver runs the workspace on a non-local target. The `ssh`, `sandbox`, + * and `plugin` drivers each realize the workspace off the host, so a host-local directory path is + * not present on the target. This mirrors the remote-transport classification in + * {@link buildWorkspaceRealizationRecord}. The `local` driver (and an unknown/absent driver) is + * treated as local. + */ +export function isRemoteExecutionEnvironmentDriver(driver: string | null | undefined): boolean { + return driver === "ssh" || driver === "sandbox" || driver === "plugin"; +} + /** * Upper bound on how many additional (mentioned) projects a single run may materialize * beyond the anchor. Bounds the fan-out of per-project authorization and workspace prep. @@ -2366,6 +2563,95 @@ export async function resolveRunReferencedProjects( return { anchor, additional, warnings }; } +export interface ResolveAdditionalRunWorkspacesOptions { + /** Gate that mirrors {@link isMultiProjectWorkspaceSyncEnabled}. When false, the result is empty. */ + enabled: boolean; + companyId: string; + /** The run actor; every additional project is authorized against this actor. */ + actor: AuthorizationActor; + issues: Pick, "findMentionedProjectIds">; + projects: Pick, "listByIds">; + access: Pick, "decide">; + /** Resolve one authorized referenced project to its own workspace cwd (injectable for tests). */ + resolveProjectWorkspace: (project: RunReferencedProject) => Promise; + maxAdditionalProjects?: number; + maxCandidateEvaluations?: number; + /** + * True when the run executes on a non-local target (ssh, sandbox, or plugin). A referenced + * project realizes as a local directory only, and a remote target has no path yet to receive + * that tree, so a resolved cwd would not exist on the target. When true, the function skips + * referenced-project authorization and workspace work and returns no additional workspaces. + */ + executionTargetIsRemote?: boolean; +} + +/** + * Resolve the read-only referenced (mentioned) project workspaces for a run. + * + * The function is inert until the multi-project workspace-sync flag is on: when `enabled` is + * false (the production default) or there is no issue, it returns an empty result and performs + * no authorization or workspace work. When enabled, it authorizes the referenced set through + * {@link resolveRunReferencedProjects} and resolves each admitted project to its own cwd. Each + * project resolves in isolation: a per-project failure drops only that project and appends a + * warning, so one bad clone never aborts the run. + */ +export async function resolveAdditionalRunWorkspaces( + issueId: string | null, + anchorProjectId: string | null, + opts: ResolveAdditionalRunWorkspacesOptions, +): Promise<{ additionalWorkspaces: ResolvedAdditionalWorkspace[]; warnings: string[] }> { + if (!opts.enabled || !issueId) { + return { additionalWorkspaces: [], warnings: [] }; + } + + // A referenced project realizes as a local directory only. A remote execution target (ssh, + // sandbox, or plugin) has no path yet to receive the referenced tree, so a resolved cwd would + // not exist on the target and the anchor-only remote sync never carries it across. Skip the + // referenced-project authorization and clone work on a remote target, so the run neither does + // work it must discard nor exposes an inaccessible referenced path to the agent. Warn only when + // the issue actually mentions a project, so a remote run without any referenced mention stays + // silent. + if (opts.executionTargetIsRemote) { + const mentionedIds = await opts.issues.findMentionedProjectIds(issueId, { + includeCommentBodies: true, + }); + const hasReferencedMention = mentionedIds.some((projectId) => projectId !== anchorProjectId); + return { + additionalWorkspaces: [], + warnings: hasReferencedMention + ? [ + "Referenced-project workspaces are available only on a local execution target. This run uses a remote execution target, so no referenced-project workspace was attached.", + ] + : [], + }; + } + + const referenced = await resolveRunReferencedProjects(issueId, anchorProjectId, { + companyId: opts.companyId, + actor: opts.actor, + issues: opts.issues, + projects: opts.projects, + access: opts.access, + maxAdditionalProjects: opts.maxAdditionalProjects, + maxCandidateEvaluations: opts.maxCandidateEvaluations, + }); + + const additionalWorkspaces: ResolvedAdditionalWorkspace[] = []; + const warnings = [...referenced.warnings]; + for (const project of referenced.additional) { + try { + additionalWorkspaces.push(await opts.resolveProjectWorkspace(project)); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + warnings.push( + `Referenced project ${project.projectId} was skipped because its workspace could not be prepared: ${reason}`, + ); + } + } + + return { additionalWorkspaces, warnings }; +} + function readNonEmptyString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value : null; } @@ -7574,12 +7860,12 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) }; } - async function resolveWorkspaceForRun( + async function resolveAnchorWorkspaceForRun( agent: typeof agents.$inferSelect, context: Record, previousSessionParams: Record | null, opts?: { useProjectWorkspace?: boolean | null }, - ): Promise { + ): Promise { const issueId = readNonEmptyString(context.issueId) ?? readNonEmptyString(context.taskId); const contextProjectId = readNonEmptyString(context.projectId); const contextProjectWorkspaceId = readNonEmptyString(context.projectWorkspaceId); @@ -7636,23 +7922,22 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) `Selected project workspace "${preferredProjectWorkspaceId}" is not available on this project.`; } for (const workspace of projectWorkspaceRows) { - let projectCwd = readNonEmptyString(workspace.cwd); + let projectCwd: string; let managedWorkspaceWarning: string | null = null; - if (!projectCwd || projectCwd === REPO_ONLY_CWD_SENTINEL) { - try { - const managedWorkspace = await ensureManagedProjectWorkspace({ - companyId: agent.companyId, - projectId: workspaceProjectId ?? resolvedProjectId ?? workspace.projectId, - repoUrl: readNonEmptyString(workspace.repoUrl), - }); - projectCwd = managedWorkspace.cwd; - managedWorkspaceWarning = managedWorkspace.warning; - } catch (error) { - if (preferredWorkspace?.id === workspace.id) { - preferredWorkspaceWarning = error instanceof Error ? error.message : String(error); - } - continue; + try { + const resolvedCwd = await resolveConfiguredOrManagedProjectCwd({ + companyId: agent.companyId, + projectId: workspaceProjectId ?? resolvedProjectId ?? workspace.projectId, + cwd: workspace.cwd, + repoUrl: workspace.repoUrl, + }); + projectCwd = resolvedCwd.cwd; + managedWorkspaceWarning = resolvedCwd.warning; + } catch (error) { + if (preferredWorkspace?.id === workspace.id) { + preferredWorkspaceWarning = error instanceof Error ? error.message : String(error); } + continue; } hasConfiguredProjectCwd = true; const projectCwdExists = await fs @@ -7782,6 +8067,54 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) }; } + /** + * Resolve the run workspace: the anchor workspace plus, when the multi-project workspace-sync + * flag is on, the read-only referenced (mentioned) project workspaces. With the flag off (the + * production default) the anchor path is unchanged and `additionalWorkspaces` is empty. + */ + async function resolveWorkspaceForRun( + agent: typeof agents.$inferSelect, + context: Record, + previousSessionParams: Record | null, + opts?: { useProjectWorkspace?: boolean | null; executionTargetIsRemote?: boolean }, + ): Promise { + const anchor = await resolveAnchorWorkspaceForRun(agent, context, previousSessionParams, opts); + if (!isMultiProjectWorkspaceSyncEnabled()) { + return { ...anchor, additionalWorkspaces: [] }; + } + + const issueId = readNonEmptyString(context.issueId) ?? readNonEmptyString(context.taskId); + const { additionalWorkspaces, warnings } = await resolveAdditionalRunWorkspaces( + issueId, + anchor.projectId, + { + enabled: true, + executionTargetIsRemote: opts?.executionTargetIsRemote ?? false, + companyId: agent.companyId, + actor: { + type: "agent", + agentId: agent.id, + companyId: agent.companyId, + source: "agent_key", + }, + issues: issueService(db), + projects: projectService(db), + access: authorizationService(db), + resolveProjectWorkspace: (project) => + resolveAdditionalProjectWorkspace( + { companyId: agent.companyId, project }, + defaultAdditionalProjectWorkspaceDeps(db), + ), + }, + ); + + return { + ...anchor, + additionalWorkspaces, + warnings: warnings.length > 0 ? [...anchor.warnings, ...warnings] : anchor.warnings, + }; + } + async function upsertTaskSession(input: { companyId: string; agentId: string; @@ -12807,7 +13140,15 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) agent, context, previousSessionParams, - { useProjectWorkspace: requestedExecutionWorkspaceMode !== "agent_default" }, + { + useProjectWorkspace: requestedExecutionWorkspaceMode !== "agent_default", + // Referenced-project workspaces attach on a local execution target only. Gate their + // resolution on the selected environment driver so a remote run never resolves a + // referenced path it cannot reach. This never changes the anchor workspace. + executionTargetIsRemote: isRemoteExecutionEnvironmentDriver( + selectedEnvironmentForConfig?.driver, + ), + }, ), }); const hostExecutionWorkspaceConfig = stripHostWorkspaceProvisionForLowTrustSandbox({ @@ -12822,6 +13163,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) workspaceId: resolvedWorkspace.workspaceId, repoUrl: resolvedWorkspace.repoUrl, repoRef: resolvedWorkspace.repoRef, + additionalWorkspaces: resolvedWorkspace.additionalWorkspaces, } satisfies ExecutionWorkspaceInput; await assertGitWorktreeBaseWorkspaceReady({ requestedExecutionWorkspaceMode, @@ -13314,7 +13656,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) return home; })(), }; - context.paperclipWorkspaces = resolvedWorkspace.workspaceHints; + context.paperclipWorkspaces = buildRunWorkspaceHints(resolvedWorkspace); // The wake payload is built before the execution workspace is resolved, so // attach the branch pin here; the shared wake-prompt renderer surfaces it as // a one-time "stay on this branch" hint on non-resumed sessions. diff --git a/server/src/services/workspace-realization.ts b/server/src/services/workspace-realization.ts index ff9374a0b9..6049cfb36c 100644 --- a/server/src/services/workspace-realization.ts +++ b/server/src/services/workspace-realization.ts @@ -37,7 +37,29 @@ function readPathAliases(value: unknown): Array<{ path: string; target: string } }); } -function readWorkspaceRealizationRequest(value: unknown): WorkspaceRealizationRequest | null { +// Read the additional referenced (mentioned) project sources. Legacy payloads omit the field, so +// this defaults to an empty array. Each source needs a localPath; entries without one are dropped. +function readAdditionalSources( + value: unknown, +): NonNullable { + if (!Array.isArray(value)) return []; + return value.flatMap((entry) => { + const parsed = parseObject(entry); + const localPath = readString(parsed.localPath); + if (!localPath) return []; + return [ + { + localPath, + projectId: readString(parsed.projectId), + projectWorkspaceId: readString(parsed.projectWorkspaceId), + repoUrl: readString(parsed.repoUrl), + repoRef: readString(parsed.repoRef), + }, + ]; + }); +} + +export function readWorkspaceRealizationRequest(value: unknown): WorkspaceRealizationRequest | null { const parsed = parseObject(value); if (parsed.version !== 1) return null; const source = parseObject(parsed.source); @@ -72,6 +94,7 @@ function readWorkspaceRealizationRequest(value: unknown): WorkspaceRealizationRe branchName: readString(source.branchName), worktreePath: readString(source.worktreePath), }, + additionalSources: readAdditionalSources(parsed.additionalSources), runtimeOverlay: { provisionCommand: readString(runtimeOverlay.provisionCommand), teardownCommand: readString(runtimeOverlay.teardownCommand), @@ -114,6 +137,19 @@ export function buildWorkspaceRealizationRequest(input: { branchName: input.workspace.branchName, worktreePath: input.workspace.worktreePath, }, + // The additional (referenced) sources carry the read-only referenced-project workspaces. Run + // preparation resolves them for a local execution target only and exposes each local path to + // the agent through the workspace-hints channel (`PAPERCLIP_WORKSPACES_JSON`). A remote target + // never receives a referenced source: run preparation skips referenced-project resolution on a + // remote target, so this array is empty there. The `sync` block below therefore realizes only + // the anchor source; a remote-transport sync of the referenced trees is not implemented yet. + additionalSources: (input.workspace.additionalWorkspaces ?? []).map((additional) => ({ + localPath: additional.cwd, + projectId: additional.projectId, + projectWorkspaceId: additional.workspaceId, + repoUrl: additional.repoUrl, + repoRef: additional.repoRef, + })), runtimeOverlay: { provisionCommand: input.workspaceConfig?.provisionCommand ?? null, teardownCommand: input.workspaceConfig?.teardownCommand ?? null, @@ -224,6 +260,13 @@ export function buildWorkspaceRealizationRecord(input: { branchName: input.request.source.branchName, worktreePath: input.request.source.worktreePath, }, + additional: (input.request.additionalSources ?? []).map((additional) => ({ + path: additional.localPath, + projectId: additional.projectId, + projectWorkspaceId: additional.projectWorkspaceId, + repoUrl: additional.repoUrl, + repoRef: additional.repoRef, + })), remote: { path: remotePath, ...(host ? { host } : {}), diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index 44ff8cfd7c..3814635ae7 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -45,6 +45,19 @@ export function resolveShell(): string { return shell; } +/** + * A read-only referenced (mentioned) project workspace carried alongside the anchor. Additive and + * backward-compatible: it defaults to an empty array. Additional workspaces never get git-worktree + * realization; the anchor keeps the single scalar realization path. + */ +export interface ExecutionWorkspaceAdditionalInput { + cwd: string; + projectId: string; + workspaceId: string | null; + repoUrl: string | null; + repoRef: string | null; +} + export interface ExecutionWorkspaceInput { baseCwd: string; source: "project_primary" | "task_session" | "agent_home"; @@ -52,6 +65,7 @@ export interface ExecutionWorkspaceInput { workspaceId: string | null; repoUrl: string | null; repoRef: string | null; + additionalWorkspaces?: ExecutionWorkspaceAdditionalInput[]; } export interface ExecutionWorkspaceIssueRef { @@ -2932,6 +2946,7 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: { workspaceId: input.workspace.projectWorkspaceId ?? input.base.workspaceId, repoUrl: input.workspace.repoUrl ?? input.base.repoUrl, repoRef: input.workspace.baseRef ?? input.base.repoRef, + additionalWorkspaces: input.base.additionalWorkspaces ?? [], strategy, cwd, branchName: input.workspace.branchName ?? null,