Add draft routine defaults and run-time overrides

This commit is contained in:
dotta 2026-04-09 10:19:52 -05:00
parent b4a58ba8a6
commit 5d021583be
18 changed files with 592 additions and 113 deletions

View file

@ -329,6 +329,53 @@ describeEmbeddedPostgres("routine routes end-to-end", () => {
expect(issue?.description).toBe("Review paperclip for high bugs");
});
it("allows drafting a routine without defaults and running it with one-off overrides", async () => {
const { companyId, agentId, projectId, userId } = await seedFixture();
const app = await createApp({
type: "board",
userId,
source: "session",
isInstanceAdmin: false,
companyIds: [companyId],
});
const createRes = await request(app)
.post(`/api/companies/${companyId}/routines`)
.send({
title: "Draft routine",
description: "No saved defaults",
});
expect(createRes.status).toBe(201);
expect(createRes.body.projectId).toBeNull();
expect(createRes.body.assigneeAgentId).toBeNull();
expect(createRes.body.status).toBe("paused");
const runRes = await request(app)
.post(`/api/routines/${createRes.body.id}/run`)
.send({
source: "manual",
projectId,
assigneeAgentId: agentId,
});
expect(runRes.status).toBe(202);
expect(runRes.body.status).toBe("issue_created");
const [issue] = await db
.select({
projectId: issues.projectId,
assigneeAgentId: issues.assigneeAgentId,
})
.from(issues)
.where(eq(issues.id, runRes.body.linkedIssueId));
expect(issue).toEqual({
projectId,
assigneeAgentId: agentId,
});
});
it("persists execution workspace selections from manual routine runs", async () => {
const { companyId, agentId, projectId, userId } = await seedFixture();
const projectWorkspaceId = randomUUID();

View file

@ -221,6 +221,31 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
expect(routineIssues.map((issue) => issue.id)).toContain(run.linkedIssueId);
});
it("creates draft routines without a project or default assignee", async () => {
const { companyId, svc } = await seedFixture();
const routine = await svc.create(
companyId,
{
projectId: null,
goalId: null,
parentIssueId: null,
title: "draft routine",
description: "No defaults yet",
assigneeAgentId: null,
priority: "medium",
status: "active",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
},
{},
);
expect(routine.projectId).toBeNull();
expect(routine.assigneeAgentId).toBeNull();
expect(routine.status).toBe("paused");
});
it("wakes the assignee when a routine creates a fresh execution issue", async () => {
const { agentId, routine, svc, wakeups } = await seedFixture();
@ -436,6 +461,73 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
});
});
it("runs draft routines with one-off agent and project overrides", async () => {
const { companyId, agentId, projectId, svc } = await seedFixture();
const draftRoutine = await svc.create(
companyId,
{
projectId: null,
goalId: null,
parentIssueId: null,
title: "draft dispatch",
description: "Pick defaults at run time",
assigneeAgentId: null,
priority: "medium",
status: "paused",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
},
{},
);
const run = await svc.runRoutine(draftRoutine.id, {
source: "manual",
projectId,
assigneeAgentId: agentId,
});
expect(run.status).toBe("issue_created");
expect(run.linkedIssueId).toBeTruthy();
const storedIssue = await db
.select({
projectId: issues.projectId,
assigneeAgentId: issues.assigneeAgentId,
})
.from(issues)
.where(eq(issues.id, run.linkedIssueId!))
.then((rows) => rows[0] ?? null);
expect(storedIssue).toEqual({
projectId,
assigneeAgentId: agentId,
});
});
it("rejects enabling automation for routines without a default agent", async () => {
const { companyId, svc } = await seedFixture();
const draftRoutine = await svc.create(
companyId,
{
projectId: null,
goalId: null,
parentIssueId: null,
title: "draft routine",
description: null,
assigneeAgentId: null,
priority: "medium",
status: "paused",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
},
{},
);
await expect(
svc.update(draftRoutine.id, { status: "active" }, {}),
).rejects.toThrow(/default agent required/i);
});
it("blocks schedule triggers when required variables do not have defaults", async () => {
const { companyId, agentId, projectId, svc } = await seedFixture();
const variableRoutine = await svc.create(

View file

@ -34,7 +34,7 @@ export function routineRoutes(db: Db) {
assertCompanyAccess(req, companyId);
if (req.actor.type === "board") return;
if (req.actor.type !== "agent" || !req.actor.agentId) throw unauthorized();
if (assigneeAgentId && assigneeAgentId !== req.actor.agentId) {
if (assigneeAgentId !== req.actor.agentId) {
throw forbidden("Agents can only manage routines assigned to themselves");
}
}
@ -114,7 +114,11 @@ export function routineRoutes(db: Db) {
if (statusWillActivate) {
await assertBoardCanAssignTasks(req, routine.companyId);
}
if (req.actor.type === "agent" && req.body.assigneeAgentId && req.body.assigneeAgentId !== req.actor.agentId) {
if (
req.actor.type === "agent" &&
req.body.assigneeAgentId !== undefined &&
req.body.assigneeAgentId !== req.actor.agentId
) {
throw forbidden("Agents can only assign routines to themselves");
}
const updated = await svc.update(routine.id, req.body, {

View file

@ -3310,9 +3310,9 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
for (const routine of selectedRoutineRows) {
const taskSlug = taskSlugByRoutineId.get(routine.id)!;
const projectSlug = projectSlugById.get(routine.projectId) ?? null;
const projectSlug = routine.projectId ? (projectSlugById.get(routine.projectId) ?? null) : null;
const taskPath = `tasks/${taskSlug}/TASK.md`;
const assigneeSlug = idToSlug.get(routine.assigneeAgentId) ?? null;
const assigneeSlug = routine.assigneeAgentId ? (idToSlug.get(routine.assigneeAgentId) ?? null) : null;
files[taskPath] = buildMarkdown(
{
name: routine.title,

View file

@ -27,6 +27,7 @@ import type {
UpdateRoutineTrigger,
} from "@paperclipai/shared";
import {
getBuiltinRoutineVariableValues,
interpolateRoutineTemplate,
stringifyRoutineVariableValue,
syncRoutineVariablesWithTemplate,
@ -230,6 +231,23 @@ function assertScheduleCompatibleVariables(variables: RoutineVariable[]) {
}
}
function statusRequiresDefaultAgent(status: string) {
return status === "active";
}
function normalizeDraftRoutineStatus(status: string, assigneeAgentId: string | null | undefined) {
if (statusRequiresDefaultAgent(status) && !assigneeAgentId) {
return "paused";
}
return status;
}
function assertRoutineCanEnable(status: string, assigneeAgentId: string | null | undefined) {
if (statusRequiresDefaultAgent(status) && !assigneeAgentId) {
throw unprocessable("Default agent required");
}
}
function collectProvidedRoutineVariables(
source: "schedule" | "manual" | "api" | "webhook",
payload: Record<string, unknown> | null | undefined,
@ -319,7 +337,8 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
return routine;
}
async function assertAssignableAgent(companyId: string, agentId: string) {
async function assertAssignableAgent(companyId: string, agentId: string | null | undefined) {
if (!agentId) return;
const agent = await db
.select({ id: agents.id, companyId: agents.companyId, status: agents.status })
.from(agents)
@ -331,7 +350,8 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
if (agent.status === "terminated") throw conflict("Cannot assign routines to terminated agents");
}
async function assertProject(companyId: string, projectId: string) {
async function assertProject(companyId: string, projectId: string | null | undefined) {
if (!projectId) return;
const project = await db
.select({ id: projects.id, companyId: projects.companyId })
.from(projects)
@ -669,14 +689,22 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
source: "schedule" | "manual" | "api" | "webhook";
payload?: Record<string, unknown> | null;
variables?: Record<string, unknown> | null;
projectId?: string | null;
assigneeAgentId?: string | null;
idempotencyKey?: string | null;
executionWorkspaceId?: string | null;
executionWorkspacePreference?: string | null;
executionWorkspaceSettings?: Record<string, unknown> | null;
}) {
const projectId = input.projectId ?? input.routine.projectId ?? null;
const assigneeAgentId = input.assigneeAgentId ?? input.routine.assigneeAgentId ?? null;
if (!assigneeAgentId) {
throw unprocessable("Default agent required");
}
const resolvedVariables = resolveRoutineVariableValues(input.routine.variables ?? [], input);
const title = interpolateRoutineTemplate(input.routine.title, resolvedVariables) ?? input.routine.title;
const description = interpolateRoutineTemplate(input.routine.description, resolvedVariables);
const allVariables = { ...getBuiltinRoutineVariableValues(), ...resolvedVariables };
const title = interpolateRoutineTemplate(input.routine.title, allVariables) ?? input.routine.title;
const description = interpolateRoutineTemplate(input.routine.description, allVariables);
const triggerPayload = mergeRoutineRunPayload(input.payload, resolvedVariables);
const run = await db.transaction(async (tx) => {
const txDb = tx as unknown as Db;
@ -746,14 +774,14 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
try {
createdIssue = await issueSvc.create(input.routine.companyId, {
projectId: input.routine.projectId,
projectId,
goalId: input.routine.goalId,
parentId: input.routine.parentIssueId,
title,
description,
status: "todo",
priority: input.routine.priority,
assigneeAgentId: input.routine.assigneeAgentId,
assigneeAgentId,
originKind: "routine_execution",
originId: input.routine.id,
originRunId: createdRun.id,
@ -906,8 +934,12 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
const row = await getRoutineById(id);
if (!row) return null;
const [project, assignee, parentIssue, triggers, recentRuns, activeIssue] = await Promise.all([
db.select().from(projects).where(eq(projects.id, row.projectId)).then((rows) => rows[0] ?? null),
db.select().from(agents).where(eq(agents.id, row.assigneeAgentId)).then((rows) => rows[0] ?? null),
row.projectId
? db.select().from(projects).where(eq(projects.id, row.projectId)).then((rows) => rows[0] ?? null)
: null,
row.assigneeAgentId
? db.select().from(agents).where(eq(agents.id, row.assigneeAgentId)).then((rows) => rows[0] ?? null)
: null,
row.parentIssueId ? issueSvc.getById(row.parentIssueId) : null,
db.select().from(routineTriggers).where(eq(routineTriggers.routineId, row.id)).orderBy(asc(routineTriggers.createdAt)),
db
@ -992,8 +1024,8 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
},
create: async (companyId: string, input: CreateRoutine, actor: Actor): Promise<Routine> => {
await assertProject(companyId, input.projectId);
await assertAssignableAgent(companyId, input.assigneeAgentId);
await assertProject(companyId, input.projectId ?? null);
await assertAssignableAgent(companyId, input.assigneeAgentId ?? null);
if (input.goalId) await assertGoal(companyId, input.goalId);
if (input.parentIssueId) await assertParentIssue(companyId, input.parentIssueId);
const variables = syncRoutineVariablesWithTemplate(
@ -1001,18 +1033,19 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
sanitizeRoutineVariableInputs(input.variables),
);
assertRoutineVariableDefinitions(variables);
const status = normalizeDraftRoutineStatus(input.status, input.assigneeAgentId);
const [created] = await db
.insert(routines)
.values({
companyId,
projectId: input.projectId,
projectId: input.projectId ?? null,
goalId: input.goalId ?? null,
parentIssueId: input.parentIssueId ?? null,
title: input.title,
description: input.description ?? null,
assigneeAgentId: input.assigneeAgentId,
assigneeAgentId: input.assigneeAgentId ?? null,
priority: input.priority,
status: input.status,
status,
concurrencyPolicy: input.concurrencyPolicy,
catchUpPolicy: input.catchUpPolicy,
variables,
@ -1028,16 +1061,23 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
update: async (id: string, patch: UpdateRoutine, actor: Actor): Promise<Routine | null> => {
const existing = await getRoutineById(id);
if (!existing) return null;
const nextProjectId = patch.projectId ?? existing.projectId;
const nextAssigneeAgentId = patch.assigneeAgentId ?? existing.assigneeAgentId;
const nextProjectId = patch.projectId === undefined ? existing.projectId : patch.projectId;
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 requestedStatus = patch.status ?? existing.status;
if (patch.status === "active") {
assertRoutineCanEnable(patch.status, nextAssigneeAgentId);
}
const nextStatus = patch.assigneeAgentId === undefined
? requestedStatus
: normalizeDraftRoutineStatus(requestedStatus, nextAssigneeAgentId);
const nextVariables = syncRoutineVariablesWithTemplate(
[nextTitle, nextDescription],
patch.variables === undefined ? existing.variables : sanitizeRoutineVariableInputs(patch.variables),
);
if (patch.projectId) await assertProject(existing.companyId, nextProjectId);
if (patch.assigneeAgentId) await assertAssignableAgent(existing.companyId, nextAssigneeAgentId);
if (patch.projectId !== undefined) await assertProject(existing.companyId, nextProjectId);
if (patch.assigneeAgentId !== undefined) await assertAssignableAgent(existing.companyId, nextAssigneeAgentId);
if (patch.goalId) await assertGoal(existing.companyId, patch.goalId);
if (patch.parentIssueId) await assertParentIssue(existing.companyId, patch.parentIssueId);
assertRoutineVariableDefinitions(nextVariables);
@ -1066,7 +1106,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
description: nextDescription,
assigneeAgentId: nextAssigneeAgentId,
priority: patch.priority ?? existing.priority,
status: patch.status ?? existing.status,
status: nextStatus,
concurrencyPolicy: patch.concurrencyPolicy ?? existing.concurrencyPolicy,
catchUpPolicy: patch.catchUpPolicy ?? existing.catchUpPolicy,
variables: nextVariables,
@ -1233,6 +1273,8 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
const routine = await getRoutineById(id);
if (!routine) throw notFound("Routine not found");
if (routine.status === "archived") throw conflict("Routine is archived");
await assertProject(routine.companyId, input.projectId ?? null);
await assertAssignableAgent(routine.companyId, input.assigneeAgentId ?? null);
const trigger = input.triggerId ? await getTriggerById(input.triggerId) : null;
if (trigger && trigger.routineId !== routine.id) throw forbidden("Trigger does not belong to routine");
if (trigger && !trigger.enabled) throw conflict("Routine trigger is not active");
@ -1242,6 +1284,8 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
source: input.source,
payload: input.payload as Record<string, unknown> | null | undefined,
variables: input.variables as Record<string, unknown> | null | undefined,
projectId: input.projectId ?? null,
assigneeAgentId: input.assigneeAgentId ?? null,
idempotencyKey: input.idempotencyKey,
executionWorkspaceId: input.executionWorkspaceId ?? null,
executionWorkspacePreference: input.executionWorkspacePreference ?? null,