mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +01:00
feat(repositories): add agent repository context and relationships
Expose stable secret-free project hints and effective repository sources in project, heartbeat, adapter, and resume context. Add source-aware relationship APIs plus request-time clone credential authorization and focused context/service coverage. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
3d4c484816
commit
01bd270119
13 files changed
+479
-15
No files matched your search
@@ -1076,6 +1076,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
executionWorkspacePolicy: null,
|
||||
repositoryHints: [],
|
||||
codebase: {
|
||||
workspaceId: null,
|
||||
repoUrl: null,
|
||||
|
||||
@@ -2407,6 +2407,12 @@ export type {
|
||||
BeginRepositoryConnectionResult,
|
||||
CompleteRepositoryConnectionResult,
|
||||
ImportRepositoriesResult,
|
||||
RepositoryContext,
|
||||
ProjectRepositoryHint,
|
||||
EffectiveRepositoryContext,
|
||||
RepositoryRelationshipProject,
|
||||
RepositoryRelationshipAgent,
|
||||
RepositoryRelationships,
|
||||
} from "./types/repository.js";
|
||||
export {
|
||||
FEATURE_TIERS,
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
WorkspaceRuntimeService,
|
||||
} from "./workspace-runtime.js";
|
||||
import type { AgentEnvConfig } from "./secrets.js";
|
||||
import type { ProjectRepositoryHint } from "./repository.js";
|
||||
|
||||
export type ProjectWorkspaceSourceType = "local_path" | "git_repo" | "remote_managed" | "non_git_path";
|
||||
export type ProjectWorkspaceVisibility = "default" | "advanced";
|
||||
@@ -95,6 +96,8 @@ export interface Project {
|
||||
pauseReason: PauseReason | null;
|
||||
pausedAt: Date | null;
|
||||
executionWorkspacePolicy: ProjectExecutionWorkspacePolicy | null;
|
||||
/** Non-exclusive guidance about the repositories this project is likely to touch. */
|
||||
repositoryHints?: ProjectRepositoryHint[];
|
||||
codebase: ProjectCodebase;
|
||||
workspaces: ProjectWorkspace[];
|
||||
primaryWorkspace: ProjectWorkspace | null;
|
||||
|
||||
@@ -139,3 +139,49 @@ export interface ImportRepositoriesResult {
|
||||
imported: number;
|
||||
skipped: string[];
|
||||
}
|
||||
|
||||
/** Secret-free repository identity sent to agents and embedded in project payloads. */
|
||||
export interface RepositoryContext {
|
||||
id: string;
|
||||
provider: RepositoryProvider;
|
||||
host: string;
|
||||
owner: string;
|
||||
name: string;
|
||||
state: RepositoryState;
|
||||
unavailableReason: string | null;
|
||||
cloneUrl: string | null;
|
||||
webUrl: string | null;
|
||||
defaultBranch: string | null;
|
||||
}
|
||||
|
||||
export interface ProjectRepositoryHint extends RepositoryContext {
|
||||
displayOrder: number;
|
||||
}
|
||||
|
||||
export interface EffectiveRepositoryContext extends RepositoryContext {
|
||||
sources: {
|
||||
direct: boolean;
|
||||
projects: EffectiveRepositoryProjectSource[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface RepositoryRelationshipProject {
|
||||
id: string;
|
||||
name: string;
|
||||
displayOrder: number;
|
||||
}
|
||||
|
||||
export interface RepositoryRelationshipAgent {
|
||||
id: string;
|
||||
name: string;
|
||||
title: string | null;
|
||||
sources: {
|
||||
direct: boolean;
|
||||
projects: EffectiveRepositoryProjectSource[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface RepositoryRelationships {
|
||||
projects: RepositoryRelationshipProject[];
|
||||
agents: RepositoryRelationshipAgent[];
|
||||
}
|
||||
@@ -155,6 +155,69 @@ describe("buildPaperclipTaskMarkdown", () => {
|
||||
expect(commentWake).toContain("Update the plan only. Do not write code or perform implementation work.");
|
||||
expect(commentWake).not.toContain("Create child issues from the approved plan only");
|
||||
});
|
||||
|
||||
it("keeps zero-repository context quiet and emits stable deduplicated repository guidance", () => {
|
||||
const issue = {
|
||||
id: "issue-repositories",
|
||||
identifier: "PAP-17067",
|
||||
title: "Use repository context",
|
||||
workMode: "standard",
|
||||
description: "Repository work",
|
||||
};
|
||||
const quiet = buildPaperclipTaskMarkdown({ issue });
|
||||
expect(quiet).not.toContain("Repository hints");
|
||||
expect(quiet).not.toContain("Additional direct repository access");
|
||||
|
||||
const hint = {
|
||||
id: "repo-hint",
|
||||
provider: "manual" as const,
|
||||
host: "github.com",
|
||||
owner: "paperclipai",
|
||||
name: "paperclip",
|
||||
state: "active" as const,
|
||||
unavailableReason: null,
|
||||
cloneUrl: "https://github.com/paperclipai/paperclip.git",
|
||||
webUrl: "https://github.com/paperclipai/paperclip",
|
||||
defaultBranch: "master",
|
||||
displayOrder: 0,
|
||||
};
|
||||
const direct = {
|
||||
id: "repo-direct",
|
||||
provider: "manual" as const,
|
||||
host: "github.com",
|
||||
owner: "paperclipai",
|
||||
name: "docs",
|
||||
state: "active" as const,
|
||||
unavailableReason: null,
|
||||
cloneUrl: "https://github.com/paperclipai/docs.git",
|
||||
webUrl: "https://github.com/paperclipai/docs",
|
||||
defaultBranch: "main",
|
||||
sources: { direct: true, projects: [] },
|
||||
};
|
||||
const full = buildPaperclipTaskMarkdown({
|
||||
issue,
|
||||
projectRepositoryHints: [hint, hint],
|
||||
effectiveRepositories: [
|
||||
{ ...hint, sources: { direct: true, projects: [{ id: "project-1", name: "Paperclip" }] } },
|
||||
direct,
|
||||
direct,
|
||||
],
|
||||
});
|
||||
expect(full?.match(/github\.com\/paperclipai\/paperclip \(default branch: master\)/g)).toHaveLength(1);
|
||||
expect(full?.match(/github\.com\/paperclipai\/docs/g)).toHaveLength(1);
|
||||
expect(full).toContain("## Repository hints (not hard boundaries)");
|
||||
expect(full).toContain("## Additional direct repository access");
|
||||
|
||||
const compact = buildPaperclipTaskMarkdown({
|
||||
issue,
|
||||
includeDescription: false,
|
||||
projectRepositoryHints: [hint],
|
||||
effectiveRepositories: [direct],
|
||||
});
|
||||
expect(compact).not.toContain("Repository work");
|
||||
expect(compact).toContain("github.com/paperclipai/paperclip");
|
||||
expect(compact).toContain("github.com/paperclipai/docs");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeCoalescedContextSnapshot", () => {
|
||||
|
||||
@@ -27,7 +27,10 @@ import {
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { repositoryRoutes } from "../routes/repositories.js";
|
||||
import { projectService } from "../services/projects.js";
|
||||
import { repositoryAccessService } from "../services/repository-access.js";
|
||||
import {
|
||||
authorizeRepositoryCloneCredentialRequest,
|
||||
repositoryAccessService,
|
||||
} from "../services/repository-access.js";
|
||||
import {
|
||||
repositoryConnectionService,
|
||||
repositoryProviderRegistry,
|
||||
@@ -38,7 +41,7 @@ import {
|
||||
sanitizeRepositoryProviderError,
|
||||
sanitizeRepositoryProviderMetadata,
|
||||
} from "../services/repository-normalization.js";
|
||||
import { repositoryService } from "../services/repositories.js";
|
||||
import { repositoryService, toRepositoryContext } from "../services/repositories.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
@@ -98,6 +101,21 @@ describe("repository normalization", () => {
|
||||
.toBe("Authorization: [redacted]");
|
||||
});
|
||||
|
||||
it("omits credential-bearing legacy locators from agent context", () => {
|
||||
expect(toRepositoryContext({
|
||||
id: "legacy-repository",
|
||||
provider: "manual",
|
||||
host: "github.com",
|
||||
owner: "acme",
|
||||
name: "legacy",
|
||||
state: "active",
|
||||
unavailableReason: null,
|
||||
cloneUrl: "https://user:secret@github.com/acme/legacy.git",
|
||||
webUrl: "https://user:secret@github.com/acme/legacy",
|
||||
defaultBranch: "main",
|
||||
})).toMatchObject({ cloneUrl: null, webUrl: null });
|
||||
});
|
||||
|
||||
it("accepts repository ids as project hints and repository-backed workspace inputs", () => {
|
||||
const repositoryId = randomUUID();
|
||||
expect(createProjectSchema.safeParse({ name: "Cross-repo", repositoryIds: [repositoryId] }).success)
|
||||
@@ -251,6 +269,30 @@ describeEmbeddedPostgres("repository services and routes", () => {
|
||||
})).toEqual([]);
|
||||
});
|
||||
|
||||
it("re-authorizes clone credential requests after access is revoked", async () => {
|
||||
const seeded = await seed();
|
||||
const access = repositoryAccessService(db);
|
||||
await access.grantAgentRepository({
|
||||
companyId: seeded.companyA.id,
|
||||
agentId: seeded.agent.id,
|
||||
repositoryId: seeded.repository.id,
|
||||
});
|
||||
expect(await authorizeRepositoryCloneCredentialRequest(db, {
|
||||
companyId: seeded.companyA.id,
|
||||
agentId: seeded.agent.id,
|
||||
repositoryId: seeded.repository.id,
|
||||
canAccessProject: async () => true,
|
||||
})).toMatchObject({ repository: { id: seeded.repository.id } });
|
||||
|
||||
await access.revokeAgentRepository(seeded.companyA.id, seeded.agent.id, seeded.repository.id);
|
||||
expect(await authorizeRepositoryCloneCredentialRequest(db, {
|
||||
companyId: seeded.companyA.id,
|
||||
agentId: seeded.agent.id,
|
||||
repositoryId: seeded.repository.id,
|
||||
canAccessProject: async () => true,
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
it("uses the authoritative project resolver for effective-access routes", async () => {
|
||||
const seeded = await seed();
|
||||
await repositoryAccessService(db).attachProjectRepository({
|
||||
@@ -309,6 +351,10 @@ describeEmbeddedPostgres("repository services and routes", () => {
|
||||
repositoryIds: [seeded.repository.id, secondRepository.id, seeded.repository.id],
|
||||
repositoryAttribution: { createdByUserId: "creator" },
|
||||
});
|
||||
expect(project.repositoryHints.map((repository) => repository.id)).toEqual([
|
||||
seeded.repository.id,
|
||||
secondRepository.id,
|
||||
]);
|
||||
const initialLinks = await db
|
||||
.select()
|
||||
.from(projectRepositories)
|
||||
@@ -339,6 +385,38 @@ describeEmbeddedPostgres("repository services and routes", () => {
|
||||
.toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns source-aware project hints and agent grants for repository detail", async () => {
|
||||
const seeded = await seed();
|
||||
const access = repositoryAccessService(db);
|
||||
await access.attachProjectRepository({
|
||||
companyId: seeded.companyA.id,
|
||||
projectId: seeded.projectOne.id,
|
||||
repositoryId: seeded.repository.id,
|
||||
displayOrder: 0,
|
||||
});
|
||||
await access.grantAgentRepository({
|
||||
companyId: seeded.companyA.id,
|
||||
agentId: seeded.agent.id,
|
||||
repositoryId: seeded.repository.id,
|
||||
});
|
||||
|
||||
const response = await request(createApp(db, seeded.companyA.id))
|
||||
.get(`/api/repositories/${seeded.repository.id}/relationships`);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.projects).toEqual([
|
||||
{ id: seeded.projectOne.id, name: "One", displayOrder: 0 },
|
||||
]);
|
||||
expect(response.body.agents).toEqual([
|
||||
expect.objectContaining({
|
||||
id: seeded.agent.id,
|
||||
sources: {
|
||||
direct: true,
|
||||
projects: [{ id: seeded.projectOne.id, name: "One" }],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("fails closed for cross-company UUIDs and archived repositories", async () => {
|
||||
const seeded = await seed();
|
||||
const access = repositoryAccessService(db);
|
||||
|
||||
@@ -133,10 +133,13 @@ import {
|
||||
logActivity,
|
||||
publishActivity,
|
||||
projectService,
|
||||
repositoryAccessService,
|
||||
authorizationService,
|
||||
routineService,
|
||||
workProductService,
|
||||
} from "../services/index.js";
|
||||
import { buildPlanReviewContext } from "../services/plan-review-context.js";
|
||||
import { toEffectiveRepositoryContext } from "../services/repository-access.js";
|
||||
import {
|
||||
decideIssueReviewPathRecovery,
|
||||
ISSUE_REVIEW_PATH_LOST_WAKE_REASON,
|
||||
@@ -2734,6 +2737,8 @@ export function issueRoutes(
|
||||
const instanceSettings = instanceSettingsService(db);
|
||||
const agentsSvc = agentService(db);
|
||||
const projectsSvc = projectService(db);
|
||||
const repositoryAccessSvc = repositoryAccessService(db);
|
||||
const authorizationSvc = authorizationService(db);
|
||||
const goalsSvc = goalService(db);
|
||||
const issueApprovalsSvc = issueApprovalService(db);
|
||||
const recoveryActionsSvc = issueRecoveryActionService(db);
|
||||
@@ -5125,6 +5130,7 @@ export function issueRoutes(
|
||||
pauseReason: project.pauseReason,
|
||||
pausedAt: project.pausedAt,
|
||||
executionWorkspacePolicy: project.executionWorkspacePolicy,
|
||||
repositoryHints: project.repositoryHints,
|
||||
codebase: project.codebase,
|
||||
workspaces: (project.workspaces ?? []).map(compactIssueProjectWorkspace),
|
||||
primaryWorkspace: compactIssueProjectWorkspace(project.primaryWorkspace),
|
||||
@@ -5822,6 +5828,26 @@ export function issueRoutes(
|
||||
issueWorkMode: issue.workMode,
|
||||
includeForIssueComment: wakeCommentId !== null,
|
||||
});
|
||||
const effectiveRepositories = issue.assigneeAgentId
|
||||
? ((await repositoryAccessSvc.listEffectiveRepositories({
|
||||
companyId: issue.companyId,
|
||||
agentId: issue.assigneeAgentId,
|
||||
canAccessProject: async (candidate) => {
|
||||
const decision = await authorizationSvc.decide({
|
||||
actor: {
|
||||
type: "agent",
|
||||
agentId: issue.assigneeAgentId,
|
||||
companyId: issue.companyId,
|
||||
source: "agent_key",
|
||||
},
|
||||
action: "project:read",
|
||||
resource: { type: "project", companyId: issue.companyId, projectId: candidate.id },
|
||||
scope: { projectId: candidate.id },
|
||||
});
|
||||
return decision.allowed;
|
||||
},
|
||||
})) ?? []).map(toEffectiveRepositoryContext)
|
||||
: [];
|
||||
|
||||
const response = {
|
||||
issue: {
|
||||
@@ -5861,8 +5887,12 @@ export function issueRoutes(
|
||||
name: project.name,
|
||||
status: project.status,
|
||||
targetDate: project.targetDate,
|
||||
repositoryHints: project.repositoryHints,
|
||||
}
|
||||
: null,
|
||||
agent: issue.assigneeAgentId
|
||||
? { id: issue.assigneeAgentId, effectiveRepositories }
|
||||
: null,
|
||||
goal: goal
|
||||
? {
|
||||
id: goal.id,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { Router, type Request } from "express";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agents, projects } from "@paperclipai/db";
|
||||
import { agentRepositoryGrants, agents, projectRepositories, projects } from "@paperclipai/db";
|
||||
import {
|
||||
attachProjectRepositorySchema,
|
||||
beginRepositoryConnectionSchema,
|
||||
@@ -107,6 +107,71 @@ export function repositoryRoutes(db: Db) {
|
||||
res.json(repository);
|
||||
});
|
||||
|
||||
router.get("/repositories/:repositoryId/relationships", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const repositoryId = req.params.repositoryId as string;
|
||||
const repository = await getAccessibleResource(
|
||||
req,
|
||||
res,
|
||||
repositories.getById(repositoryId),
|
||||
"Repository not found",
|
||||
);
|
||||
if (!repository) return;
|
||||
|
||||
const [projectRows, agentRows, directGrantRows] = await Promise.all([
|
||||
db
|
||||
.select({ id: projects.id, name: projects.name, displayOrder: projectRepositories.displayOrder })
|
||||
.from(projectRepositories)
|
||||
.innerJoin(projects, eq(projectRepositories.projectId, projects.id))
|
||||
.where(and(
|
||||
eq(projectRepositories.companyId, repository.companyId),
|
||||
eq(projectRepositories.repositoryId, repositoryId),
|
||||
eq(projects.companyId, repository.companyId),
|
||||
))
|
||||
.orderBy(asc(projectRepositories.displayOrder), asc(projects.name), asc(projects.id)),
|
||||
db
|
||||
.select({ id: agents.id, name: agents.name, title: agents.title })
|
||||
.from(agents)
|
||||
.where(eq(agents.companyId, repository.companyId))
|
||||
.orderBy(asc(agents.name), asc(agents.id)),
|
||||
db
|
||||
.select({ agentId: agentRepositoryGrants.agentId })
|
||||
.from(agentRepositoryGrants)
|
||||
.where(and(
|
||||
eq(agentRepositoryGrants.companyId, repository.companyId),
|
||||
eq(agentRepositoryGrants.repositoryId, repositoryId),
|
||||
)),
|
||||
]);
|
||||
|
||||
const directAgentIds = new Set(directGrantRows.map((grant) => grant.agentId));
|
||||
const relationshipAgents = (
|
||||
await Promise.all(agentRows.map(async (agent) => {
|
||||
const projectAccess = await Promise.all(projectRows.map(async (project) => ({
|
||||
project,
|
||||
allowed: (await access.decide({
|
||||
actor: {
|
||||
type: "agent",
|
||||
agentId: agent.id,
|
||||
companyId: repository.companyId,
|
||||
source: "agent_key",
|
||||
},
|
||||
action: "project:read",
|
||||
resource: { type: "project", companyId: repository.companyId, projectId: project.id },
|
||||
})).allowed,
|
||||
})));
|
||||
const projectSources = projectAccess
|
||||
.filter((entry) => entry.allowed)
|
||||
.map((entry) => ({ id: entry.project.id, name: entry.project.name }));
|
||||
const direct = directAgentIds.has(agent.id);
|
||||
return direct || projectSources.length > 0
|
||||
? { ...agent, sources: { direct, projects: projectSources } }
|
||||
: null;
|
||||
}))
|
||||
).filter((agent): agent is NonNullable<typeof agent> => Boolean(agent));
|
||||
|
||||
res.json({ projects: projectRows, agents: relationshipAgents });
|
||||
});
|
||||
|
||||
router.patch("/repositories/:repositoryId", validate(updateRepositorySchema), async (req, res) => {
|
||||
assertBoard(req);
|
||||
const repositoryId = req.params.repositoryId as string;
|
||||
|
||||
@@ -18,10 +18,12 @@ import {
|
||||
type ExecutionWorkspace,
|
||||
type ExecutionWorkspaceConfig,
|
||||
type HeartbeatRunStatusPhase,
|
||||
type EffectiveRepositoryContext,
|
||||
type IssueExecutionMonitorClearReason,
|
||||
type IssueExecutionMonitorPolicy,
|
||||
type IssueExecutionMonitorRecoveryPolicy,
|
||||
type ModelProfileKey,
|
||||
type ProjectRepositoryHint,
|
||||
type RequestConfirmationResult,
|
||||
type RoutineRevisionSnapshotV1,
|
||||
type RunLivenessState,
|
||||
@@ -152,6 +154,8 @@ import {
|
||||
import { issueService } from "./issues.js";
|
||||
import { projectService } from "./projects.js";
|
||||
import { authorizationService, type AuthorizationActor } from "./authorization.js";
|
||||
import { repositoryAccessService, toEffectiveRepositoryContext } from "./repository-access.js";
|
||||
import { toRepositoryContext } from "./repositories.js";
|
||||
import { createToolGatewayService } from "./tool-gateway.js";
|
||||
import { toolAccessService } from "./tool-access.js";
|
||||
import { visibleIssueCondition } from "./issue-visibility.js";
|
||||
@@ -6084,6 +6088,8 @@ export function buildPaperclipTaskMarkdown(input: {
|
||||
status?: string | null;
|
||||
} | null;
|
||||
acceptedPlanContinuation?: boolean;
|
||||
projectRepositoryHints?: ProjectRepositoryHint[] | null;
|
||||
effectiveRepositories?: EffectiveRepositoryContext[] | null;
|
||||
// false builds the compact variant used for resume deltas, where the session
|
||||
// already received the description with the assignment.
|
||||
includeDescription?: boolean;
|
||||
@@ -6174,6 +6180,40 @@ export function buildPaperclipTaskMarkdown(input: {
|
||||
if (wakeComment?.body.trim()) {
|
||||
lines.push("", "Latest wake comment:", fenceTaskText(wakeComment.body.trim()));
|
||||
}
|
||||
const repositoryHints = [...new Map(
|
||||
(input.projectRepositoryHints ?? []).map((repository) => [repository.id, repository] as const),
|
||||
).values()].sort((left, right) =>
|
||||
left.displayOrder - right.displayOrder
|
||||
|| left.host.localeCompare(right.host)
|
||||
|| left.owner.localeCompare(right.owner)
|
||||
|| left.name.localeCompare(right.name)
|
||||
|| left.id.localeCompare(right.id),
|
||||
);
|
||||
if (repositoryHints.length > 0) {
|
||||
lines.push(
|
||||
"",
|
||||
"## Repository hints (not hard boundaries)",
|
||||
"These repositories are likely relevant to this project. They do not limit other authorized repository access.",
|
||||
);
|
||||
for (const repository of repositoryHints) {
|
||||
const branch = repository.defaultBranch ? ` (default branch: ${repository.defaultBranch})` : "";
|
||||
const locator = repository.cloneUrl ? ` — ${repository.cloneUrl}` : "";
|
||||
const state = repository.state === "active" ? "" : ` [${repository.state}]`;
|
||||
lines.push(`- ${repository.host}/${repository.owner}/${repository.name}${state}${branch}${locator}`);
|
||||
}
|
||||
}
|
||||
const hintedRepositoryIds = new Set(repositoryHints.map((repository) => repository.id));
|
||||
const additionalDirectRepositories = [...new Map(
|
||||
(input.effectiveRepositories ?? [])
|
||||
.filter((repository) => repository.sources.direct && !hintedRepositoryIds.has(repository.id))
|
||||
.map((repository) => [repository.id, repository] as const),
|
||||
).values()];
|
||||
if (additionalDirectRepositories.length > 0) {
|
||||
lines.push("", "## Additional direct repository access");
|
||||
for (const repository of additionalDirectRepositories) {
|
||||
lines.push(`- ${repository.host}/${repository.owner}/${repository.name}`);
|
||||
}
|
||||
}
|
||||
lines.push("", "Use this task context as the current assignment.");
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -6622,6 +6662,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
const secretsSvc = secretService(db);
|
||||
const companySkills = companySkillService(db);
|
||||
const issuesSvc = issueService(db);
|
||||
const repositoryAccess = repositoryAccessService(db);
|
||||
const authorization = authorizationService(db);
|
||||
const treeControlSvc = issueTreeControlService(db);
|
||||
const executionWorkspacesSvc = executionWorkspaceService(db);
|
||||
const environmentsSvc = environmentService(db);
|
||||
@@ -13691,6 +13733,41 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
.where(and(eq(projects.id, executionProjectId), eq(projects.companyId, agent.companyId)))
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: null;
|
||||
const projectRepositoryHints: ProjectRepositoryHint[] = executionProjectId
|
||||
? ((await repositoryAccess.listProjectRepositories(agent.companyId, executionProjectId)) ?? [])
|
||||
.map((entry) => ({
|
||||
...toRepositoryContext(entry.repository),
|
||||
displayOrder: entry.link.displayOrder,
|
||||
}))
|
||||
: [];
|
||||
const effectiveRepositories = (
|
||||
await repositoryAccess.listEffectiveRepositories({
|
||||
companyId: agent.companyId,
|
||||
agentId: agent.id,
|
||||
canAccessProject: async (project) => {
|
||||
const decision = await authorization.decide({
|
||||
actor: {
|
||||
type: "agent",
|
||||
agentId: agent.id,
|
||||
companyId: agent.companyId,
|
||||
source: "agent_key",
|
||||
},
|
||||
action: "project:read",
|
||||
resource: { type: "project", companyId: agent.companyId, projectId: project.id },
|
||||
scope: { projectId: project.id },
|
||||
});
|
||||
return decision.allowed;
|
||||
},
|
||||
})
|
||||
)?.map(toEffectiveRepositoryContext) ?? [];
|
||||
context.project = {
|
||||
id: executionProjectId ?? null,
|
||||
repositoryHints: projectRepositoryHints,
|
||||
};
|
||||
context.agent = {
|
||||
id: agent.id,
|
||||
effectiveRepositories,
|
||||
};
|
||||
const acceptedPlanContinuationWake = issueContext
|
||||
? readNonEmptyString(context.workspaceRefreshReason) === "accepted_plan_confirmation"
|
||||
|| (
|
||||
@@ -13894,6 +13971,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
acceptedPlanContinuation:
|
||||
readNonEmptyString(context.workspaceRefreshReason) === "accepted_plan_confirmation"
|
||||
&& Object.keys(parseObject(context.acceptedPlanWakeRouting)).length === 0,
|
||||
projectRepositoryHints,
|
||||
effectiveRepositories,
|
||||
};
|
||||
const taskMarkdown = buildPaperclipTaskMarkdown(taskMarkdownInput);
|
||||
const taskMarkdownCompact = buildPaperclipTaskMarkdown({ ...taskMarkdownInput, includeDescription: false });
|
||||
|
||||
@@ -40,7 +40,11 @@ export {
|
||||
toRepositoryConnection,
|
||||
type RepositoryProviderAdapter,
|
||||
} from "./repository-connections.js";
|
||||
export { repositoryAccessService } from "./repository-access.js";
|
||||
export {
|
||||
authorizeRepositoryCloneCredentialRequest,
|
||||
repositoryAccessService,
|
||||
toEffectiveRepositoryContext,
|
||||
} from "./repository-access.js";
|
||||
export {
|
||||
clampIssueListLimit,
|
||||
ISSUE_LIST_DEFAULT_LIMIT,
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
type ProjectExecutionWorkspacePolicy,
|
||||
type ProjectGoalRef,
|
||||
type ProjectManagedByPlugin,
|
||||
type ProjectRepositoryHint,
|
||||
type ProjectWorkspaceRuntimeConfig,
|
||||
type ProjectWorkspace,
|
||||
type WorkspaceRuntimeService,
|
||||
@@ -35,6 +36,7 @@ import { unprocessable } from "../errors.js";
|
||||
import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
|
||||
import { mergeProjectWorkspaceRuntimeConfig, readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js";
|
||||
import { resolveManagedProjectWorkspaceDir } from "../home-paths.js";
|
||||
import { toRepository, toRepositoryContext } from "./repositories.js";
|
||||
|
||||
type ProjectRow = typeof projects.$inferSelect;
|
||||
type ProjectWorkspaceRow = typeof projectWorkspaces.$inferSelect;
|
||||
@@ -65,6 +67,7 @@ interface ProjectWithGoals extends Omit<ProjectRow, "executionWorkspacePolicy">
|
||||
goalIds: string[];
|
||||
goals: ProjectGoalRef[];
|
||||
executionWorkspacePolicy: ProjectExecutionWorkspacePolicy | null;
|
||||
repositoryHints: ProjectRepositoryHint[];
|
||||
codebase: ProjectCodebase;
|
||||
workspaces: ProjectWorkspace[];
|
||||
primaryWorkspace: ProjectWorkspace | null;
|
||||
@@ -89,15 +92,29 @@ async function attachGoals(db: Db, rows: ProjectRow[]): Promise<ProjectWithGoals
|
||||
const projectIds = rows.map((r) => r.id);
|
||||
|
||||
// Fetch join rows + goal titles in one query
|
||||
const links = await db
|
||||
.select({
|
||||
projectId: projectGoals.projectId,
|
||||
goalId: projectGoals.goalId,
|
||||
goalTitle: goals.title,
|
||||
})
|
||||
.from(projectGoals)
|
||||
.innerJoin(goals, eq(projectGoals.goalId, goals.id))
|
||||
.where(inArray(projectGoals.projectId, projectIds));
|
||||
const [links, repositoryLinks] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
projectId: projectGoals.projectId,
|
||||
goalId: projectGoals.goalId,
|
||||
goalTitle: goals.title,
|
||||
})
|
||||
.from(projectGoals)
|
||||
.innerJoin(goals, eq(projectGoals.goalId, goals.id))
|
||||
.where(inArray(projectGoals.projectId, projectIds)),
|
||||
db
|
||||
.select({ link: projectRepositories, repository: repositories })
|
||||
.from(projectRepositories)
|
||||
.innerJoin(repositories, eq(projectRepositories.repositoryId, repositories.id))
|
||||
.where(inArray(projectRepositories.projectId, projectIds))
|
||||
.orderBy(
|
||||
asc(projectRepositories.displayOrder),
|
||||
asc(repositories.host),
|
||||
asc(repositories.owner),
|
||||
asc(repositories.name),
|
||||
asc(repositories.id),
|
||||
),
|
||||
]);
|
||||
|
||||
const map = new Map<string, ProjectGoalRef[]>();
|
||||
for (const link of links) {
|
||||
@@ -108,6 +125,15 @@ async function attachGoals(db: Db, rows: ProjectRow[]): Promise<ProjectWithGoals
|
||||
}
|
||||
arr.push({ id: link.goalId, title: link.goalTitle });
|
||||
}
|
||||
const repositoriesByProjectId = new Map<string, ProjectRepositoryHint[]>();
|
||||
for (const row of repositoryLinks) {
|
||||
const hints = repositoriesByProjectId.get(row.link.projectId) ?? [];
|
||||
hints.push({
|
||||
...toRepositoryContext(toRepository(row.repository)),
|
||||
displayOrder: row.link.displayOrder,
|
||||
});
|
||||
repositoriesByProjectId.set(row.link.projectId, hints);
|
||||
}
|
||||
|
||||
return rows.map((r) => {
|
||||
const g = map.get(r.id) ?? [];
|
||||
@@ -116,6 +142,7 @@ async function attachGoals(db: Db, rows: ProjectRow[]): Promise<ProjectWithGoals
|
||||
urlKey: deriveProjectUrlKey(r.name, r.id),
|
||||
goalIds: g.map((x) => x.id),
|
||||
goals: g,
|
||||
repositoryHints: repositoriesByProjectId.get(r.id) ?? [],
|
||||
executionWorkspacePolicy: parseProjectExecutionWorkspacePolicy(r.executionWorkspacePolicy),
|
||||
} as ProjectWithGoals;
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
CreateManualRepository,
|
||||
Repository,
|
||||
RepositoryCatalogItem,
|
||||
RepositoryContext,
|
||||
RepositoryVisibility,
|
||||
UpdateRepository,
|
||||
} from "@paperclipai/shared";
|
||||
@@ -59,6 +60,42 @@ export function toRepository(row: RepositoryRow): Repository {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce a repository to the stable, secret-free shape that is safe to place in
|
||||
* project payloads and agent prompts. Legacy rows may predate URL validation,
|
||||
* so invalid or credential-bearing URLs are omitted instead of echoed.
|
||||
*/
|
||||
export function toRepositoryContext(
|
||||
repository: Pick<Repository, "id" | "provider" | "host" | "owner" | "name" | "state" | "unavailableReason" | "cloneUrl" | "webUrl" | "defaultBranch">,
|
||||
): RepositoryContext {
|
||||
let cloneUrl: string | null = null;
|
||||
let webUrl: string | null = null;
|
||||
try {
|
||||
cloneUrl = normalizeRepositoryLocator(repository.cloneUrl).cloneUrl;
|
||||
} catch {
|
||||
// Fail closed for pre-normalization rows.
|
||||
}
|
||||
if (repository.webUrl) {
|
||||
try {
|
||||
webUrl = normalizeRepositoryWebUrl(repository.webUrl);
|
||||
} catch {
|
||||
// Fail closed for pre-normalization rows.
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: repository.id,
|
||||
provider: repository.provider,
|
||||
host: repository.host,
|
||||
owner: repository.owner,
|
||||
name: repository.name,
|
||||
state: repository.state,
|
||||
unavailableReason: repository.unavailableReason,
|
||||
cloneUrl,
|
||||
webUrl,
|
||||
defaultBranch: repository.defaultBranch,
|
||||
};
|
||||
}
|
||||
|
||||
export function repositoryService(db: Db) {
|
||||
async function getRowById(id: string) {
|
||||
return db.select().from(repositories).where(eq(repositories.id, id)).then((rows) => rows[0] ?? null);
|
||||
|
||||
@@ -10,14 +10,22 @@ import {
|
||||
import type {
|
||||
AgentRepositoryGrantEntry,
|
||||
EffectiveRepositoryAccess,
|
||||
EffectiveRepositoryContext,
|
||||
ProjectRepositoryEntry,
|
||||
Repository,
|
||||
} from "@paperclipai/shared";
|
||||
import { conflict } from "../errors.js";
|
||||
import { toRepository } from "./repositories.js";
|
||||
import { toRepository, toRepositoryContext } from "./repositories.js";
|
||||
|
||||
type ProjectAccessCandidate = { id: string; name: string; companyId: string };
|
||||
|
||||
export function toEffectiveRepositoryContext(entry: EffectiveRepositoryAccess): EffectiveRepositoryContext {
|
||||
return {
|
||||
...toRepositoryContext(entry.repository),
|
||||
sources: entry.sources,
|
||||
};
|
||||
}
|
||||
|
||||
function toProjectEntry(row: {
|
||||
link: typeof projectRepositories.$inferSelect;
|
||||
repository: typeof repositories.$inferSelect;
|
||||
@@ -302,3 +310,20 @@ export function repositoryAccessService(db: Db) {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-resolve access at the moment a clone credential is requested. Callers
|
||||
* must never treat repository IDs cached in adapter context as authorization.
|
||||
*/
|
||||
export async function authorizeRepositoryCloneCredentialRequest(
|
||||
db: Db,
|
||||
input: {
|
||||
companyId: string;
|
||||
agentId: string;
|
||||
repositoryId: string;
|
||||
canAccessProject: (project: ProjectAccessCandidate) => Promise<boolean>;
|
||||
},
|
||||
): Promise<EffectiveRepositoryAccess | null> {
|
||||
const effective = await repositoryAccessService(db).listEffectiveRepositories(input);
|
||||
return effective?.find((entry) => entry.repository.id === input.repositoryId) ?? null;
|
||||
}
|
||||
Reference in new issue
Block a user