From d8869723abf2ce1e55164bcf924c65ee5aa96643 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 16 May 2026 19:53:09 -0500 Subject: [PATCH] Implement routine env runtime contract Co-Authored-By: Paperclip --- doc/DATABASE.md | 4 + doc/SPEC-implementation.md | 4 +- .../0086_routine_env_runtime_contract.sql | 8 ++ packages/db/src/migrations/meta/_journal.json | 7 ++ packages/db/src/schema/routines.ts | 5 +- packages/shared/src/index.ts | 1 + packages/shared/src/types/index.ts | 1 + packages/shared/src/types/routine.ts | 6 ++ packages/shared/src/validators/routine.ts | 3 + .../__tests__/heartbeat-project-env.test.ts | 69 ++++++++++---- server/src/__tests__/routines-service.test.ts | 71 ++++++++++++++ server/src/__tests__/secrets-service.test.ts | 45 +++++++++ server/src/services/heartbeat.ts | 95 ++++++++++++++++++- server/src/services/routines.ts | 69 +++++++++++++- 14 files changed, 360 insertions(+), 28 deletions(-) create mode 100644 packages/db/src/migrations/0086_routine_env_runtime_contract.sql diff --git a/doc/DATABASE.md b/doc/DATABASE.md index 2e0ad661358..887f8d9d99e 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -165,6 +165,10 @@ Paperclip stores secret metadata and versions in: - `company_secrets` - `company_secret_versions` +- `company_secret_bindings` +- `secret_access_events` + +Secret-aware env bindings are supported by agents, projects, and routines. Routine env lives in `routines.env`, is captured in `routine_revisions.snapshot`, and routine dispatches store `routine_runs.routine_revision_id` so runtime secret resolution uses the env snapshot that existed when the run was created. Routine secret refs bind with `target_type = 'routine'`, `target_id = routines.id`, and `config_path` values under `env.*`. For local/default installs, the active provider is `local_encrypted`: diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 0f7152f9456..7e2be87a33a 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -207,6 +207,8 @@ Invariant: - project env is merged into run environment for issues in that project and overrides conflicting agent env keys before Paperclip runtime-owned keys are injected +Routine execution issues add a routine-scoped env overlay after project env and before Paperclip runtime-owned keys. Routine env uses the same secret-aware binding format, is stored on `routines.env`, is snapshotted in routine revisions, and resolves secret refs against the routine binding target so routine-owned secrets do not require direct bindings on the executing agent. + ## 7.6 `issues` (core task entity) - `id` uuid pk @@ -400,7 +402,7 @@ The current implementation includes additional V1-control-plane tables beyond th - Issue structure and review: `issue_relations` for blockers, `labels`/`issue_labels`, `issue_thread_interactions`, `issue_approvals`, `issue_execution_decisions`, `issue_work_products`, `issue_inbox_archives`, `issue_read_states`, and issue reference mention indexes. - Execution and workspace control: `execution_workspaces`, `project_workspaces`, `workspace_runtime_services`, `workspace_operations`, `environments`, `environment_leases`, `agent_task_sessions`, `agent_runtime_state`, `agent_wakeup_requests`, heartbeat events, and watchdog decision tables. -- Plugins and routines: `plugins`, plugin config/state/entities/jobs/logs/webhooks, plugin database namespaces/migrations, plugin company settings, and `routines`. +- Plugins and routines: `plugins`, plugin config/state/entities/jobs/logs/webhooks, plugin database namespaces/migrations, plugin company settings, `routines`, `routine_revisions`, `routine_triggers`, and `routine_runs`. - Access and operations: company memberships, instance roles, principal permission grants, invites, join requests, board API keys, CLI auth challenges, budget policies/incidents, feedback exports/votes, company skills, sidebar preferences, and company logos. ## 8. State Machines diff --git a/packages/db/src/migrations/0086_routine_env_runtime_contract.sql b/packages/db/src/migrations/0086_routine_env_runtime_contract.sql new file mode 100644 index 00000000000..55228ccb106 --- /dev/null +++ b/packages/db/src/migrations/0086_routine_env_runtime_contract.sql @@ -0,0 +1,8 @@ +ALTER TABLE "routines" ADD COLUMN IF NOT EXISTS "env" jsonb;--> statement-breakpoint +ALTER TABLE "routine_runs" ADD COLUMN IF NOT EXISTS "routine_revision_id" uuid;--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'routine_runs_routine_revision_id_routine_revisions_id_fk') THEN + ALTER TABLE "routine_runs" ADD CONSTRAINT "routine_runs_routine_revision_id_routine_revisions_id_fk" FOREIGN KEY ("routine_revision_id") REFERENCES "public"."routine_revisions"("id") ON DELETE set null ON UPDATE no action; + END IF; +END $$;--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "routine_runs_revision_idx" ON "routine_runs" USING btree ("routine_revision_id"); diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 953d0a098a6..a47bae7c2d1 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -603,6 +603,13 @@ "when": 1778787362162, "tag": "0085_tranquil_the_executioner", "breakpoints": true + }, + { + "idx": 86, + "version": "7", + "when": 1778976000000, + "tag": "0086_routine_env_runtime_contract", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/routines.ts b/packages/db/src/schema/routines.ts index dfc48143d19..684d12d64af 100644 --- a/packages/db/src/schema/routines.ts +++ b/packages/db/src/schema/routines.ts @@ -17,7 +17,7 @@ import { issues } from "./issues.js"; import { projects } from "./projects.js"; import { goals } from "./goals.js"; import { heartbeatRuns } from "./heartbeat_runs.js"; -import type { RoutineRevisionSnapshotV1, RoutineVariable } from "@paperclipai/shared"; +import type { RoutineEnvConfig, RoutineRevisionSnapshotV1, RoutineVariable } from "@paperclipai/shared"; export const routines = pgTable( "routines", @@ -35,6 +35,7 @@ export const routines = pgTable( concurrencyPolicy: text("concurrency_policy").notNull().default("coalesce_if_active"), catchUpPolicy: text("catch_up_policy").notNull().default("skip_missed"), variables: jsonb("variables").$type().notNull().default([]), + env: jsonb("env").$type(), latestRevisionId: uuid("latest_revision_id"), latestRevisionNumber: integer("latest_revision_number").notNull().default(1), createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }), @@ -131,6 +132,7 @@ export const routineRuns = pgTable( source: text("source").notNull(), status: text("status").notNull().default("received"), triggeredAt: timestamp("triggered_at", { withTimezone: true }).notNull().defaultNow(), + routineRevisionId: uuid("routine_revision_id").references(() => routineRevisions.id, { onDelete: "set null" }), idempotencyKey: text("idempotency_key"), triggerPayload: jsonb("trigger_payload").$type>(), dispatchFingerprint: text("dispatch_fingerprint"), @@ -143,6 +145,7 @@ export const routineRuns = pgTable( }, (table) => ({ companyRoutineIdx: index("routine_runs_company_routine_idx").on(table.companyId, table.routineId, table.createdAt), + routineRevisionIdx: index("routine_runs_revision_idx").on(table.routineRevisionId), triggerIdx: index("routine_runs_trigger_idx").on(table.triggerId, table.createdAt), dispatchFingerprintIdx: index("routine_runs_dispatch_fingerprint_idx").on(table.routineId, table.dispatchFingerprint), linkedIssueIdx: index("routine_runs_linked_issue_idx").on(table.linkedIssueId), diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 8dd130c268e..88b12dccfc8 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -576,6 +576,7 @@ export type { SecretVersionSelector, SecretVersionStatus, Routine, + RoutineEnvConfig, RoutineManagedByPlugin, RoutineVariable, RoutineVariableDefaultValue, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 9cb46a2c3ca..a23547572a3 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -279,6 +279,7 @@ export type { } from "./secrets.js"; export type { Routine, + RoutineEnvConfig, RoutineManagedByPlugin, RoutineVariable, RoutineVariableDefaultValue, diff --git a/packages/shared/src/types/routine.ts b/packages/shared/src/types/routine.ts index c6f866dd43d..3e87715300b 100644 --- a/packages/shared/src/types/routine.ts +++ b/packages/shared/src/types/routine.ts @@ -8,6 +8,7 @@ import type { RoutineTriggerSigningMode, RoutineVariableType, } from "../constants.js"; +import type { EnvBinding } from "./secrets.js"; export interface RoutineProjectSummary { id: string; @@ -45,6 +46,8 @@ export interface RoutineVariable { options: string[]; } +export type RoutineEnvConfig = Record; + export interface Routine { id: string; companyId: string; @@ -59,6 +62,7 @@ export interface Routine { concurrencyPolicy: string; catchUpPolicy: string; variables: RoutineVariable[]; + env?: RoutineEnvConfig | null; latestRevisionId: string | null; latestRevisionNumber: number; createdByAgentId: string | null; @@ -98,6 +102,7 @@ export interface RoutineRevisionSnapshotRoutineV1 { concurrencyPolicy: RoutineConcurrencyPolicy; catchUpPolicy: RoutineCatchUpPolicy; variables: RoutineVariable[]; + env: RoutineEnvConfig | null; } export interface RoutineRevisionSnapshotTriggerV1 { @@ -169,6 +174,7 @@ export interface RoutineRun { source: string; status: string; triggeredAt: Date; + routineRevisionId?: string | null; idempotencyKey: string | null; triggerPayload: Record | null; dispatchFingerprint: string | null; diff --git a/packages/shared/src/validators/routine.ts b/packages/shared/src/validators/routine.ts index c3ae1b8aef3..9f2e2b052f0 100644 --- a/packages/shared/src/validators/routine.ts +++ b/packages/shared/src/validators/routine.ts @@ -12,6 +12,7 @@ import { ISSUE_EXECUTION_WORKSPACE_PREFERENCES, issueExecutionWorkspaceSettingsSchema, } from "./issue.js"; +import { envConfigSchema } from "./secret.js"; const routineVariableValueSchema = z.union([z.string(), z.number().finite(), z.boolean()]); @@ -60,6 +61,7 @@ export const createRoutineSchema = z.object({ concurrencyPolicy: z.enum(ROUTINE_CONCURRENCY_POLICIES).optional().default("coalesce_if_active"), catchUpPolicy: z.enum(ROUTINE_CATCH_UP_POLICIES).optional().default("skip_missed"), variables: z.array(routineVariableSchema).optional().default([]), + env: envConfigSchema.optional().nullable(), }); export type CreateRoutine = z.infer; @@ -83,6 +85,7 @@ export const routineRevisionSnapshotRoutineV1Schema = z.object({ concurrencyPolicy: z.enum(ROUTINE_CONCURRENCY_POLICIES), catchUpPolicy: z.enum(ROUTINE_CATCH_UP_POLICIES), variables: z.array(routineVariableSchema), + env: envConfigSchema.nullable(), }).strict(); export const routineRevisionSnapshotTriggerV1Schema = z.object({ diff --git a/server/src/__tests__/heartbeat-project-env.test.ts b/server/src/__tests__/heartbeat-project-env.test.ts index 55653be3692..707f87f63ce 100644 --- a/server/src/__tests__/heartbeat-project-env.test.ts +++ b/server/src/__tests__/heartbeat-project-env.test.ts @@ -7,7 +7,7 @@ import { } from "../services/heartbeat.ts"; describe("resolveExecutionRunAdapterConfig", () => { - it("overlays project env on top of agent env and unions secret keys", async () => { + it("overlays project and routine env on top of agent env and unions secret keys", async () => { const resolveAdapterConfigForRuntime = vi.fn().mockResolvedValue({ config: { env: { @@ -29,29 +29,51 @@ describe("resolveExecutionRunAdapterConfig", () => { }, ], }); - const resolveEnvBindings = vi.fn().mockResolvedValue({ - env: { - SHARED_KEY: "project", - PROJECT_ONLY: "project-only", - }, - secretKeys: new Set(["PROJECT_SECRET"]), - manifest: [ - { - configPath: "env.PROJECT_SECRET", - envKey: "PROJECT_SECRET", - secretId: "secret-project", - secretKey: "project-secret", - version: 1, - provider: "local_encrypted", - outcome: "success", + const resolveEnvBindings = vi + .fn() + .mockResolvedValueOnce({ + env: { + SHARED_KEY: "project", + PROJECT_ONLY: "project-only", }, - ], - }); + secretKeys: new Set(["PROJECT_SECRET"]), + manifest: [ + { + configPath: "env.PROJECT_SECRET", + envKey: "PROJECT_SECRET", + secretId: "secret-project", + secretKey: "project-secret", + version: 1, + provider: "local_encrypted", + outcome: "success", + }, + ], + }) + .mockResolvedValueOnce({ + env: { + SHARED_KEY: "routine", + ROUTINE_ONLY: "routine-only", + }, + secretKeys: new Set(["ROUTINE_SECRET"]), + manifest: [ + { + configPath: "env.ROUTINE_SECRET", + envKey: "ROUTINE_SECRET", + secretId: "secret-routine", + secretKey: "routine-secret", + version: 1, + provider: "local_encrypted", + outcome: "success", + }, + ], + }); const result = await resolveExecutionRunAdapterConfig({ companyId: "company-1", executionRunConfig: { env: { SHARED_KEY: "agent" } }, projectEnv: { SHARED_KEY: "project" }, + routineEnv: { SHARED_KEY: "routine" }, + routineId: "routine-1", secretsSvc: { resolveAdapterConfigForRuntime, resolveEnvBindings, @@ -61,18 +83,25 @@ describe("resolveExecutionRunAdapterConfig", () => { expect(result.resolvedConfig).toMatchObject({ other: "value", env: { - SHARED_KEY: "project", + SHARED_KEY: "routine", AGENT_ONLY: "agent-only", PROJECT_ONLY: "project-only", + ROUTINE_ONLY: "routine-only", }, }); - expect(Array.from(result.secretKeys).sort()).toEqual(["AGENT_SECRET", "PROJECT_SECRET"]); + expect(Array.from(result.secretKeys).sort()).toEqual(["AGENT_SECRET", "PROJECT_SECRET", "ROUTINE_SECRET"]); expect(result.secretManifest.map((entry) => entry.secretId).sort()).toEqual([ "secret-agent", "secret-project", + "secret-routine", ]); expect(JSON.stringify(result.secretManifest)).not.toContain("agent-only"); expect(JSON.stringify(result.secretManifest)).not.toContain("project-only"); + expect(JSON.stringify(result.secretManifest)).not.toContain("routine-only"); + expect(resolveEnvBindings.mock.calls[1]?.[2]).toMatchObject({ + consumerType: "routine", + consumerId: "routine-1", + }); }); it("skips project env resolution when the project has no bindings", async () => { diff --git a/server/src/__tests__/routines-service.test.ts b/server/src/__tests__/routines-service.test.ts index 70fe9d0500f..f7898ac9053 100644 --- a/server/src/__tests__/routines-service.test.ts +++ b/server/src/__tests__/routines-service.test.ts @@ -5,6 +5,7 @@ import { activityLog, agents, companies, + companySecretBindings, companySecrets, companySecretVersions, createDb, @@ -19,6 +20,7 @@ import { routineRuns, routines, routineTriggers, + secretAccessEvents, } from "@paperclipai/db"; import { getEmbeddedPostgresTestSupport, @@ -28,6 +30,7 @@ import { issueService } from "../services/issues.ts"; import { instanceSettingsService } from "../services/instance-settings.ts"; import * as providerRegistry from "../secrets/provider-registry.ts"; import { routineService } from "../services/routines.ts"; +import { secretService } from "../services/secrets.ts"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -57,6 +60,8 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { await db.delete(activityLog); await db.delete(issueInboxArchives); await db.delete(issueReadStates); + await db.delete(secretAccessEvents); + await db.delete(companySecretBindings); await db.delete(routineRuns); await db.delete(routineTriggers); await db.delete(routines); @@ -331,6 +336,72 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { expect(revisions[1]?.snapshot.routine.description).toBe("Run the frog routine"); }); + it("stores routine env in revisions, syncs routine secret bindings, and stamps runs with the dispatch revision", async () => { + const { agentId, companyId, projectId, svc } = await seedFixture(); + const secrets = secretService(db); + const secret = await secrets.create(companyId, { + name: `routine-api-${randomUUID()}`, + provider: "local_encrypted", + value: "secret-value", + }); + + const routine = await svc.create( + companyId, + { + projectId, + goalId: null, + parentIssueId: null, + title: "secret routine", + description: null, + assigneeAgentId: agentId, + priority: "medium", + status: "active", + concurrencyPolicy: "always_enqueue", + catchUpPolicy: "skip_missed", + env: { + ROUTINE_API_KEY: { type: "secret_ref", secretId: secret.id, version: "latest" }, + ROUTINE_PLAIN: { type: "plain", value: "plain-value" }, + }, + }, + {}, + ); + + const bindings = await db + .select() + .from(companySecretBindings) + .where(eq(companySecretBindings.targetId, routine.id)); + expect(bindings).toMatchObject([ + { + companyId, + secretId: secret.id, + targetType: "routine", + configPath: "env.ROUTINE_API_KEY", + }, + ]); + + const [initialRevision] = await svc.listRevisions(routine.id); + expect(initialRevision?.snapshot.routine.env).toEqual(routine.env); + + const runBefore = await svc.runRoutine(routine.id, { source: "manual" }); + expect(runBefore.routineRevisionId).toBe(routine.latestRevisionId); + + const updated = await svc.update( + routine.id, + { + env: { + ROUTINE_API_KEY: { type: "secret_ref", secretId: secret.id, version: "latest" }, + ROUTINE_PLAIN: { type: "plain", value: "changed" }, + }, + }, + {}, + ); + expect(updated?.latestRevisionNumber).toBe(routine.latestRevisionNumber + 1); + + const runAfter = await svc.runRoutine(routine.id, { source: "manual" }); + expect(runAfter.routineRevisionId).toBe(updated?.latestRevisionId); + expect(runAfter.dispatchFingerprint).not.toBe(runBefore.dispatchFingerprint); + }); + it("rejects stale routine baseRevisionId updates", async () => { const { routine, svc } = await seedFixture(); const updated = await svc.update(routine.id, { description: "new description" }, {}); diff --git a/server/src/__tests__/secrets-service.test.ts b/server/src/__tests__/secrets-service.test.ts index 01f13041650..de4e11e54d5 100644 --- a/server/src/__tests__/secrets-service.test.ts +++ b/server/src/__tests__/secrets-service.test.ts @@ -205,6 +205,51 @@ describeEmbeddedPostgres("secretService", () => { expect(JSON.stringify(events)).not.toContain("runtime-secret"); }); + it("resolves routine env secret refs through routine bindings and records value-free access metadata", async () => { + const companyId = await seedCompany(); + const svc = secretService(db); + const secret = await svc.create(companyId, { + name: `routine-secret-${randomUUID()}`, + provider: "local_encrypted", + value: "routine-super-secret", + }); + const env = { + ROUTINE_API_KEY: { type: "secret_ref" as const, secretId: secret.id, version: "latest" as const }, + }; + await svc.syncEnvBindingsForTarget(companyId, { targetType: "routine", targetId: "routine-1" }, env); + + const resolved = await svc.resolveEnvBindings(companyId, env, { + consumerType: "routine", + consumerId: "routine-1", + actorType: "agent", + actorId: "agent-1", + }); + + expect(resolved.env.ROUTINE_API_KEY).toBe("routine-super-secret"); + expect(resolved.manifest).toEqual([ + expect.objectContaining({ + configPath: "env.ROUTINE_API_KEY", + envKey: "ROUTINE_API_KEY", + secretId: secret.id, + outcome: "success", + }), + ]); + + const events = await svc.listAccessEvents(companyId, secret.id); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + companyId, + secretId: secret.id, + consumerType: "routine", + consumerId: "routine-1", + configPath: "env.ROUTINE_API_KEY", + actorType: "agent", + actorId: "agent-1", + outcome: "success", + }); + expect(JSON.stringify(events)).not.toContain("routine-super-secret"); + }); + it("scopes env binding sync deletes to the env path prefix", async () => { const companyId = await seedCompany(); const svc = secretService(db); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 248a51edbd3..be7aeba5525 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -18,6 +18,7 @@ import { type IssueExecutionMonitorPolicy, type IssueExecutionMonitorRecoveryPolicy, type ModelProfileKey, + type RoutineRevisionSnapshotV1, type RunLivenessState, } from "@paperclipai/shared"; import { @@ -40,6 +41,9 @@ import { issueWorkProducts, projects, projectWorkspaces, + routineRevisions, + routineRuns, + routines, workspaceOperations, } from "@paperclipai/db"; import { conflict, HttpError, notFound } from "../errors.js"; @@ -333,8 +337,10 @@ export async function resolveExecutionRunAdapterConfig(input: { issueId?: string | null; heartbeatRunId?: string | null; projectId?: string | null; + routineId?: string | null; executionRunConfig: Record; projectEnv: unknown; + routineEnv?: unknown; secretsSvc: RuntimeConfigSecretResolver; }) { const { config: resolvedConfig, secretKeys, manifest } = await input.secretsSvc.resolveAdapterConfigForRuntime( @@ -376,10 +382,39 @@ export async function resolveExecutionRunAdapterConfig(input: { secretKeys.add(key); } } + const routineEnvResolution = input.routineEnv + ? await input.secretsSvc.resolveEnvBindings( + input.companyId, + input.routineEnv, + input.routineId + ? { + consumerType: "routine", + consumerId: input.routineId, + actorType: "agent", + actorId: input.agentId ?? null, + issueId: input.issueId ?? null, + heartbeatRunId: input.heartbeatRunId ?? null, + } + : undefined, + ) + : { env: {}, secretKeys: new Set(), manifest: [] }; + if (Object.keys(routineEnvResolution.env).length > 0) { + resolvedConfig.env = { + ...parseObject(resolvedConfig.env), + ...routineEnvResolution.env, + }; + for (const key of routineEnvResolution.secretKeys) { + secretKeys.add(key); + } + } return { resolvedConfig, secretKeys, - secretManifest: [...(manifest ?? []), ...(projectEnvResolution.manifest ?? [])], + secretManifest: [ + ...(manifest ?? []), + ...(projectEnvResolution.manifest ?? []), + ...(routineEnvResolution.manifest ?? []), + ], }; } @@ -2433,12 +2468,67 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) assigneeAgentId: issues.assigneeAgentId, assigneeAdapterOverrides: issues.assigneeAdapterOverrides, executionWorkspaceSettings: issues.executionWorkspaceSettings, + originKind: issues.originKind, + originId: issues.originId, + originRunId: issues.originRunId, }) .from(issues) .where(and(eq(issues.id, issueId), eq(issues.companyId, companyId))) .then((rows) => rows[0] ?? null); } + async function getRoutineEnvForExecutionIssue( + companyId: string, + issueContext: Awaited> | null, + ) { + if (!issueContext || issueContext.originKind !== "routine_execution" || !issueContext.originId) { + return { routineId: null, env: null }; + } + + const routineRun = issueContext.originRunId + ? await db + .select({ + routineRevisionId: routineRuns.routineRevisionId, + }) + .from(routineRuns) + .where( + and( + eq(routineRuns.id, issueContext.originRunId), + eq(routineRuns.companyId, companyId), + eq(routineRuns.routineId, issueContext.originId), + ), + ) + .then((rows) => rows[0] ?? null) + : null; + + if (routineRun?.routineRevisionId) { + const revision = await db + .select({ + snapshot: routineRevisions.snapshot, + }) + .from(routineRevisions) + .where( + and( + eq(routineRevisions.id, routineRun.routineRevisionId), + eq(routineRevisions.companyId, companyId), + eq(routineRevisions.routineId, issueContext.originId), + ), + ) + .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 }; + } + } + + const routine = await db + .select({ env: routines.env }) + .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 }; + } + async function getRuntimeState(agentId: string) { return db .select() @@ -6870,6 +6960,7 @@ 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 routineEnvContext = await getRoutineEnvForExecutionIssue(agent.companyId, issueContext); const projectExecutionWorkspacePolicy = gateProjectExecutionWorkspacePolicy( parseProjectExecutionWorkspacePolicy(projectContext?.executionWorkspacePolicy), isolatedWorkspacesEnabled, @@ -7074,8 +7165,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) issueId, heartbeatRunId: run.id, projectId: projectContext?.id ?? null, + routineId: routineEnvContext.routineId, executionRunConfig, projectEnv: projectContext?.env ?? null, + routineEnv: routineEnvContext.env, secretsSvc, }); if (secretManifest.length > 0) { diff --git a/server/src/services/routines.ts b/server/src/services/routines.ts index b280119ac64..b420ea51809 100644 --- a/server/src/services/routines.ts +++ b/server/src/services/routines.ts @@ -366,6 +366,8 @@ function createRoutineDispatchFingerprint(input: { payload: Record | null; projectId: string | null; assigneeAgentId: string | null; + routineRevisionId: string | null; + routineEnvFingerprint: string | null; executionWorkspaceId?: string | null; executionWorkspacePreference?: string | null; executionWorkspaceSettings?: Record | null; @@ -376,6 +378,11 @@ function createRoutineDispatchFingerprint(input: { return crypto.createHash("sha256").update(canonical).digest("hex"); } +function createRoutineEnvFingerprint(env: unknown) { + const canonical = JSON.stringify(normalizeRoutineDispatchFingerprintValue(env ?? null)); + return crypto.createHash("sha256").update(canonical).digest("hex"); +} + function readManagedRoutineIssueTemplate(defaultsJson: Record | null | undefined) { const value = defaultsJson?.issueTemplate; if (!isPlainRecord(value)) return null; @@ -406,6 +413,7 @@ function routineRevisionSnapshotRoutine(routine: RoutineRow): RoutineRevisionSna concurrencyPolicy: routine.concurrencyPolicy as RoutineRevisionSnapshotV1["routine"]["concurrencyPolicy"], catchUpPolicy: routine.catchUpPolicy as RoutineRevisionSnapshotV1["routine"]["catchUpPolicy"], variables: routine.variables ?? [], + env: routine.env ?? null, }; } @@ -686,6 +694,7 @@ export function routineService( idempotencyKey: routineRuns.idempotencyKey, triggerPayload: routineRuns.triggerPayload, dispatchFingerprint: routineRuns.dispatchFingerprint, + routineRevisionId: routineRuns.routineRevisionId, linkedIssueId: routineRuns.linkedIssueId, coalescedIntoRunId: routineRuns.coalescedIntoRunId, failureReason: routineRuns.failureReason, @@ -719,6 +728,7 @@ export function routineService( idempotencyKey: row.idempotencyKey, triggerPayload: row.triggerPayload as Record | null, dispatchFingerprint: row.dispatchFingerprint, + routineRevisionId: row.routineRevisionId, linkedIssueId: row.linkedIssueId, coalescedIntoRunId: row.coalescedIntoRunId, failureReason: row.failureReason, @@ -1138,6 +1148,8 @@ export function routineService( payload: triggerPayload, projectId, assigneeAgentId, + routineRevisionId: input.routine.latestRevisionId, + routineEnvFingerprint: createRoutineEnvFingerprint(input.routine.env), executionWorkspaceId: input.executionWorkspaceId ?? null, executionWorkspacePreference: input.executionWorkspacePreference ?? null, executionWorkspaceSettings: input.executionWorkspaceSettings ?? null, @@ -1183,6 +1195,7 @@ export function routineService( idempotencyKey: input.idempotencyKey ?? null, triggerPayload, dispatchFingerprint, + routineRevisionId: input.routine.latestRevisionId, }) .returning(); @@ -1430,6 +1443,7 @@ export function routineService( idempotencyKey: routineRuns.idempotencyKey, triggerPayload: routineRuns.triggerPayload, dispatchFingerprint: routineRuns.dispatchFingerprint, + routineRevisionId: routineRuns.routineRevisionId, linkedIssueId: routineRuns.linkedIssueId, coalescedIntoRunId: routineRuns.coalescedIntoRunId, failureReason: routineRuns.failureReason, @@ -1462,6 +1476,7 @@ export function routineService( idempotencyKey: run.idempotencyKey, triggerPayload: run.triggerPayload as Record | null, dispatchFingerprint: run.dispatchFingerprint, + routineRevisionId: run.routineRevisionId, linkedIssueId: run.linkedIssueId, coalescedIntoRunId: run.coalescedIntoRunId, failureReason: run.failureReason, @@ -1508,13 +1523,19 @@ export function routineService( await assertAssignableAgent(companyId, input.assigneeAgentId ?? null); if (input.goalId) await assertGoal(companyId, input.goalId); if (input.parentIssueId) await assertParentIssue(companyId, input.parentIssueId); + const env = input.env === undefined || input.env === null + ? null + : await secretsSvc.normalizeEnvBindingsForPersistence(companyId, input.env, { + strictMode: process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true", + fieldPath: "env", + }); const variables = syncRoutineVariablesWithTemplate( [input.title, input.description], sanitizeRoutineVariableInputs(input.variables), ); assertRoutineVariableDefinitions(variables); const status = normalizeDraftRoutineStatus(input.status, input.assigneeAgentId); - return db.transaction(async (tx) => { + const createdRoutine = await db.transaction(async (tx) => { const txDb = tx as unknown as Db; const [created] = await txDb .insert(routines) @@ -1531,6 +1552,7 @@ export function routineService( concurrencyPolicy: input.concurrencyPolicy, catchUpPolicy: input.catchUpPolicy, variables, + env, createdByAgentId: actor.agentId ?? null, createdByUserId: actor.userId ?? null, updatedByAgentId: actor.agentId ?? null, @@ -1542,6 +1564,14 @@ export function routineService( }); return routine; }); + if (env) { + await secretsSvc.syncEnvBindingsForTarget( + companyId, + { targetType: "routine", targetId: createdRoutine.id }, + env, + ); + } + return createdRoutine; }, update: async (id: string, patch: UpdateRoutine, actor: Actor): Promise => { @@ -1551,6 +1581,14 @@ export function routineService( const nextAssigneeAgentId = patch.assigneeAgentId === undefined ? existing.assigneeAgentId : patch.assigneeAgentId; const nextTitle = patch.title ?? existing.title; const nextDescription = patch.description === undefined ? existing.description : patch.description; + const nextEnv = patch.env === undefined + ? existing.env + : patch.env === null + ? null + : await secretsSvc.normalizeEnvBindingsForPersistence(existing.companyId, patch.env, { + strictMode: process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true", + fieldPath: "env", + }); const requestedStatus = patch.status ?? existing.status; if (patch.status === "active") { assertRoutineCanEnable(patch.status, nextAssigneeAgentId); @@ -1582,7 +1620,7 @@ export function routineService( if (enabledScheduleTriggers) { assertScheduleCompatibleVariables(nextVariables); } - return db.transaction(async (tx) => { + const updatedRoutine = await db.transaction(async (tx) => { const txDb = tx as unknown as Db; await tx.execute(sql`select id from ${routines} where ${routines.id} = ${id} for update`); const locked = await txDb @@ -1611,6 +1649,7 @@ export function routineService( concurrencyPolicy: patch.concurrencyPolicy ?? locked.concurrencyPolicy, catchUpPolicy: patch.catchUpPolicy ?? locked.catchUpPolicy, variables: nextVariables, + env: nextEnv, updatedByAgentId: actor.agentId ?? null, updatedByUserId: actor.userId ?? null, }; @@ -1651,6 +1690,7 @@ export function routineService( concurrencyPolicy: candidate.concurrencyPolicy, catchUpPolicy: candidate.catchUpPolicy, variables: candidate.variables, + env: candidate.env, updatedByAgentId: actor.agentId ?? null, updatedByUserId: actor.userId ?? null, updatedAt: new Date(), @@ -1663,6 +1703,14 @@ export function routineService( }); return routine; }); + if (updatedRoutine && patch.env !== undefined) { + await secretsSvc.syncEnvBindingsForTarget( + updatedRoutine.companyId, + { targetType: "routine", targetId: updatedRoutine.id }, + updatedRoutine.env, + ); + } + return updatedRoutine; }, createTrigger: async ( @@ -1770,7 +1818,7 @@ export function routineService( } } - return db.transaction(async (tx) => { + const result = await db.transaction(async (tx) => { const txDb = tx as unknown as Db; await tx.execute(sql`select id from ${routines} where ${routines.id} = ${existing.routineId} for update`); const [updated] = await txDb @@ -1801,12 +1849,13 @@ export function routineService( }); return { trigger: updated as RoutineTrigger, revision: appended.revision }; }); + return result; }, deleteTrigger: async (id: string, actor: Actor = {}): Promise<{ deleted: boolean; revision: RoutineRevision | null }> => { const existing = await getTriggerById(id); if (!existing) return { deleted: false, revision: null }; - return db.transaction(async (tx) => { + const result = await db.transaction(async (tx) => { const txDb = tx as unknown as Db; await tx.execute(sql`select id from ${routines} where ${routines.id} = ${existing.routineId} for update`); await txDb.delete(routineTriggers).where(eq(routineTriggers.id, id)); @@ -1821,6 +1870,7 @@ export function routineService( }); return { deleted: true, revision: appended.revision }; }); + return result; }, rotateTriggerSecret: async ( @@ -1912,7 +1962,7 @@ export function routineService( const routineSnapshot = snapshot.routine; await assertRestorableAssignee(existingRoutine.companyId, routineSnapshot.assigneeAgentId, actor); - return db.transaction(async (tx) => { + const result = await db.transaction(async (tx) => { const txDb = tx as unknown as Db; await tx.execute(sql`select id from ${routines} where ${routines.id} = ${existingRoutine.id} for update`); const locked = await txDb @@ -1964,6 +2014,7 @@ export function routineService( concurrencyPolicy: routineSnapshot.concurrencyPolicy, catchUpPolicy: routineSnapshot.catchUpPolicy, variables: routineSnapshot.variables, + env: routineSnapshot.env, updatedByAgentId: actor.agentId ?? null, updatedByUserId: actor.userId ?? null, updatedAt: now, @@ -2041,6 +2092,12 @@ export function routineService( secretMaterials: [...recreatedWebhookSecrets.values()].map((entry) => entry.secretMaterial), }; }); + await secretsSvc.syncEnvBindingsForTarget( + existingRoutine.companyId, + { targetType: "routine", targetId: existingRoutine.id }, + routineSnapshot.env, + ); + return result; }, runRoutine: async (id: string, input: RunRoutine, actor?: Actor) => { @@ -2172,6 +2229,7 @@ export function routineService( idempotencyKey: routineRuns.idempotencyKey, triggerPayload: routineRuns.triggerPayload, dispatchFingerprint: routineRuns.dispatchFingerprint, + routineRevisionId: routineRuns.routineRevisionId, linkedIssueId: routineRuns.linkedIssueId, coalescedIntoRunId: routineRuns.coalescedIntoRunId, failureReason: routineRuns.failureReason, @@ -2204,6 +2262,7 @@ export function routineService( idempotencyKey: row.idempotencyKey, triggerPayload: row.triggerPayload as Record | null, dispatchFingerprint: row.dispatchFingerprint, + routineRevisionId: row.routineRevisionId, linkedIssueId: row.linkedIssueId, coalescedIntoRunId: row.coalescedIntoRunId, failureReason: row.failureReason,