diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 74b55c61295..d31d6287528 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1141,6 +1141,9 @@ export { updateRoutineTriggerSchema, routineVariableSchema, runRoutineSchema, + runRoutineIntakeFormSchema, + pipelineStageConfigSchema, + pipelineStageVariableSchema, rotateRoutineTriggerSecretSchema, routineRevisionSnapshotRoutineV1Schema, routineRevisionSnapshotTriggerV1Schema, @@ -1160,6 +1163,9 @@ export { type CreateRoutineTrigger, type UpdateRoutineTrigger, type RunRoutine, + type RunRoutineIntakeForm, + type PipelineStageConfig, + type PipelineStageVariable, type RotateRoutineTriggerSecret, createCostEventSchema, createFinanceEventSchema, diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 683950de77d..f97cdd06cc3 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -437,12 +437,14 @@ export { routineRevisionSnapshotV1Schema, routineRevisionSnapshotSchema, runRoutineSchema, + runRoutineIntakeFormSchema, rotateRoutineTriggerSecretSchema, type CreateRoutine, type UpdateRoutine, type CreateRoutineTrigger, type UpdateRoutineTrigger, type RunRoutine, + type RunRoutineIntakeForm, type RotateRoutineTriggerSecret, } from "./routine.js"; @@ -463,6 +465,13 @@ export { type CreateAssetImageMetadata, } from "./asset.js"; +export { + pipelineStageConfigSchema, + pipelineStageVariableSchema, + type PipelineStageConfig, + type PipelineStageVariable, +} from "./pipeline.js"; + export { createCompanyInviteSchema, createOpenClawInvitePromptSchema, diff --git a/packages/shared/src/validators/pipeline.test.ts b/packages/shared/src/validators/pipeline.test.ts new file mode 100644 index 00000000000..d5718d5ad7f --- /dev/null +++ b/packages/shared/src/validators/pipeline.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { pipelineStageConfigSchema } from "./pipeline.js"; + +describe("pipeline stage variable schema", () => { + it("validates select variables require options", () => { + expect( + pipelineStageConfigSchema.safeParse({ + variables: [{ key: "status", type: "select", options: ["open", "done"] }], + }).success, + ).toBe(true); + + expect( + pipelineStageConfigSchema.safeParse({ + variables: [{ key: "status", type: "select", options: [] }], + }).success, + ).toBe(false); + }); + + it("enforces unique variable keys in stage config", () => { + expect( + pipelineStageConfigSchema.safeParse({ + variables: [ + { key: "repo", type: "text" }, + { key: "repo", type: "text" }, + ], + }).success, + ).toBe(false); + }); +}); diff --git a/packages/shared/src/validators/pipeline.ts b/packages/shared/src/validators/pipeline.ts new file mode 100644 index 00000000000..347b2a8dd44 --- /dev/null +++ b/packages/shared/src/validators/pipeline.ts @@ -0,0 +1,46 @@ +import { z } from "zod"; + +const routineVariableLikeNameSchema = z.string().trim().regex(/^[A-Za-z][A-Za-z0-9_]*$/); + +export const pipelineStageVariableSchema = z.object({ + key: routineVariableLikeNameSchema, + label: z.string().trim().max(120), + type: z.enum(["select", "text", "multiline"]).default("text"), + options: z.array(z.string().trim().min(1).max(120)).max(50).optional().default([]), + required: z.boolean().optional().default(false), + showInAddForm: z.boolean().optional().default(false), +}).superRefine((value, ctx) => { + if (value.type === "select" && value.options.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["options"], + message: "Select variables require at least one option", + }); + } + if (value.type !== "select" && value.options.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["options"], + message: "Only select variables can define options", + }); + } +}); + +export const pipelineStageConfigSchema = z.object({ + variables: z.array(pipelineStageVariableSchema).default([]), +}).passthrough().superRefine((value, ctx) => { + const keys = new Set(); + value.variables.forEach((variable, index) => { + if (keys.has(variable.key)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["variables", index, "key"], + message: "Pipeline stage variable keys must be unique", + }); + } + keys.add(variable.key); + }); +}); + +export type PipelineStageVariable = z.infer; +export type PipelineStageConfig = z.infer; diff --git a/packages/shared/src/validators/routine.ts b/packages/shared/src/validators/routine.ts index 2db48fbbf58..c0d1fafbc33 100644 --- a/packages/shared/src/validators/routine.ts +++ b/packages/shared/src/validators/routine.ts @@ -148,6 +148,7 @@ export const runRoutineSchema = z.object({ triggerId: z.string().uuid().optional().nullable(), payload: z.record(z.string(), z.unknown()).optional().nullable(), variables: z.record(z.string(), routineVariableValueSchema).optional().nullable(), + caseFields: z.record(z.string(), routineVariableValueSchema).optional().nullable(), projectId: z.string().uuid().optional().nullable(), assigneeAgentId: z.string().uuid().optional().nullable(), idempotencyKey: z.string().trim().max(255).optional().nullable(), @@ -157,7 +158,14 @@ export const runRoutineSchema = z.object({ executionWorkspaceSettings: issueExecutionWorkspaceSettingsSchema.optional().nullable(), }); +export const runRoutineIntakeFormSchema = runRoutineSchema + .omit({ + source: true, + }) + .passthrough(); + export type RunRoutine = z.infer; +export type RunRoutineIntakeForm = z.infer; export const rotateRoutineTriggerSecretSchema = z.object({}); export type RotateRoutineTriggerSecret = z.infer; diff --git a/server/src/__tests__/routines-routes.test.ts b/server/src/__tests__/routines-routes.test.ts index d541f0cf909..89b7b91f430 100644 --- a/server/src/__tests__/routines-routes.test.ts +++ b/server/src/__tests__/routines-routes.test.ts @@ -414,6 +414,76 @@ describe("routine routes", () => { expect(mockRoutineService.runRoutine).not.toHaveBeenCalled(); }); + it("requires tasks:assign permission to run a routine from an intake form", async () => { + const app = await createApp({ + type: "board", + userId: "board-user", + source: "session", + isInstanceAdmin: false, + companyIds: [companyId], + }); + + const res = await request(app) + .post(`/api/routines/${routineId}/intake-form`) + .send({ + userName: "Ada", + amount: 12, + }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("tasks:assign"); + expect(mockRoutineService.runRoutine).not.toHaveBeenCalled(); + }); + + it("runs a routine from an intake form by mapping freeform fields to variables", async () => { + mockAccessService.canUser.mockResolvedValue(true); + const app = await createApp({ + type: "board", + userId: "board-user", + source: "session", + isInstanceAdmin: false, + companyIds: [companyId], + }); + + mockRoutineService.runRoutine.mockResolvedValueOnce({ + id: "run-1", + source: "api", + status: "issue_created", + }); + + const res = await request(app) + .post(`/api/routines/${routineId}/intake-form`) + .send({ + projectId, + payload: { + source: "landing", + }, + userName: "Ada", + priority: "high", + }); + + expect(res.status).toBe(202); + expect(mockRoutineService.runRoutine).toHaveBeenCalledWith(routineId, { + source: "api", + projectId, + payload: { + source: "landing", + }, + variables: { + userName: "Ada", + priority: "high", + }, + }, { + agentId: null, + userId: "board-user", + }); + expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + action: "routine.run_triggered", + entityId: "run-1", + details: { source: "api", status: "issue_created" }, + })); + }); + it("passes the board actor through when manually running a routine", async () => { mockAccessService.canUser.mockResolvedValue(true); const app = await createApp({ diff --git a/server/src/__tests__/routines-service.test.ts b/server/src/__tests__/routines-service.test.ts index 22756ff9727..cf5457e97ea 100644 --- a/server/src/__tests__/routines-service.test.ts +++ b/server/src/__tests__/routines-service.test.ts @@ -1047,6 +1047,58 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { }); }); + it("does not fail when automation case fields are missing required variables and records interpolation warnings", async () => { + const { companyId, agentId, projectId, svc } = await seedFixture(); + const variableRoutine = await svc.create( + companyId, + { + projectId, + goalId: null, + parentIssueId: null, + title: "repo triage for {{repo}} by {{owner}}", + description: "Notify {{owner}} about {{repo}}", + assigneeAgentId: agentId, + priority: "medium", + status: "active", + concurrencyPolicy: "coalesce_if_active", + catchUpPolicy: "skip_missed", + variables: [ + { name: "repo", label: null, type: "text", defaultValue: null, required: true, options: [] }, + { name: "owner", label: null, type: "text", defaultValue: null, required: true, options: [] }, + ], + }, + {}, + ); + + const run = await svc.runRoutine(variableRoutine.id, { + source: "api", + caseFields: { + repo: "paperclip", + }, + }); + expect(run.status).toBe("issue_created"); + + const storedIssue = await db + .select({ title: issues.title, description: issues.description }) + .from(issues) + .where(eq(issues.id, run.linkedIssueId!)) + .then((rows) => rows[0] ?? null); + const storedRun = await db + .select({ triggerPayload: routineRuns.triggerPayload }) + .from(routineRuns) + .where(eq(routineRuns.id, run.id)) + .then((rows) => rows[0] ?? null); + + expect(storedIssue?.title).toBe("repo triage for paperclip by "); + expect(storedIssue?.description).toBe("Notify about paperclip"); + expect(storedRun?.triggerPayload).toMatchObject({ + interpolationWarnings: ["owner"], + variables: { + repo: "paperclip", + }, + }); + }); + it("attaches the selected execution workspace to manually triggered routine issues", async () => { const { companyId, projectId, routine, svc } = await seedFixture(); const projectWorkspaceId = randomUUID(); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 8ba78523532..8dd5978f4f2 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -44,6 +44,7 @@ import { updateRoutineTriggerSchema, rotateRoutineTriggerSecretSchema, runRoutineSchema, + runRoutineIntakeFormSchema, // Goal createGoalSchema, updateGoalSchema, @@ -1900,6 +1901,18 @@ registry.registerPath({ responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, }); +registry.registerPath({ + method: "post", + path: "/api/routines/{id}/intake-form", + tags: ["routines"], + summary: "Run a routine from an intake form submission", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(runRoutineIntakeFormSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + registry.registerPath({ method: "post", path: "/api/routines/{id}/triggers", diff --git a/server/src/routes/routines.ts b/server/src/routes/routines.ts index d5d9d35f082..73078d1008d 100644 --- a/server/src/routes/routines.ts +++ b/server/src/routes/routines.ts @@ -5,6 +5,7 @@ import { createRoutineTriggerSchema, rotateRoutineTriggerSecretSchema, runRoutineSchema, + runRoutineIntakeFormSchema, updateRoutineSchema, updateRoutineTriggerSchema, } from "@paperclipai/shared"; @@ -25,6 +26,18 @@ export function routineRoutes( pluginWorkerManager: options.pluginWorkerManager, }); const access = accessService(db); + const intakeFormControlFields = new Set([ + "payload", + "variables", + "triggerId", + "projectId", + "assigneeAgentId", + "idempotencyKey", + "executionWorkspaceId", + "executionWorkspacePreference", + "executionWorkspaceSettings", + "source", + ]); async function assertBoardCanAssignTasks(req: Request, companyId: string) { assertCompanyAccess(req, companyId); @@ -85,6 +98,21 @@ export function routineRoutes( }); } + function splitIntakeFormRunInput(input: Record) { + const payload: Record = {}; + const control: Record = {}; + + for (const [key, value] of Object.entries(input)) { + if (intakeFormControlFields.has(key)) { + control[key] = value; + } else { + payload[key] = value; + } + } + + return { payload, control }; + } + router.get("/companies/:companyId/routines", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); @@ -327,6 +355,41 @@ export function routineRoutes( res.json(updated?.trigger ?? null); }); + router.post("/routines/:id/intake-form", validate(runRoutineIntakeFormSchema), async (req, res) => { + const routine = await assertCanManageExistingRoutine(req, req.params.id as string); + if (!routine) { + res.status(404).json({ error: "Routine not found" }); + return; + } + await assertBoardCanAssignTasks(req, routine.companyId); + const { payload, control } = splitIntakeFormRunInput(req.body); + const runPayload = "payload" in control + ? control.payload as Record | null + : payload; + const run = await svc.runRoutine(routine.id, { + ...control, + source: "api", + payload: runPayload, + variables: payload, + }, { + agentId: req.actor.type === "agent" ? req.actor.agentId : null, + userId: req.actor.type === "board" ? req.actor.userId ?? null : null, + }); + const actor = getActorInfo(req); + await logActivity(db, { + companyId: routine.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "routine.run_triggered", + entityType: "routine_run", + entityId: run.id, + details: { routineId: routine.id, source: run.source, status: run.status }, + }); + res.status(202).json(run); + }); + router.delete("/routine-triggers/:id", async (req, res) => { const trigger = await svc.getTrigger(req.params.id as string); if (!trigger) { diff --git a/server/src/services/routines.ts b/server/src/services/routines.ts index 7081377c5e2..a79703ce2be 100644 --- a/server/src/services/routines.ts +++ b/server/src/services/routines.ts @@ -293,6 +293,11 @@ function collectProvidedRoutineVariables( return provided; } +interface RoutineVariableResolution { + resolvedVariables: Record; + unresolvedVariables: string[]; +} + function resolveRoutineVariableValues( variables: RoutineVariable[], input: { @@ -300,13 +305,22 @@ function resolveRoutineVariableValues( payload?: Record | null; variables?: Record | null; automaticVariables?: Record; + caseFields?: Record | null; + allowUnresolvedVariables?: boolean; }, -) { - if (variables.length === 0) return {} as Record; +): RoutineVariableResolution { + if (variables.length === 0) { + return { + resolvedVariables: {}, + unresolvedVariables: [], + }; + } const provided = collectProvidedRoutineVariables(input.source, input.payload, input.variables); const automaticVariables = input.automaticVariables ?? {}; - const resolved: Record = {}; - const missing: string[] = []; + const caseFields = input.caseFields ?? {}; + const resolvedVariables: Record = {}; + const unresolvedVariables: string[] = []; + const strict = !input.allowUnresolvedVariables; for (const variable of variables) { // Workspace-derived automatic values are authoritative for variables that @@ -315,20 +329,48 @@ function resolveRoutineVariableValues( ? automaticVariables[variable.name] : provided[variable.name] !== undefined ? provided[variable.name] - : variable.defaultValue; + : caseFields[variable.name] !== undefined + ? caseFields[variable.name] + : variable.defaultValue; const normalized = normalizeRoutineVariableValue(variable, candidate); if (normalized == null || (typeof normalized === "string" && normalized.trim().length === 0)) { - if (variable.required) missing.push(variable.name); + if (strict && variable.required) { + unresolvedVariables.push(variable.name); + continue; + } + if (!strict) { + unresolvedVariables.push(variable.name); + const fallback = variable.defaultValue ?? ""; + if (fallback != null && fallback !== "") { + const normalizedFallback = normalizeRoutineVariableValue(variable, fallback); + if ( + normalizedFallback != null && + !( + typeof normalizedFallback === "string" && + normalizedFallback.trim().length === 0 + ) + ) { + resolvedVariables[variable.name] = normalizedFallback; + } else if (typeof normalizedFallback === "string") { + resolvedVariables[variable.name] = ""; + } + } else { + resolvedVariables[variable.name] = ""; + } + } continue; } - resolved[variable.name] = normalized; + resolvedVariables[variable.name] = normalized; } - if (missing.length > 0) { - throw unprocessable(`Missing routine variables: ${missing.join(", ")}`); + if (unresolvedVariables.length > 0 && strict) { + throw unprocessable(`Missing routine variables: ${unresolvedVariables.join(", ")}`); } - return resolved; + return { + resolvedVariables, + unresolvedVariables: strict ? [] : unresolvedVariables, + }; } function mergeRoutineRunPayload( @@ -1145,6 +1187,7 @@ export function routineService( source: "schedule" | "manual" | "api" | "webhook"; payload?: Record | null; variables?: Record | null; + caseFields?: Record | null; projectId?: string | null; assigneeAgentId?: string | null; idempotencyKey?: string | null; @@ -1179,14 +1222,37 @@ export function routineService( automaticVariables[WORKSPACE_BRANCH_ROUTINE_VARIABLE] = branchName; } } - const resolvedVariables = resolveRoutineVariableValues(input.routine.variables ?? [], { - ...input, + const automationInputs = input.source === "api" || input.caseFields != null; + const variableResolution = resolveRoutineVariableValues(input.routine.variables ?? [], { + source: input.source, + payload: input.payload, + variables: input.variables, automaticVariables, + caseFields: input.caseFields, + allowUnresolvedVariables: automationInputs, }); - const allVariables = { ...getBuiltinRoutineVariableValues(), ...automaticVariables, ...resolvedVariables }; + const resolvedVariables = variableResolution.resolvedVariables; + const templateVariableNames = extractRoutineVariableNames([ + input.routine.title, + input.routine.description, + ]); + const interpolationWarnings = automationInputs + ? variableResolution.unresolvedVariables.filter((name) => templateVariableNames.includes(name)) + : []; + const allVariables = { + ...getBuiltinRoutineVariableValues(), + ...automaticVariables, + ...resolvedVariables, + }; const title = interpolateRoutineTemplate(input.routine.title, allVariables) ?? input.routine.title; const description = interpolateRoutineTemplate(input.routine.description, allVariables); - const triggerPayload = mergeRoutineRunPayload(input.payload, { ...automaticVariables, ...resolvedVariables }); + let triggerPayload = mergeRoutineRunPayload(input.payload, { ...automaticVariables, ...resolvedVariables }); + if (interpolationWarnings.length > 0) { + triggerPayload = { + ...(triggerPayload ?? {}), + interpolationWarnings, + }; + } const managedRoutineBinding = await getManagedRoutineBinding(input.routine); const managedIssueTemplate = readManagedRoutineIssueTemplate(managedRoutineBinding?.defaultsJson); const issueOriginKind = managedIssueTemplate?.surfaceVisibility === "plugin_operation" && managedRoutineBinding @@ -2189,6 +2255,7 @@ export function routineService( source: input.source, payload: input.payload as Record | null | undefined, variables: input.variables as Record | null | undefined, + caseFields: input.caseFields as Record | null | undefined, projectId: input.projectId ?? null, assigneeAgentId: input.assigneeAgentId ?? null, idempotencyKey: input.idempotencyKey,