mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 04:02:18 +01:00
PAP-10829: routine intake and unresolved interpolation handling
This commit is contained in:
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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user