mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 02:00:52 +01:00
Implement user-secret runtime responsibility gates
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
4e8cbf13e3
commit
2f8bab01bc
9 files changed
+519
-15
No files matched your search
@@ -283,6 +283,72 @@ describe("resolveExecutionRunAdapterConfig", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks required missing user secrets before runtime env resolution", async () => {
|
||||
const resolveAdapterConfigForRuntime = vi.fn();
|
||||
const resolveEnvBindings = vi.fn();
|
||||
const collectMissingRuntimeBindings = vi.fn(async (_companyId, _env, context) =>
|
||||
context.consumerType === "agent"
|
||||
? [
|
||||
{
|
||||
consumerType: "agent",
|
||||
consumerId: "agent-1",
|
||||
configPath: "env.GITHUB_TOKEN",
|
||||
envKey: "GITHUB_TOKEN",
|
||||
bindingType: "user_secret_ref",
|
||||
secretId: null,
|
||||
secretName: null,
|
||||
userSecretDefinitionId: "definition-1",
|
||||
userSecretDefinitionKey: "github_token",
|
||||
userSecretDefinitionName: "GitHub token",
|
||||
responsibleUserId: context.responsibleUserId,
|
||||
errorCode: "user_secret_missing",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
|
||||
await expect(resolveExecutionRunAdapterConfig({
|
||||
companyId: "company-1",
|
||||
agentId: "agent-1",
|
||||
issueId: "issue-1",
|
||||
heartbeatRunId: "run-1",
|
||||
responsibleUserId: "user-1",
|
||||
executionRunConfig: {
|
||||
env: {
|
||||
GITHUB_TOKEN: { type: "user_secret_ref", key: "github_token", required: true },
|
||||
},
|
||||
},
|
||||
projectEnv: null,
|
||||
secretsSvc: {
|
||||
resolveAdapterConfigForRuntime,
|
||||
resolveEnvBindings,
|
||||
collectMissingRuntimeBindings,
|
||||
} as any,
|
||||
})).rejects.toMatchObject({
|
||||
code: "configuration_incomplete",
|
||||
resultJson: {
|
||||
configurationIncomplete: {
|
||||
reason: "secret_binding_missing",
|
||||
companyId: "company-1",
|
||||
agentId: "agent-1",
|
||||
issueId: "issue-1",
|
||||
missingBindings: [
|
||||
expect.objectContaining({
|
||||
bindingType: "user_secret_ref",
|
||||
userSecretDefinitionKey: "github_token",
|
||||
responsibleUserId: "user-1",
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(collectMissingRuntimeBindings.mock.calls[0]?.[2]).toMatchObject({
|
||||
responsibleUserId: "user-1",
|
||||
});
|
||||
expect(resolveAdapterConfigForRuntime).not.toHaveBeenCalled();
|
||||
expect(resolveEnvBindings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects inline sensitive env values for low-trust runs", async () => {
|
||||
await expect(resolveExecutionRunAdapterConfig({
|
||||
companyId: "company-1",
|
||||
|
||||
@@ -2151,6 +2151,7 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
|
||||
await db.delete(projectWorkspaces);
|
||||
await db.delete(projects);
|
||||
await db.delete(goals);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(agents);
|
||||
await db.delete(environments);
|
||||
await db.delete(instanceSettings);
|
||||
@@ -2237,6 +2238,57 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("inherits responsible user for agent-created child issues", async () => {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const runId = randomUUID();
|
||||
const responsibleUserId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Coder",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
|
||||
const parent = await svc.create(companyId, {
|
||||
title: "Parent issue",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
createdByUserId: responsibleUserId,
|
||||
});
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: runId,
|
||||
companyId,
|
||||
agentId,
|
||||
invocationSource: "assignment",
|
||||
status: "running",
|
||||
responsibleUserId,
|
||||
contextSnapshot: { issueId: parent.id },
|
||||
});
|
||||
|
||||
const child = await svc.create(companyId, {
|
||||
parentId: parent.id,
|
||||
title: "Agent-created child",
|
||||
createdByAgentId: agentId,
|
||||
actorRunId: runId,
|
||||
});
|
||||
|
||||
expect(parent.responsibleUserId).toBe(responsibleUserId);
|
||||
expect(child.responsibleUserId).toBe(responsibleUserId);
|
||||
});
|
||||
|
||||
it("does not stamp the assignee default environment onto new issues", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
|
||||
@@ -712,6 +712,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
||||
id: issues.id,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
createdByUserId: issues.createdByUserId,
|
||||
responsibleUserId: issues.responsibleUserId,
|
||||
})
|
||||
.from(issues)
|
||||
.where(eq(issues.id, run.linkedIssueId!));
|
||||
@@ -719,6 +720,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
||||
id: run.linkedIssueId,
|
||||
assigneeAgentId: agentId,
|
||||
createdByUserId: userId,
|
||||
responsibleUserId: userId,
|
||||
});
|
||||
|
||||
const inboxIssues = await issueSvc.list(companyId, {
|
||||
@@ -729,6 +731,45 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
||||
expect(inboxIssues.map((issue) => issue.id)).toContain(run.linkedIssueId);
|
||||
});
|
||||
|
||||
it("uses the routine revision responsible-user snapshot for automatic runs", async () => {
|
||||
const { companyId, agentId, projectId, svc } = await seedFixture();
|
||||
const responsibleUserId = randomUUID();
|
||||
const driftUserId = randomUUID();
|
||||
const routine = await svc.create(
|
||||
companyId,
|
||||
{
|
||||
projectId,
|
||||
goalId: null,
|
||||
parentIssueId: null,
|
||||
title: "snapshotted owner routine",
|
||||
description: null,
|
||||
assigneeAgentId: agentId,
|
||||
priority: "medium",
|
||||
status: "active",
|
||||
concurrencyPolicy: "coalesce_if_active",
|
||||
catchUpPolicy: "skip_missed",
|
||||
},
|
||||
{ userId: responsibleUserId },
|
||||
);
|
||||
|
||||
await db
|
||||
.update(routines)
|
||||
.set({ responsibleUserId: driftUserId, updatedAt: new Date() })
|
||||
.where(eq(routines.id, routine.id));
|
||||
|
||||
const run = await svc.runRoutine(routine.id, { source: "schedule" });
|
||||
|
||||
expect(run.status).toBe("issue_created");
|
||||
expect(run.responsibleUserId).toBe(responsibleUserId);
|
||||
const [createdIssue] = await db
|
||||
.select({
|
||||
responsibleUserId: issues.responsibleUserId,
|
||||
})
|
||||
.from(issues)
|
||||
.where(eq(issues.id, run.linkedIssueId!));
|
||||
expect(createdIssue?.responsibleUserId).toBe(responsibleUserId);
|
||||
});
|
||||
|
||||
it("waits for the assignee wakeup to be queued before returning the routine run", async () => {
|
||||
let wakeupResolved = false;
|
||||
const { routine, svc } = await seedFixture({
|
||||
|
||||
@@ -420,6 +420,52 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
responsibleUserId: "user-2",
|
||||
}),
|
||||
).rejects.toThrow(/not configured/i);
|
||||
await expect(
|
||||
svc.collectMissingRuntimeBindings(companyId, env, {
|
||||
consumerType: "agent",
|
||||
consumerId: "agent-1",
|
||||
responsibleUserId: "user-2",
|
||||
}),
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
bindingType: "user_secret_ref",
|
||||
configPath: "env.GITHUB_TOKEN",
|
||||
envKey: "GITHUB_TOKEN",
|
||||
userSecretDefinitionId: definition.id,
|
||||
userSecretDefinitionKey: "github_token",
|
||||
responsibleUserId: "user-2",
|
||||
errorCode: "user_secret_missing",
|
||||
}),
|
||||
]);
|
||||
|
||||
const optionalEnv = {
|
||||
OPTIONAL_GITHUB_TOKEN: {
|
||||
type: "user_secret_ref" as const,
|
||||
key: "github_token",
|
||||
version: "latest" as const,
|
||||
required: false,
|
||||
},
|
||||
};
|
||||
await svc.syncEnvBindingsForTarget(companyId, { targetType: "agent", targetId: "agent-optional" }, optionalEnv);
|
||||
await expect(
|
||||
svc.collectMissingRuntimeBindings(companyId, optionalEnv, {
|
||||
consumerType: "agent",
|
||||
consumerId: "agent-optional",
|
||||
responsibleUserId: "user-2",
|
||||
}),
|
||||
).resolves.toEqual([]);
|
||||
await expect(
|
||||
svc.resolveEnvBindings(companyId, optionalEnv, {
|
||||
consumerType: "agent",
|
||||
consumerId: "agent-optional",
|
||||
actorType: "agent",
|
||||
actorId: "agent-optional",
|
||||
responsibleUserId: "user-2",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
env: {},
|
||||
manifest: [],
|
||||
});
|
||||
|
||||
const resolved = await svc.resolveEnvBindings(companyId, env, {
|
||||
consumerType: "agent",
|
||||
|
||||
@@ -5229,6 +5229,7 @@ export function issueRoutes(
|
||||
...(sourceTrust ? { sourceTrust } : {}),
|
||||
createdByAgentId: actor.agentId,
|
||||
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
|
||||
actorRunId: actor.runId,
|
||||
watchdogActorRunId: actor.runId,
|
||||
});
|
||||
await issueReferencesSvc.syncIssue(issue.id);
|
||||
@@ -5397,6 +5398,7 @@ export function issueRoutes(
|
||||
...(sourceTrust ? { sourceTrust } : {}),
|
||||
createdByAgentId: actor.agentId,
|
||||
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
|
||||
actorRunId: actor.runId,
|
||||
actorAgentId: actor.agentId,
|
||||
actorUserId: actor.actorType === "user" ? actor.actorId : null,
|
||||
watchdogActorRunId: actor.runId,
|
||||
@@ -5562,6 +5564,7 @@ export function issueRoutes(
|
||||
...(sourceTrust ? { sourceTrust } : {}),
|
||||
createdByAgentId: actor.agentId,
|
||||
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
|
||||
actorRunId: actor.runId,
|
||||
actorAgentId: actor.agentId,
|
||||
actorUserId: actor.actorType === "user" ? actor.actorId : null,
|
||||
});
|
||||
|
||||
@@ -431,9 +431,19 @@ type RuntimeConfigSecretResolver = Pick<
|
||||
>;
|
||||
|
||||
function formatMissingBindingForOperator(missing: MissingRuntimeBinding): string {
|
||||
if (missing.bindingType === "user_secret_ref") {
|
||||
const definitionLabel =
|
||||
missing.userSecretDefinitionName
|
||||
? `"${missing.userSecretDefinitionName}"`
|
||||
: missing.userSecretDefinitionKey
|
||||
? `"${missing.userSecretDefinitionKey}"`
|
||||
: "declared user secret";
|
||||
const ownerLabel = missing.responsibleUserId ? ` for responsible user ${missing.responsibleUserId}` : "";
|
||||
return `user secret ${definitionLabel}${ownerLabel} not available at ${missing.consumerType} ${missing.configPath}`;
|
||||
}
|
||||
const secretLabel = missing.secretName
|
||||
? `"${missing.secretName}"`
|
||||
: missing.secretId;
|
||||
: missing.secretId ?? "unknown";
|
||||
return `secret ${secretLabel} not bound at ${missing.consumerType} ${missing.configPath}`;
|
||||
}
|
||||
|
||||
@@ -579,7 +589,11 @@ export async function resolveExecutionRunAdapterConfig(input: {
|
||||
...(await input.secretsSvc.collectMissingRuntimeBindings(
|
||||
input.companyId,
|
||||
environmentEnv,
|
||||
{ consumerType: "environment", consumerId: input.environmentId },
|
||||
{
|
||||
consumerType: "environment",
|
||||
consumerId: input.environmentId,
|
||||
responsibleUserId: input.responsibleUserId ?? null,
|
||||
},
|
||||
)),
|
||||
);
|
||||
}
|
||||
@@ -588,7 +602,11 @@ export async function resolveExecutionRunAdapterConfig(input: {
|
||||
...(await input.secretsSvc.collectMissingRuntimeBindings(
|
||||
input.companyId,
|
||||
parseObject(executionRunConfig.env),
|
||||
{ consumerType: "agent", consumerId: input.agentId },
|
||||
{
|
||||
consumerType: "agent",
|
||||
consumerId: input.agentId,
|
||||
responsibleUserId: input.responsibleUserId ?? null,
|
||||
},
|
||||
)),
|
||||
);
|
||||
if (typeof input.secretsSvc.collectMissingAdapterConfigRuntimeBindings === "function") {
|
||||
@@ -597,7 +615,11 @@ export async function resolveExecutionRunAdapterConfig(input: {
|
||||
input.companyId,
|
||||
executionRunConfig,
|
||||
input.adapterType ?? null,
|
||||
{ consumerType: "agent", consumerId: input.agentId },
|
||||
{
|
||||
consumerType: "agent",
|
||||
consumerId: input.agentId,
|
||||
responsibleUserId: input.responsibleUserId ?? null,
|
||||
},
|
||||
)),
|
||||
);
|
||||
}
|
||||
@@ -607,7 +629,11 @@ export async function resolveExecutionRunAdapterConfig(input: {
|
||||
...(await input.secretsSvc.collectMissingRuntimeBindings(
|
||||
input.companyId,
|
||||
projectEnv,
|
||||
{ consumerType: "project", consumerId: input.projectId },
|
||||
{
|
||||
consumerType: "project",
|
||||
consumerId: input.projectId,
|
||||
responsibleUserId: input.responsibleUserId ?? null,
|
||||
},
|
||||
)),
|
||||
);
|
||||
}
|
||||
@@ -616,7 +642,11 @@ export async function resolveExecutionRunAdapterConfig(input: {
|
||||
...(await input.secretsSvc.collectMissingRuntimeBindings(
|
||||
input.companyId,
|
||||
routineEnv,
|
||||
{ consumerType: "routine", consumerId: input.routineId },
|
||||
{
|
||||
consumerType: "routine",
|
||||
consumerId: input.routineId,
|
||||
responsibleUserId: input.responsibleUserId ?? null,
|
||||
},
|
||||
)),
|
||||
);
|
||||
}
|
||||
@@ -4498,6 +4528,9 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
assigneeAdapterOverrides: issues.assigneeAdapterOverrides,
|
||||
executionPolicy: issues.executionPolicy,
|
||||
executionWorkspaceSettings: issues.executionWorkspaceSettings,
|
||||
parentId: issues.parentId,
|
||||
createdByUserId: issues.createdByUserId,
|
||||
responsibleUserId: issues.responsibleUserId,
|
||||
originKind: issues.originKind,
|
||||
originId: issues.originId,
|
||||
originRunId: issues.originRunId,
|
||||
@@ -4513,13 +4546,14 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
issueContext: Awaited<ReturnType<typeof getIssueExecutionContext>> | null,
|
||||
) {
|
||||
if (!issueContext || issueContext.originKind !== "routine_execution" || !issueContext.originId) {
|
||||
return { routineId: null, env: null };
|
||||
return { routineId: null, env: null, responsibleUserId: null };
|
||||
}
|
||||
|
||||
const routineRun = issueContext.originRunId
|
||||
? await db
|
||||
.select({
|
||||
routineRevisionId: routineRuns.routineRevisionId,
|
||||
responsibleUserId: routineRuns.responsibleUserId,
|
||||
})
|
||||
.from(routineRuns)
|
||||
.where(
|
||||
@@ -4536,6 +4570,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
const revision = await db
|
||||
.select({
|
||||
snapshot: routineRevisions.snapshot,
|
||||
responsibleUserId: routineRevisions.responsibleUserId,
|
||||
})
|
||||
.from(routineRevisions)
|
||||
.where(
|
||||
@@ -4548,16 +4583,50 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
.then((rows) => rows[0] ?? null);
|
||||
const snapshot = revision?.snapshot as RoutineRevisionSnapshotV1 | undefined;
|
||||
if (snapshot?.version === 1) {
|
||||
return { routineId: issueContext.originId, env: snapshot.routine.env ?? null };
|
||||
return {
|
||||
routineId: issueContext.originId,
|
||||
env: snapshot.routine.env ?? null,
|
||||
responsibleUserId: revision?.responsibleUserId ?? snapshot.routine.responsibleUserId ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const routine = await db
|
||||
.select({ env: routines.env })
|
||||
.select({ env: routines.env, responsibleUserId: routines.responsibleUserId })
|
||||
.from(routines)
|
||||
.where(and(eq(routines.id, issueContext.originId), eq(routines.companyId, companyId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return { routineId: issueContext.originId, env: routine?.env ?? null };
|
||||
return {
|
||||
routineId: issueContext.originId,
|
||||
env: routine?.env ?? null,
|
||||
responsibleUserId: routineRun?.responsibleUserId ?? routine?.responsibleUserId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveResponsibleUserIdForRun(input: {
|
||||
run: typeof heartbeatRuns.$inferSelect;
|
||||
contextSnapshot: Record<string, unknown>;
|
||||
issueContext: Awaited<ReturnType<typeof getIssueExecutionContext>> | null;
|
||||
routineEnvContext: Awaited<ReturnType<typeof getRoutineEnvForExecutionIssue>>;
|
||||
}) {
|
||||
const contextResponsibleUserId = readNonEmptyString(input.contextSnapshot.responsibleUserId);
|
||||
if (contextResponsibleUserId) return contextResponsibleUserId;
|
||||
if (input.run.responsibleUserId) return input.run.responsibleUserId;
|
||||
if (input.routineEnvContext.responsibleUserId) return input.routineEnvContext.responsibleUserId;
|
||||
if (input.issueContext?.responsibleUserId) return input.issueContext.responsibleUserId;
|
||||
if (input.issueContext?.createdByUserId) return input.issueContext.createdByUserId;
|
||||
if (input.issueContext?.parentId) {
|
||||
const parent = await db
|
||||
.select({
|
||||
responsibleUserId: issues.responsibleUserId,
|
||||
createdByUserId: issues.createdByUserId,
|
||||
})
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, input.run.companyId), eq(issues.id, input.issueContext.parentId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return parent?.responsibleUserId ?? parent?.createdByUserId ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getRuntimeState(agentId: string) {
|
||||
@@ -8301,10 +8370,17 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
}
|
||||
|
||||
const claimedAt = new Date();
|
||||
const responsibleUserId = await resolveResponsibleUserIdForRun({
|
||||
run,
|
||||
contextSnapshot: context,
|
||||
issueContext: issueId ? await getIssueExecutionContext(run.companyId, issueId) : null,
|
||||
routineEnvContext: { routineId: null, env: null, responsibleUserId: null },
|
||||
});
|
||||
const claimed = await db
|
||||
.update(heartbeatRuns)
|
||||
.set({
|
||||
status: "running",
|
||||
responsibleUserId,
|
||||
startedAt: run.startedAt ?? claimedAt,
|
||||
updatedAt: claimedAt,
|
||||
})
|
||||
@@ -9395,6 +9471,26 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
delete context.acceptedPlanWakeRouting;
|
||||
}
|
||||
const routineEnvContext = await getRoutineEnvForExecutionIssue(agent.companyId, issueContext);
|
||||
const responsibleUserId = await resolveResponsibleUserIdForRun({
|
||||
run,
|
||||
contextSnapshot: context,
|
||||
issueContext,
|
||||
routineEnvContext,
|
||||
});
|
||||
if (responsibleUserId && run.responsibleUserId !== responsibleUserId) {
|
||||
await db
|
||||
.update(heartbeatRuns)
|
||||
.set({ responsibleUserId, updatedAt: new Date() })
|
||||
.where(eq(heartbeatRuns.id, run.id));
|
||||
run = { ...run, responsibleUserId };
|
||||
}
|
||||
if (responsibleUserId && issueContext && !issueContext.responsibleUserId) {
|
||||
await db
|
||||
.update(issues)
|
||||
.set({ responsibleUserId, updatedAt: new Date() })
|
||||
.where(and(eq(issues.companyId, agent.companyId), eq(issues.id, issueContext.id), isNull(issues.responsibleUserId)));
|
||||
issueContext = { ...issueContext, responsibleUserId };
|
||||
}
|
||||
const projectExecutionWorkspacePolicy = gateProjectExecutionWorkspacePolicy(
|
||||
parseProjectExecutionWorkspacePolicy(projectContext?.executionWorkspacePolicy),
|
||||
isolatedWorkspacesEnabled,
|
||||
@@ -9703,6 +9799,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
environmentEnv: selectedEnvironmentForConfig?.envVars ?? null,
|
||||
projectId: projectContext?.id ?? null,
|
||||
routineId: routineEnvContext.routineId,
|
||||
responsibleUserId,
|
||||
executionRunConfig,
|
||||
projectEnv: projectContext?.env ?? null,
|
||||
routineEnv: routineEnvContext.env,
|
||||
@@ -12017,6 +12114,9 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
if (projectId && !readNonEmptyString(enrichedContextSnapshot.projectId)) {
|
||||
enrichedContextSnapshot.projectId = projectId;
|
||||
}
|
||||
const queuedResponsibleUserId =
|
||||
readNonEmptyString(enrichedContextSnapshot.responsibleUserId) ??
|
||||
(opts.requestedByActorType === "user" ? opts.requestedByActorId ?? null : null);
|
||||
|
||||
const budgetBlock = await budgets.getInvocationBlock(agent.companyId, agentId, {
|
||||
issueId,
|
||||
@@ -12603,6 +12703,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
invocationSource: source,
|
||||
triggerDetail,
|
||||
status: "queued",
|
||||
responsibleUserId: queuedResponsibleUserId,
|
||||
wakeupRequestId: wakeupRequest.id,
|
||||
contextSnapshot: enrichedContextSnapshot,
|
||||
sessionIdBefore: sessionBefore,
|
||||
@@ -12668,7 +12769,6 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
Boolean(sameScopeRunningRun) &&
|
||||
!sameScopeQueuedRun &&
|
||||
shouldQueueFollowupForRunningIssueWake({ contextSnapshot: enrichedContextSnapshot, wakeCommentId });
|
||||
|
||||
const rawCoalescedTarget =
|
||||
sameScopeQueuedRun ??
|
||||
sameScopeScheduledRetryRun ??
|
||||
@@ -12777,6 +12877,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
invocationSource: source,
|
||||
triggerDetail,
|
||||
status: "queued",
|
||||
responsibleUserId: queuedResponsibleUserId,
|
||||
wakeupRequestId: wakeupRequest.id,
|
||||
contextSnapshot: enrichedContextSnapshot,
|
||||
sessionIdBefore: sessionBefore,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
documents,
|
||||
goals,
|
||||
heartbeatRuns,
|
||||
routineRuns,
|
||||
executionWorkspaces,
|
||||
issueApprovals,
|
||||
issueAttachments,
|
||||
@@ -137,6 +138,55 @@ function readStringFromRecord(record: unknown, key: string) {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
async function resolveResponsibleUserIdForIssueCreate(
|
||||
reader: DbReader,
|
||||
companyId: string,
|
||||
input: {
|
||||
explicitResponsibleUserId?: string | null;
|
||||
createdByUserId?: string | null;
|
||||
parentId?: string | null;
|
||||
originKind?: string | null;
|
||||
originRunId?: string | null;
|
||||
actorRunId?: string | null;
|
||||
},
|
||||
) {
|
||||
const explicitResponsibleUserId = readStringFromRecord(input, "explicitResponsibleUserId");
|
||||
if (explicitResponsibleUserId) return explicitResponsibleUserId;
|
||||
|
||||
if (input.originKind === "routine_execution" && input.originRunId) {
|
||||
const routineRun = await reader
|
||||
.select({ responsibleUserId: routineRuns.responsibleUserId })
|
||||
.from(routineRuns)
|
||||
.where(and(eq(routineRuns.companyId, companyId), eq(routineRuns.id, input.originRunId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (routineRun?.responsibleUserId) return routineRun.responsibleUserId;
|
||||
}
|
||||
|
||||
if (input.actorRunId) {
|
||||
const actorRun = await reader
|
||||
.select({ responsibleUserId: heartbeatRuns.responsibleUserId })
|
||||
.from(heartbeatRuns)
|
||||
.where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, input.actorRunId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (actorRun?.responsibleUserId) return actorRun.responsibleUserId;
|
||||
}
|
||||
|
||||
if (input.parentId) {
|
||||
const parent = await reader
|
||||
.select({
|
||||
responsibleUserId: issues.responsibleUserId,
|
||||
createdByUserId: issues.createdByUserId,
|
||||
})
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, companyId), eq(issues.id, input.parentId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (parent?.responsibleUserId) return parent.responsibleUserId;
|
||||
if (parent?.createdByUserId) return parent.createdByUserId;
|
||||
}
|
||||
|
||||
return input.createdByUserId ?? null;
|
||||
}
|
||||
|
||||
function buildReusedExecutionWorkspaceConfigPatchFromIssueSettings(
|
||||
settings: ReturnType<typeof parseIssueExecutionWorkspaceSettings>,
|
||||
) {
|
||||
@@ -366,6 +416,7 @@ type IssueCreateInput = Omit<typeof issues.$inferInsert, "companyId"> & {
|
||||
inheritExecutionWorkspaceFromIssueId?: string | null;
|
||||
watchdog?: { agentId: string; instructions?: string | null } | null;
|
||||
watchdogActorRunId?: string | null;
|
||||
actorRunId?: string | null;
|
||||
};
|
||||
type IssueChildCreateInput = IssueCreateInput & {
|
||||
acceptanceCriteria?: string[];
|
||||
@@ -5014,6 +5065,7 @@ export function issueService(db: Db) {
|
||||
inheritExecutionWorkspaceFromIssueId,
|
||||
watchdog,
|
||||
watchdogActorRunId,
|
||||
actorRunId,
|
||||
...issueData
|
||||
} = data;
|
||||
const isolatedWorkspacesEnabled = (await instanceSettings.getExperimental()).enableIsolatedWorkspaces;
|
||||
@@ -5159,9 +5211,18 @@ export function issueService(db: Db) {
|
||||
|
||||
const issueNumber = company.issueCounter;
|
||||
const identifier = `${company.issuePrefix}-${issueNumber}`;
|
||||
const responsibleUserId = await resolveResponsibleUserIdForIssueCreate(tx, companyId, {
|
||||
explicitResponsibleUserId: issueData.responsibleUserId ?? null,
|
||||
createdByUserId: issueData.createdByUserId ?? null,
|
||||
parentId: issueData.parentId ?? null,
|
||||
originKind: issueData.originKind ?? "manual",
|
||||
originRunId: issueData.originRunId ?? null,
|
||||
actorRunId: actorRunId ?? null,
|
||||
});
|
||||
|
||||
const values = {
|
||||
...issueData,
|
||||
responsibleUserId,
|
||||
requestDepth: clampIssueRequestDepth(issueData.requestDepth),
|
||||
originKind: issueData.originKind ?? "manual",
|
||||
goalId: resolveIssueGoalId({
|
||||
|
||||
@@ -820,6 +820,7 @@ export function routineService(
|
||||
createdByAgentId: actor.agentId ?? null,
|
||||
createdByUserId: actor.userId ?? null,
|
||||
createdByRunId: actor.runId ?? null,
|
||||
responsibleUserId: snapshot.routine.responsibleUserId ?? null,
|
||||
createdAt: now,
|
||||
})
|
||||
.returning();
|
||||
@@ -1137,6 +1138,7 @@ export function routineService(
|
||||
completedAt: triggeredAt,
|
||||
linkedIssueId: null,
|
||||
routineRevisionId: input.routine.latestRevisionId,
|
||||
responsibleUserId: input.routine.responsibleUserId ?? null,
|
||||
})
|
||||
.returning();
|
||||
await updateRoutineTouchedState({
|
||||
@@ -1486,6 +1488,26 @@ export function routineService(
|
||||
|
||||
const triggeredAt = new Date();
|
||||
const manualRunnerUserId = input.source === "manual" ? input.actor?.userId ?? null : null;
|
||||
const latestRevisionResponsibleUserId = input.routine.latestRevisionId
|
||||
? await txDb
|
||||
.select({
|
||||
responsibleUserId: routineRevisions.responsibleUserId,
|
||||
snapshot: routineRevisions.snapshot,
|
||||
})
|
||||
.from(routineRevisions)
|
||||
.where(and(
|
||||
eq(routineRevisions.companyId, input.routine.companyId),
|
||||
eq(routineRevisions.routineId, input.routine.id),
|
||||
eq(routineRevisions.id, input.routine.latestRevisionId),
|
||||
))
|
||||
.then((rows) => {
|
||||
const row = rows[0] ?? null;
|
||||
const snapshot = row?.snapshot as RoutineRevisionSnapshotV1 | undefined;
|
||||
return row?.responsibleUserId ?? snapshot?.routine.responsibleUserId ?? null;
|
||||
})
|
||||
: null;
|
||||
const responsibleUserId =
|
||||
manualRunnerUserId ?? latestRevisionResponsibleUserId ?? input.routine.responsibleUserId ?? null;
|
||||
const [createdRun] = await txDb
|
||||
.insert(routineRuns)
|
||||
.values({
|
||||
@@ -1499,6 +1521,7 @@ export function routineService(
|
||||
triggerPayload,
|
||||
dispatchFingerprint,
|
||||
routineRevisionId: input.routine.latestRevisionId,
|
||||
responsibleUserId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
@@ -1552,6 +1575,7 @@ export function routineService(
|
||||
assigneeAgentId,
|
||||
createdByAgentId: input.source === "manual" ? input.actor?.agentId ?? null : null,
|
||||
createdByUserId: manualRunnerUserId,
|
||||
responsibleUserId,
|
||||
originKind: issueOriginKind,
|
||||
originId: issueOriginId,
|
||||
originRunId: createdRun.id,
|
||||
@@ -1861,6 +1885,7 @@ export function routineService(
|
||||
catchUpPolicy: input.catchUpPolicy,
|
||||
variables,
|
||||
env,
|
||||
responsibleUserId: actor.userId ?? null,
|
||||
createdByAgentId: actor.agentId ?? null,
|
||||
createdByUserId: actor.userId ?? null,
|
||||
updatedByAgentId: actor.agentId ?? null,
|
||||
|
||||
@@ -266,8 +266,14 @@ export type MissingRuntimeBinding = {
|
||||
consumerId: string;
|
||||
configPath: string;
|
||||
envKey: string;
|
||||
secretId: string;
|
||||
bindingType?: "secret_ref" | "user_secret_ref";
|
||||
secretId: string | null;
|
||||
secretName: string | null;
|
||||
userSecretDefinitionId?: string | null;
|
||||
userSecretDefinitionKey?: string | null;
|
||||
userSecretDefinitionName?: string | null;
|
||||
responsibleUserId?: string | null;
|
||||
errorCode?: SecretResolutionErrorCode;
|
||||
};
|
||||
|
||||
type RuntimeSecretResolution = {
|
||||
@@ -2251,6 +2257,7 @@ export function secretService(db: Db) {
|
||||
throw unprocessable("User secret definition is not active");
|
||||
}
|
||||
if (!responsibleUserId?.trim()) {
|
||||
if (input.allowMissingOverride || input.required === false) return null;
|
||||
throw unprocessable("Responsible user is required for user secret resolution", {
|
||||
code: "responsible_user_missing",
|
||||
});
|
||||
@@ -3441,7 +3448,16 @@ export function secretService(db: Db) {
|
||||
if (binding.type !== "secret_ref") return [];
|
||||
return [{ key, configPath: `env.${key}`, secretId: binding.secretId }];
|
||||
});
|
||||
if (secretRefs.length === 0) return [];
|
||||
const userSecretRefs = Object.entries(record).flatMap(([key, rawBinding]) => {
|
||||
if (!ENV_KEY_RE.test(key)) return [];
|
||||
const parsed = envBindingSchema.safeParse(rawBinding);
|
||||
if (!parsed.success) return [];
|
||||
const binding = canonicalizeBinding(parsed.data as EnvBinding);
|
||||
if (binding.type !== "user_secret_ref") return [];
|
||||
if (!binding.required || binding.allowMissingOverride) return [];
|
||||
return [{ key, configPath: `env.${key}`, binding }];
|
||||
});
|
||||
if (secretRefs.length === 0 && userSecretRefs.length === 0) return [];
|
||||
|
||||
const bindingChecks = await Promise.all(secretRefs.map(async (entry) => ({
|
||||
entry,
|
||||
@@ -3456,7 +3472,6 @@ export function secretService(db: Db) {
|
||||
const missingEntries = bindingChecks
|
||||
.filter((check) => !check.found)
|
||||
.map((check) => check.entry);
|
||||
if (missingEntries.length === 0) return [];
|
||||
|
||||
const secretRows = await Promise.all(
|
||||
[...new Set(missingEntries.map((entry) => entry.secretId))].map(async (secretId) => [
|
||||
@@ -3466,14 +3481,108 @@ export function secretService(db: Db) {
|
||||
);
|
||||
const secretsById = new Map(secretRows);
|
||||
|
||||
return missingEntries.map((entry) => ({
|
||||
const missingSecretBindings: MissingRuntimeBinding[] = missingEntries.map((entry) => ({
|
||||
consumerType: context.consumerType,
|
||||
consumerId: context.consumerId,
|
||||
configPath: entry.configPath,
|
||||
envKey: entry.key,
|
||||
bindingType: "secret_ref",
|
||||
secretId: entry.secretId,
|
||||
secretName: secretsById.get(entry.secretId)?.name ?? null,
|
||||
}));
|
||||
|
||||
const missingUserSecretBindings: MissingRuntimeBinding[] = [];
|
||||
for (const entry of userSecretRefs) {
|
||||
let definition: typeof userSecretDefinitions.$inferSelect | null = null;
|
||||
try {
|
||||
definition = await resolveUserSecretDefinition(companyId, { definitionKey: entry.binding.key });
|
||||
} catch {
|
||||
missingUserSecretBindings.push({
|
||||
consumerType: context.consumerType,
|
||||
consumerId: context.consumerId,
|
||||
configPath: entry.configPath,
|
||||
envKey: entry.key,
|
||||
bindingType: "user_secret_ref",
|
||||
secretId: null,
|
||||
secretName: null,
|
||||
userSecretDefinitionKey: entry.binding.key,
|
||||
responsibleUserId: context.responsibleUserId ?? null,
|
||||
errorCode: "user_secret_definition_missing",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const declaration = await db
|
||||
.select()
|
||||
.from(userSecretDeclarations)
|
||||
.where(and(
|
||||
eq(userSecretDeclarations.companyId, companyId),
|
||||
eq(userSecretDeclarations.userSecretDefinitionId, definition.id),
|
||||
eq(userSecretDeclarations.targetType, context.consumerType),
|
||||
eq(userSecretDeclarations.targetId, context.consumerId),
|
||||
eq(userSecretDeclarations.configPath, entry.configPath),
|
||||
))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!declaration) {
|
||||
missingUserSecretBindings.push({
|
||||
consumerType: context.consumerType,
|
||||
consumerId: context.consumerId,
|
||||
configPath: entry.configPath,
|
||||
envKey: entry.key,
|
||||
bindingType: "user_secret_ref",
|
||||
secretId: null,
|
||||
secretName: null,
|
||||
userSecretDefinitionId: definition.id,
|
||||
userSecretDefinitionKey: definition.key,
|
||||
userSecretDefinitionName: definition.name,
|
||||
responsibleUserId: context.responsibleUserId ?? null,
|
||||
errorCode: "binding_missing",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!context.responsibleUserId?.trim()) {
|
||||
missingUserSecretBindings.push({
|
||||
consumerType: context.consumerType,
|
||||
consumerId: context.consumerId,
|
||||
configPath: entry.configPath,
|
||||
envKey: entry.key,
|
||||
bindingType: "user_secret_ref",
|
||||
secretId: null,
|
||||
secretName: null,
|
||||
userSecretDefinitionId: definition.id,
|
||||
userSecretDefinitionKey: definition.key,
|
||||
userSecretDefinitionName: definition.name,
|
||||
responsibleUserId: null,
|
||||
errorCode: "responsible_user_missing",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const secret = await getUserSecretValue({
|
||||
companyId,
|
||||
ownerUserId: context.responsibleUserId,
|
||||
definitionId: definition.id,
|
||||
});
|
||||
if (!secret || secret.status !== "active") {
|
||||
missingUserSecretBindings.push({
|
||||
consumerType: context.consumerType,
|
||||
consumerId: context.consumerId,
|
||||
configPath: entry.configPath,
|
||||
envKey: entry.key,
|
||||
bindingType: "user_secret_ref",
|
||||
secretId: secret?.id ?? null,
|
||||
secretName: null,
|
||||
userSecretDefinitionId: definition.id,
|
||||
userSecretDefinitionKey: definition.key,
|
||||
userSecretDefinitionName: definition.name,
|
||||
responsibleUserId: context.responsibleUserId,
|
||||
errorCode: secret ? "secret_inactive" : "user_secret_missing",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [...missingSecretBindings, ...missingUserSecretBindings];
|
||||
},
|
||||
|
||||
collectMissingAdapterConfigRuntimeBindings: async (
|
||||
|
||||
Reference in new issue
Block a user