PAP-10829: routine intake and unresolved interpolation handling

This commit is contained in:
Dotta committed 2026-06-10 20:06:21 +00:00
1 parent db1e46f3dc
commit 8b48eed4e4
10 files changed
+377 -14

No files matched your search

@@ -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({
@@ -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();
+13
View File
@@ -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",
+63
View File
@@ -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<string, unknown>) {
const payload: Record<string, unknown> = {};
const control: Record<string, unknown> = {};
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<string, unknown> | 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) {
+81 -14
View File
@@ -293,6 +293,11 @@ function collectProvidedRoutineVariables(
return provided;
}
interface RoutineVariableResolution {
resolvedVariables: Record<string, string | number | boolean>;
unresolvedVariables: string[];
}
function resolveRoutineVariableValues(
variables: RoutineVariable[],
input: {
@@ -300,13 +305,22 @@ function resolveRoutineVariableValues(
payload?: Record<string, unknown> | null;
variables?: Record<string, unknown> | null;
automaticVariables?: Record<string, string | number | boolean>;
caseFields?: Record<string, unknown> | null;
allowUnresolvedVariables?: boolean;
},
) {
if (variables.length === 0) return {} as Record<string, string | number | boolean>;
): RoutineVariableResolution {
if (variables.length === 0) {
return {
resolvedVariables: {},
unresolvedVariables: [],
};
}
const provided = collectProvidedRoutineVariables(input.source, input.payload, input.variables);
const automaticVariables = input.automaticVariables ?? {};
const resolved: Record<string, string | number | boolean> = {};
const missing: string[] = [];
const caseFields = input.caseFields ?? {};
const resolvedVariables: Record<string, string | number | boolean> = {};
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<string, unknown> | null;
variables?: Record<string, unknown> | null;
caseFields?: Record<string, unknown> | 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<string, unknown> | null | undefined,
variables: input.variables as Record<string, unknown> | null | undefined,
caseFields: input.caseFields as Record<string, unknown> | null | undefined,
projectId: input.projectId ?? null,
assigneeAgentId: input.assigneeAgentId ?? null,
idempotencyKey: input.idempotencyKey,