diff --git a/server/src/__tests__/heartbeat-project-env.test.ts b/server/src/__tests__/heartbeat-project-env.test.ts index 3fd5112a324..c311d064dad 100644 --- a/server/src/__tests__/heartbeat-project-env.test.ts +++ b/server/src/__tests__/heartbeat-project-env.test.ts @@ -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", diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 8064953df4d..97dd540c9fd 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -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(); diff --git a/server/src/__tests__/routines-service.test.ts b/server/src/__tests__/routines-service.test.ts index 183913f2273..15137f3b7c8 100644 --- a/server/src/__tests__/routines-service.test.ts +++ b/server/src/__tests__/routines-service.test.ts @@ -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({ diff --git a/server/src/__tests__/secrets-service.test.ts b/server/src/__tests__/secrets-service.test.ts index 071f2c0995c..14dbf2775e6 100644 --- a/server/src/__tests__/secrets-service.test.ts +++ b/server/src/__tests__/secrets-service.test.ts @@ -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", diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 8e0889c6190..11d82bd08f8 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -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, }); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index d1d9a59e721..ecfcd4971f6 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -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> | 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; + issueContext: Awaited> | null; + routineEnvContext: Awaited>; + }) { + 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, diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 733c3c9c20b..3d9bc1d1505 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -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, ) { @@ -366,6 +416,7 @@ type IssueCreateInput = Omit & { 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({ diff --git a/server/src/services/routines.ts b/server/src/services/routines.ts index d7ab92c517f..ce0ce389f27 100644 --- a/server/src/services/routines.ts +++ b/server/src/services/routines.ts @@ -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, diff --git a/server/src/services/secrets.ts b/server/src/services/secrets.ts index 6c413283bb1..99c4a12dae0 100644 --- a/server/src/services/secrets.ts +++ b/server/src/services/secrets.ts @@ -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 (