Merge pull request #3220 from paperclipai/pap-1266-routines

feat(routines): support draft routines and run-time overrides
This commit is contained in:
Dotta 2026-04-09 10:47:03 -05:00 committed by GitHub
commit 6d63a4df45
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 609 additions and 118 deletions

View file

@ -0,0 +1,2 @@
ALTER TABLE "routines" ALTER COLUMN "project_id" DROP NOT NULL;
ALTER TABLE "routines" ALTER COLUMN "assignee_agent_id" DROP NOT NULL;

View file

@ -379,6 +379,13 @@
"when": 1775604018515, "when": 1775604018515,
"tag": "0053_sharp_wild_child", "tag": "0053_sharp_wild_child",
"breakpoints": true "breakpoints": true
},
{
"idx": 54,
"version": "7",
"when": 1775750400000,
"tag": "0054_draft_routines",
"breakpoints": true
} }
] ]
} }

View file

@ -22,12 +22,12 @@ export const routines = pgTable(
{ {
id: uuid("id").primaryKey().defaultRandom(), id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
projectId: uuid("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }), projectId: uuid("project_id").references(() => projects.id, { onDelete: "cascade" }),
goalId: uuid("goal_id").references(() => goals.id, { onDelete: "set null" }), goalId: uuid("goal_id").references(() => goals.id, { onDelete: "set null" }),
parentIssueId: uuid("parent_issue_id").references(() => issues.id, { onDelete: "set null" }), parentIssueId: uuid("parent_issue_id").references(() => issues.id, { onDelete: "set null" }),
title: text("title").notNull(), title: text("title").notNull(),
description: text("description"), description: text("description"),
assigneeAgentId: uuid("assignee_agent_id").notNull().references(() => agents.id), assigneeAgentId: uuid("assignee_agent_id").references(() => agents.id),
priority: text("priority").notNull().default("medium"), priority: text("priority").notNull().default("medium"),
status: text("status").notNull().default("active"), status: text("status").notNull().default("active"),
concurrencyPolicy: text("concurrency_policy").notNull().default("coalesce_if_active"), concurrencyPolicy: text("concurrency_policy").notNull().default("coalesce_if_active"),

View file

@ -637,8 +637,11 @@ export {
} from "./project-mentions.js"; } from "./project-mentions.js";
export { export {
BUILTIN_ROUTINE_VARIABLE_NAMES,
extractRoutineVariableNames, extractRoutineVariableNames,
getBuiltinRoutineVariableValues,
interpolateRoutineTemplate, interpolateRoutineTemplate,
isBuiltinRoutineVariable,
isValidRoutineVariableName, isValidRoutineVariableName,
stringifyRoutineVariableValue, stringifyRoutineVariableValue,
syncRoutineVariablesWithTemplate, syncRoutineVariablesWithTemplate,

View file

@ -1,7 +1,10 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
BUILTIN_ROUTINE_VARIABLE_NAMES,
extractRoutineVariableNames, extractRoutineVariableNames,
getBuiltinRoutineVariableValues,
interpolateRoutineTemplate, interpolateRoutineTemplate,
isBuiltinRoutineVariable,
syncRoutineVariablesWithTemplate, syncRoutineVariablesWithTemplate,
} from "./routine-variables.js"; } from "./routine-variables.js";
@ -40,4 +43,34 @@ describe("routine variable helpers", () => {
}), }),
).toBe("Review paperclip for high"); ).toBe("Review paperclip for high");
}); });
it("identifies built-in variable names", () => {
expect(isBuiltinRoutineVariable("date")).toBe(true);
expect(isBuiltinRoutineVariable("repo")).toBe(false);
expect(BUILTIN_ROUTINE_VARIABLE_NAMES.has("date")).toBe(true);
});
it("getBuiltinRoutineVariableValues returns date in YYYY-MM-DD format", () => {
const values = getBuiltinRoutineVariableValues();
expect(values.date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(values.date).toBe(new Date().toISOString().slice(0, 10));
});
it("excludes built-in variables from syncRoutineVariablesWithTemplate", () => {
const result = syncRoutineVariablesWithTemplate(
"Daily report for {{date}} — {{repo}}",
[],
);
expect(result).toEqual([
{ name: "repo", label: null, type: "text", defaultValue: null, required: true, options: [] },
]);
});
it("interpolates built-in date variable alongside user variables", () => {
const builtins = getBuiltinRoutineVariableValues();
const allVars = { ...builtins, repo: "paperclip" };
expect(
interpolateRoutineTemplate("Report for {{date}} on {{repo}}", allVars),
).toBe(`Report for ${builtins.date} on paperclip`);
});
}); });

View file

@ -3,6 +3,26 @@ import type { RoutineVariable } from "./types/routine.js";
const ROUTINE_VARIABLE_MATCHER = /\{\{\s*([A-Za-z][A-Za-z0-9_]*)\s*\}\}/g; const ROUTINE_VARIABLE_MATCHER = /\{\{\s*([A-Za-z][A-Za-z0-9_]*)\s*\}\}/g;
type RoutineTemplateInput = string | null | undefined | Array<string | null | undefined>; type RoutineTemplateInput = string | null | undefined | Array<string | null | undefined>;
/**
* Built-in variable names that are automatically available in routine templates
* without needing to be defined in the routine's variables list.
*/
export const BUILTIN_ROUTINE_VARIABLE_NAMES = new Set(["date"]);
export function isBuiltinRoutineVariable(name: string): boolean {
return BUILTIN_ROUTINE_VARIABLE_NAMES.has(name);
}
/**
* Returns current values for all built-in routine variables.
* `date` expands to the current date in YYYY-MM-DD format (UTC).
*/
export function getBuiltinRoutineVariableValues(): Record<string, string> {
return {
date: new Date().toISOString().slice(0, 10),
};
}
export function isValidRoutineVariableName(name: string): boolean { export function isValidRoutineVariableName(name: string): boolean {
return /^[A-Za-z][A-Za-z0-9_]*$/.test(name); return /^[A-Za-z][A-Za-z0-9_]*$/.test(name);
} }
@ -40,7 +60,7 @@ export function syncRoutineVariablesWithTemplate(
template: RoutineTemplateInput, template: RoutineTemplateInput,
existing: RoutineVariable[] | null | undefined, existing: RoutineVariable[] | null | undefined,
): RoutineVariable[] { ): RoutineVariable[] {
const names = extractRoutineVariableNames(template); const names = extractRoutineVariableNames(template).filter((name) => !isBuiltinRoutineVariable(name));
const existingByName = new Map((existing ?? []).map((variable) => [variable.name, variable])); const existingByName = new Map((existing ?? []).map((variable) => [variable.name, variable]));
return names.map((name) => existingByName.get(name) ?? defaultRoutineVariable(name)); return names.map((name) => existingByName.get(name) ?? defaultRoutineVariable(name));
} }

View file

@ -39,12 +39,12 @@ export interface RoutineVariable {
export interface Routine { export interface Routine {
id: string; id: string;
companyId: string; companyId: string;
projectId: string; projectId: string | null;
goalId: string | null; goalId: string | null;
parentIssueId: string | null; parentIssueId: string | null;
title: string; title: string;
description: string | null; description: string | null;
assigneeAgentId: string; assigneeAgentId: string | null;
priority: string; priority: string;
status: string; status: string;
concurrencyPolicy: string; concurrencyPolicy: string;

View file

@ -48,12 +48,12 @@ export const routineVariableSchema = z.object({
}); });
export const createRoutineSchema = z.object({ export const createRoutineSchema = z.object({
projectId: z.string().uuid(), projectId: z.string().uuid().optional().nullable(),
goalId: z.string().uuid().optional().nullable(), goalId: z.string().uuid().optional().nullable(),
parentIssueId: z.string().uuid().optional().nullable(), parentIssueId: z.string().uuid().optional().nullable(),
title: z.string().trim().min(1).max(200), title: z.string().trim().min(1).max(200),
description: z.string().optional().nullable(), description: z.string().optional().nullable(),
assigneeAgentId: z.string().uuid(), assigneeAgentId: z.string().uuid().optional().nullable(),
priority: z.enum(ISSUE_PRIORITIES).optional().default("medium"), priority: z.enum(ISSUE_PRIORITIES).optional().default("medium"),
status: z.enum(ROUTINE_STATUSES).optional().default("active"), status: z.enum(ROUTINE_STATUSES).optional().default("active"),
concurrencyPolicy: z.enum(ROUTINE_CONCURRENCY_POLICIES).optional().default("coalesce_if_active"), concurrencyPolicy: z.enum(ROUTINE_CONCURRENCY_POLICIES).optional().default("coalesce_if_active"),
@ -104,6 +104,8 @@ export const runRoutineSchema = z.object({
triggerId: z.string().uuid().optional().nullable(), triggerId: z.string().uuid().optional().nullable(),
payload: z.record(z.unknown()).optional().nullable(), payload: z.record(z.unknown()).optional().nullable(),
variables: z.record(routineVariableValueSchema).optional().nullable(), variables: z.record(routineVariableValueSchema).optional().nullable(),
projectId: z.string().uuid().optional().nullable(),
assigneeAgentId: z.string().uuid().optional().nullable(),
idempotencyKey: z.string().trim().max(255).optional().nullable(), idempotencyKey: z.string().trim().max(255).optional().nullable(),
source: z.enum(["manual", "api"]).optional().default("manual"), source: z.enum(["manual", "api"]).optional().default("manual"),
executionWorkspaceId: z.string().uuid().optional().nullable(), executionWorkspaceId: z.string().uuid().optional().nullable(),

View file

@ -329,6 +329,53 @@ describeEmbeddedPostgres("routine routes end-to-end", () => {
expect(issue?.description).toBe("Review paperclip for high bugs"); 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 () => { it("persists execution workspace selections from manual routine runs", async () => {
const { companyId, agentId, projectId, userId } = await seedFixture(); const { companyId, agentId, projectId, userId } = await seedFixture();
const projectWorkspaceId = randomUUID(); const projectWorkspaceId = randomUUID();

View file

@ -221,6 +221,31 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
expect(routineIssues.map((issue) => issue.id)).toContain(run.linkedIssueId); 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 () => { it("wakes the assignee when a routine creates a fresh execution issue", async () => {
const { agentId, routine, svc, wakeups } = await seedFixture(); 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 () => { it("blocks schedule triggers when required variables do not have defaults", async () => {
const { companyId, agentId, projectId, svc } = await seedFixture(); const { companyId, agentId, projectId, svc } = await seedFixture();
const variableRoutine = await svc.create( const variableRoutine = await svc.create(

View file

@ -34,7 +34,7 @@ export function routineRoutes(db: Db) {
assertCompanyAccess(req, companyId); assertCompanyAccess(req, companyId);
if (req.actor.type === "board") return; if (req.actor.type === "board") return;
if (req.actor.type !== "agent" || !req.actor.agentId) throw unauthorized(); 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"); throw forbidden("Agents can only manage routines assigned to themselves");
} }
} }
@ -114,7 +114,11 @@ export function routineRoutes(db: Db) {
if (statusWillActivate) { if (statusWillActivate) {
await assertBoardCanAssignTasks(req, routine.companyId); 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"); throw forbidden("Agents can only assign routines to themselves");
} }
const updated = await svc.update(routine.id, req.body, { 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) { for (const routine of selectedRoutineRows) {
const taskSlug = taskSlugByRoutineId.get(routine.id)!; 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 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( files[taskPath] = buildMarkdown(
{ {
name: routine.title, name: routine.title,

View file

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

View file

@ -23,6 +23,8 @@ interface InlineEntitySelectorProps {
renderOption?: (option: InlineEntityOption, isSelected: boolean) => ReactNode; renderOption?: (option: InlineEntityOption, isSelected: boolean) => ReactNode;
/** Skip the Portal so the popover stays in the DOM tree (fixes scroll inside Dialogs). */ /** Skip the Portal so the popover stays in the DOM tree (fixes scroll inside Dialogs). */
disablePortal?: boolean; disablePortal?: boolean;
/** Open the popover when the trigger receives keyboard/programmatic focus. */
openOnFocus?: boolean;
} }
export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySelectorProps>( export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySelectorProps>(
@ -40,6 +42,7 @@ export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySe
renderTriggerValue, renderTriggerValue,
renderOption, renderOption,
disablePortal, disablePortal,
openOnFocus = true,
}, },
ref, ref,
) { ) {
@ -103,7 +106,7 @@ export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySe
)} )}
onPointerDown={() => { isPointerDownRef.current = true; }} onPointerDown={() => { isPointerDownRef.current = true; }}
onFocus={() => { onFocus={() => {
if (!isPointerDownRef.current) setOpen(true); if (openOnFocus && !isPointerDownRef.current) setOpen(true);
isPointerDownRef.current = false; isPointerDownRef.current = false;
}} }}
> >
@ -123,7 +126,9 @@ export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySe
// On touch devices, don't auto-focus the search input to avoid // On touch devices, don't auto-focus the search input to avoid
// opening the virtual keyboard which reshapes the viewport and // opening the virtual keyboard which reshapes the viewport and
// pushes the popover off-screen. // pushes the popover off-screen.
const isTouch = window.matchMedia("(pointer: coarse)").matches; const isTouch = typeof window.matchMedia === "function"
? window.matchMedia("(pointer: coarse)").matches
: false;
if (!isTouch) { if (!isTouch) {
inputRef.current?.focus(); inputRef.current?.focus();
} }

View file

@ -222,6 +222,18 @@ async function flush() {
}); });
} }
async function waitForValue<T>(getValue: () => T | null | undefined, attempts = 10): Promise<T> {
for (let attempt = 0; attempt < attempts; attempt += 1) {
const value = getValue();
if (value != null) {
return value;
}
await flush();
}
throw new Error("Timed out waiting for value");
}
function renderDialog(container: HTMLDivElement) { function renderDialog(container: HTMLDivElement) {
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
@ -421,13 +433,13 @@ describe("NewIssueDialog", () => {
expect(container.textContent).not.toContain("will no longer use the parent issue workspace"); expect(container.textContent).not.toContain("will no longer use the parent issue workspace");
const selects = Array.from(container.querySelectorAll("select")); const modeSelect = await waitForValue(
const modeSelect = selects[0] as HTMLSelectElement | undefined; () => container.querySelector("select") as HTMLSelectElement | null,
expect(modeSelect).not.toBeUndefined(); );
await act(async () => { await act(async () => {
modeSelect!.value = "shared_workspace"; modeSelect.value = "shared_workspace";
modeSelect!.dispatchEvent(new Event("change", { bubbles: true })); modeSelect.dispatchEvent(new Event("change", { bubbles: true }));
}); });
await flush(); await flush();

View file

@ -3,7 +3,7 @@
import { act } from "react"; import { act } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Project } from "@paperclipai/shared"; import type { Agent, Project } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RoutineRunVariablesDialog } from "./RoutineRunVariablesDialog"; import { RoutineRunVariablesDialog } from "./RoutineRunVariablesDialog";
@ -85,6 +85,33 @@ function createProject(): Project {
}; };
} }
function createAgent(): Agent {
return {
id: "agent-1",
companyId: "company-1",
name: "Routine Agent",
role: "engineer",
title: null,
status: "active",
reportsTo: null,
capabilities: null,
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
lastHeartbeatAt: null,
icon: "code",
metadata: null,
createdAt: new Date("2026-04-02T00:00:00.000Z"),
updatedAt: new Date("2026-04-02T00:00:00.000Z"),
urlKey: "routine-agent",
pauseReason: null,
pausedAt: null,
permissions: { canCreateAgents: false },
};
}
describe("RoutineRunVariablesDialog", () => { describe("RoutineRunVariablesDialog", () => {
let container: HTMLDivElement; let container: HTMLDivElement;
@ -116,7 +143,10 @@ describe("RoutineRunVariablesDialog", () => {
open open
onOpenChange={() => {}} onOpenChange={() => {}}
companyId="company-1" companyId="company-1"
project={createProject()} projects={[createProject()]}
agents={[createAgent()]}
defaultProjectId="project-1"
defaultAssigneeAgentId="agent-1"
variables={[]} variables={[]}
isPending={false} isPending={false}
onSubmit={() => {}} onSubmit={() => {}}
@ -129,6 +159,8 @@ describe("RoutineRunVariablesDialog", () => {
expect(issueWorkspaceDraftCalls).toBeLessThanOrEqual(2); expect(issueWorkspaceDraftCalls).toBeLessThanOrEqual(2);
expect(document.body.textContent).toContain("Run routine"); expect(document.body.textContent).toContain("Run routine");
expect(document.body.textContent).not.toContain("Search agents...");
expect(document.body.textContent).not.toContain("Search projects...");
await act(async () => { await act(async () => {
root.unmount(); root.unmount();

View file

@ -1,9 +1,12 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import type { IssueExecutionWorkspaceSettings, Project, RoutineVariable } from "@paperclipai/shared"; import type { Agent, IssueExecutionWorkspaceSettings, Project, RoutineVariable } from "@paperclipai/shared";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { instanceSettingsApi } from "../api/instanceSettings"; import { instanceSettingsApi } from "../api/instanceSettings";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { IssueWorkspaceCard } from "./IssueWorkspaceCard"; import { IssueWorkspaceCard } from "./IssueWorkspaceCard";
import { AgentIcon } from "./AgentIconPicker";
import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector";
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@ -28,6 +31,16 @@ function buildInitialValues(variables: RoutineVariable[]) {
return Object.fromEntries(variables.map((variable) => [variable.name, variable.defaultValue ?? ""])); return Object.fromEntries(variables.map((variable) => [variable.name, variable.defaultValue ?? ""]));
} }
function buildInitialRunSelection(input: {
defaultAssigneeAgentId?: string | null;
defaultProjectId?: string | null;
}) {
return {
assigneeAgentId: input.defaultAssigneeAgentId ?? "",
projectId: input.defaultProjectId ?? "",
};
}
function defaultProjectWorkspaceIdForProject(project: Project | null | undefined) { function defaultProjectWorkspaceIdForProject(project: Project | null | undefined) {
if (!project) return null; if (!project) return null;
return project.executionWorkspacePolicy?.defaultProjectWorkspaceId return project.executionWorkspacePolicy?.defaultProjectWorkspaceId
@ -107,6 +120,8 @@ export function routineRunNeedsConfiguration(input: {
export interface RoutineRunDialogSubmitData { export interface RoutineRunDialogSubmitData {
variables?: Record<string, string | number | boolean>; variables?: Record<string, string | number | boolean>;
assigneeAgentId?: string | null;
projectId?: string | null;
executionWorkspaceId?: string | null; executionWorkspaceId?: string | null;
executionWorkspacePreference?: string | null; executionWorkspacePreference?: string | null;
executionWorkspaceSettings?: IssueExecutionWorkspaceSettings | null; executionWorkspaceSettings?: IssueExecutionWorkspaceSettings | null;
@ -116,7 +131,10 @@ export function RoutineRunVariablesDialog({
open, open,
onOpenChange, onOpenChange,
companyId, companyId,
project, projects,
agents,
defaultProjectId,
defaultAssigneeAgentId,
variables, variables,
isPending, isPending,
onSubmit, onSubmit,
@ -124,13 +142,48 @@ export function RoutineRunVariablesDialog({
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
companyId: string | null | undefined; companyId: string | null | undefined;
project: Project | null | undefined; projects: Project[];
agents: Agent[];
defaultProjectId?: string | null;
defaultAssigneeAgentId?: string | null;
variables: RoutineVariable[]; variables: RoutineVariable[];
isPending: boolean; isPending: boolean;
onSubmit: (data: RoutineRunDialogSubmitData) => void; onSubmit: (data: RoutineRunDialogSubmitData) => void;
}) { }) {
const [values, setValues] = useState<Record<string, unknown>>({}); const [values, setValues] = useState<Record<string, unknown>>({});
const [workspaceConfig, setWorkspaceConfig] = useState(() => buildInitialWorkspaceConfig(project)); const [selection, setSelection] = useState(() => buildInitialRunSelection({
defaultAssigneeAgentId,
defaultProjectId,
}));
const selectedProject = useMemo(
() => projects.find((project) => project.id === selection.projectId) ?? null,
[projects, selection.projectId],
);
const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [open]);
const assigneeOptions = useMemo<InlineEntityOption[]>(
() =>
sortAgentsByRecency(
agents.filter((agent) => agent.status !== "terminated"),
recentAssigneeIds,
).map((agent) => ({
id: agent.id,
label: agent.name,
searchText: `${agent.name} ${agent.role} ${agent.title ?? ""}`,
})),
[agents, recentAssigneeIds],
);
const projectOptions = useMemo<InlineEntityOption[]>(
() => projects.map((project) => ({
id: project.id,
label: project.name,
searchText: project.description ?? "",
})),
[projects],
);
const currentAssignee = selection.assigneeAgentId
? agents.find((agent) => agent.id === selection.assigneeAgentId) ?? null
: null;
const [workspaceConfig, setWorkspaceConfig] = useState(() => buildInitialWorkspaceConfig(selectedProject));
const [workspaceConfigValid, setWorkspaceConfigValid] = useState(true); const [workspaceConfigValid, setWorkspaceConfigValid] = useState(true);
const { data: experimentalSettings } = useQuery({ const { data: experimentalSettings } = useQuery({
@ -140,16 +193,18 @@ export function RoutineRunVariablesDialog({
}); });
const workspaceSelectionEnabled = supportsRoutineRunWorkspaceSelection( const workspaceSelectionEnabled = supportsRoutineRunWorkspaceSelection(
project, selectedProject,
experimentalSettings?.enableIsolatedWorkspaces === true, experimentalSettings?.enableIsolatedWorkspaces === true,
); );
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
setValues(buildInitialValues(variables)); setValues(buildInitialValues(variables));
setWorkspaceConfig(buildInitialWorkspaceConfig(project)); const nextSelection = buildInitialRunSelection({ defaultAssigneeAgentId, defaultProjectId });
setSelection(nextSelection);
setWorkspaceConfig(buildInitialWorkspaceConfig(projects.find((project) => project.id === nextSelection.projectId) ?? null));
setWorkspaceConfigValid(true); setWorkspaceConfigValid(true);
}, [open, project, variables]); }, [defaultAssigneeAgentId, defaultProjectId, open, projects, variables]);
const missingRequired = useMemo( const missingRequired = useMemo(
() => () =>
@ -162,7 +217,7 @@ export function RoutineRunVariablesDialog({
const workspaceIssue = useMemo(() => ({ const workspaceIssue = useMemo(() => ({
companyId: companyId ?? null, companyId: companyId ?? null,
projectId: project?.id ?? null, projectId: selectedProject?.id ?? null,
projectWorkspaceId: workspaceConfig.projectWorkspaceId, projectWorkspaceId: workspaceConfig.projectWorkspaceId,
executionWorkspaceId: workspaceConfig.executionWorkspaceId, executionWorkspaceId: workspaceConfig.executionWorkspaceId,
executionWorkspacePreference: workspaceConfig.executionWorkspacePreference, executionWorkspacePreference: workspaceConfig.executionWorkspacePreference,
@ -170,14 +225,17 @@ export function RoutineRunVariablesDialog({
currentExecutionWorkspace: null, currentExecutionWorkspace: null,
}), [ }), [
companyId, companyId,
project?.id, selectedProject?.id,
workspaceConfig.executionWorkspaceId, workspaceConfig.executionWorkspaceId,
workspaceConfig.executionWorkspacePreference, workspaceConfig.executionWorkspacePreference,
workspaceConfig.executionWorkspaceSettings, workspaceConfig.executionWorkspaceSettings,
workspaceConfig.projectWorkspaceId, workspaceConfig.projectWorkspaceId,
]); ]);
const canSubmit = missingRequired.length === 0 && (!workspaceSelectionEnabled || workspaceConfigValid); const canSubmit =
selection.assigneeAgentId.trim().length > 0 &&
missingRequired.length === 0 &&
(!workspaceSelectionEnabled || workspaceConfigValid);
const handleWorkspaceUpdate = useCallback((data: Record<string, unknown>) => { const handleWorkspaceUpdate = useCallback((data: Record<string, unknown>) => {
setWorkspaceConfig((current) => applyWorkspaceDraft(current, data)); setWorkspaceConfig((current) => applyWorkspaceDraft(current, data));
@ -197,11 +255,100 @@ export function RoutineRunVariablesDialog({
<DialogHeader> <DialogHeader>
<DialogTitle>Run routine</DialogTitle> <DialogTitle>Run routine</DialogTitle>
<DialogDescription> <DialogDescription>
Fill in the routine variables before starting the execution issue. Choose the agent and optional project for this one run. Routine defaults are prefilled and won&apos;t be changed.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-1.5">
<Label className="text-xs">Agent *</Label>
<InlineEntitySelector
value={selection.assigneeAgentId}
options={assigneeOptions}
placeholder="Agent"
noneLabel="Select an agent"
searchPlaceholder="Search agents..."
emptyMessage="No agents found."
disablePortal
openOnFocus={false}
onChange={(assigneeAgentId) => {
if (assigneeAgentId) trackRecentAssignee(assigneeAgentId);
setSelection((current) => ({ ...current, assigneeAgentId }));
}}
renderTriggerValue={(option) =>
option ? (
currentAssignee ? (
<>
<AgentIcon icon={currentAssignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{option.label}</span>
</>
) : (
<span className="truncate">{option.label}</span>
)
) : (
<span className="text-muted-foreground">Select an agent</span>
)
}
renderOption={(option) => {
if (!option.id) return <span className="truncate">{option.label}</span>;
const assignee = agents.find((agent) => agent.id === option.id);
return (
<>
{assignee ? <AgentIcon icon={assignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null}
<span className="truncate">{option.label}</span>
</>
);
}}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Project</Label>
<InlineEntitySelector
value={selection.projectId}
options={projectOptions}
placeholder="Project"
noneLabel="No project"
searchPlaceholder="Search projects..."
emptyMessage="No projects found."
disablePortal
openOnFocus={false}
onChange={(projectId) => {
const project = projects.find((entry) => entry.id === projectId) ?? null;
setSelection((current) => ({ ...current, projectId }));
setWorkspaceConfig(buildInitialWorkspaceConfig(project));
setWorkspaceConfigValid(true);
}}
renderTriggerValue={(option) =>
option && selectedProject ? (
<>
<span
className="h-3.5 w-3.5 shrink-0 rounded-sm"
style={{ backgroundColor: selectedProject.color ?? "#64748b" }}
/>
<span className="truncate">{option.label}</span>
</>
) : (
<span className="text-muted-foreground">No project</span>
)
}
renderOption={(option) => {
if (!option.id) return <span className="truncate">{option.label}</span>;
const project = projects.find((entry) => entry.id === option.id);
return (
<>
<span
className="h-3.5 w-3.5 shrink-0 rounded-sm"
style={{ backgroundColor: project?.color ?? "#64748b" }}
/>
<span className="truncate">{option.label}</span>
</>
);
}}
/>
</div>
</div>
{variables.map((variable) => ( {variables.map((variable) => (
<div key={variable.name} className="space-y-1.5"> <div key={variable.name} className="space-y-1.5">
<Label className="text-xs"> <Label className="text-xs">
@ -259,11 +406,11 @@ export function RoutineRunVariablesDialog({
</div> </div>
))} ))}
{workspaceSelectionEnabled && project && companyId ? ( {workspaceSelectionEnabled && selectedProject && companyId ? (
<IssueWorkspaceCard <IssueWorkspaceCard
key={`${open ? "open" : "closed"}:${project.id}`} key={`${open ? "open" : "closed"}:${selectedProject.id}`}
issue={workspaceIssue} issue={workspaceIssue}
project={project} project={selectedProject}
initialEditing initialEditing
livePreview livePreview
onUpdate={handleWorkspaceUpdate} onUpdate={handleWorkspaceUpdate}
@ -273,7 +420,9 @@ export function RoutineRunVariablesDialog({
</div> </div>
<DialogFooter showCloseButton={false}> <DialogFooter showCloseButton={false}>
{missingRequired.length > 0 ? ( {!selection.assigneeAgentId ? (
<p className="mr-auto text-xs text-amber-600">Default agent required for this run.</p>
) : missingRequired.length > 0 ? (
<p className="mr-auto text-xs text-amber-600"> <p className="mr-auto text-xs text-amber-600">
Missing: {missingRequired.join(", ")} Missing: {missingRequired.join(", ")}
</p> </p>
@ -303,6 +452,8 @@ export function RoutineRunVariablesDialog({
} }
onSubmit({ onSubmit({
variables: nextVariables, variables: nextVariables,
assigneeAgentId: selection.assigneeAgentId,
projectId: selection.projectId || null,
...(workspaceSelectionEnabled ...(workspaceSelectionEnabled
? { ? {
executionWorkspaceId: workspaceConfig.executionWorkspaceId, executionWorkspaceId: workspaceConfig.executionWorkspaceId,

View file

@ -17,7 +17,6 @@ import {
} from "lucide-react"; } from "lucide-react";
import { routinesApi, type RoutineTriggerResponse, type RotateRoutineTriggerResponse } from "../api/routines"; import { routinesApi, type RoutineTriggerResponse, type RotateRoutineTriggerResponse } from "../api/routines";
import { heartbeatsApi } from "../api/heartbeats"; import { heartbeatsApi } from "../api/heartbeats";
import { instanceSettingsApi } from "../api/instanceSettings";
import { LiveRunWidget } from "../components/LiveRunWidget"; import { LiveRunWidget } from "../components/LiveRunWidget";
import { agentsApi } from "../api/agents"; import { agentsApi } from "../api/agents";
import { projectsApi } from "../api/projects"; import { projectsApi } from "../api/projects";
@ -35,7 +34,6 @@ import { InlineEntitySelector, type InlineEntityOption } from "../components/Inl
import { MarkdownEditor, type MarkdownEditorRef } from "../components/MarkdownEditor"; import { MarkdownEditor, type MarkdownEditorRef } from "../components/MarkdownEditor";
import { import {
RoutineRunVariablesDialog, RoutineRunVariablesDialog,
routineRunNeedsConfiguration,
type RoutineRunDialogSubmitData, type RoutineRunDialogSubmitData,
} from "../components/RoutineRunVariablesDialog"; } from "../components/RoutineRunVariablesDialog";
import { RoutineVariablesEditor, RoutineVariablesHint } from "../components/RoutineVariablesEditor"; import { RoutineVariablesEditor, RoutineVariablesHint } from "../components/RoutineVariablesEditor";
@ -123,6 +121,24 @@ function getLocalTimezone(): string {
} }
} }
function buildRoutineMutationPayload(input: {
title: string;
description: string;
projectId: string;
assigneeAgentId: string;
priority: string;
concurrencyPolicy: string;
catchUpPolicy: string;
variables: RoutineVariable[];
}) {
return {
...input,
description: input.description.trim() || null,
projectId: input.projectId || null,
assigneeAgentId: input.assigneeAgentId || null,
};
}
function TriggerEditor({ function TriggerEditor({
trigger, trigger,
onSave, onSave,
@ -333,11 +349,6 @@ export function RoutineDetail() {
queryFn: () => projectsApi.list(selectedCompanyId!), queryFn: () => projectsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId, enabled: !!selectedCompanyId,
}); });
const { data: experimentalSettings } = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
retry: false,
});
const routineDefaults = useMemo( const routineDefaults = useMemo(
() => () =>
@ -345,8 +356,8 @@ export function RoutineDetail() {
? { ? {
title: routine.title, title: routine.title,
description: routine.description ?? "", description: routine.description ?? "",
projectId: routine.projectId, projectId: routine.projectId ?? "",
assigneeAgentId: routine.assigneeAgentId, assigneeAgentId: routine.assigneeAgentId ?? "",
priority: routine.priority, priority: routine.priority,
concurrencyPolicy: routine.concurrencyPolicy, concurrencyPolicy: routine.concurrencyPolicy,
catchUpPolicy: routine.catchUpPolicy, catchUpPolicy: routine.catchUpPolicy,
@ -418,10 +429,7 @@ export function RoutineDetail() {
const saveRoutine = useMutation({ const saveRoutine = useMutation({
mutationFn: () => { mutationFn: () => {
return routinesApi.update(routineId!, { return routinesApi.update(routineId!, buildRoutineMutationPayload(editDraft));
...editDraft,
description: editDraft.description.trim() || null,
});
}, },
onSuccess: async () => { onSuccess: async () => {
await Promise.all([ await Promise.all([
@ -443,6 +451,8 @@ export function RoutineDetail() {
mutationFn: (data?: RoutineRunDialogSubmitData) => mutationFn: (data?: RoutineRunDialogSubmitData) =>
routinesApi.run(routineId!, { routinesApi.run(routineId!, {
...(data?.variables && Object.keys(data.variables).length > 0 ? { variables: data.variables } : {}), ...(data?.variables && Object.keys(data.variables).length > 0 ? { variables: data.variables } : {}),
...(data?.assigneeAgentId !== undefined ? { assigneeAgentId: data.assigneeAgentId } : {}),
...(data?.projectId !== undefined ? { projectId: data.projectId } : {}),
...(data?.executionWorkspaceId !== undefined ? { executionWorkspaceId: data.executionWorkspaceId } : {}), ...(data?.executionWorkspaceId !== undefined ? { executionWorkspaceId: data.executionWorkspaceId } : {}),
...(data?.executionWorkspacePreference !== undefined ...(data?.executionWorkspacePreference !== undefined
? { executionWorkspacePreference: data.executionWorkspacePreference } ? { executionWorkspacePreference: data.executionWorkspacePreference }
@ -657,14 +667,15 @@ export function RoutineDetail() {
} }
const automationEnabled = routine.status === "active"; const automationEnabled = routine.status === "active";
const selectedProject = projects?.find((project) => project.id === routine.projectId) ?? null; const selectedProject = routine.projectId ? (projects?.find((project) => project.id === routine.projectId) ?? null) : null;
const needsRunConfiguration = routineRunNeedsConfiguration({
variables: routine.variables ?? [],
project: selectedProject,
isolatedWorkspacesEnabled: experimentalSettings?.enableIsolatedWorkspaces === true,
});
const automationToggleDisabled = updateRoutineStatus.isPending || routine.status === "archived"; const automationToggleDisabled = updateRoutineStatus.isPending || routine.status === "archived";
const automationLabel = routine.status === "archived" ? "Archived" : automationEnabled ? "Active" : "Paused"; const automationLabel = routine.status === "archived"
? "Archived"
: !routine.assigneeAgentId
? "Draft"
: automationEnabled
? "Active"
: "Paused";
const automationLabelClassName = routine.status === "archived" const automationLabelClassName = routine.status === "archived"
? "text-muted-foreground" ? "text-muted-foreground"
: automationEnabled : automationEnabled
@ -708,18 +719,24 @@ export function RoutineDetail() {
<div className="flex shrink-0 items-center gap-3 pt-1"> <div className="flex shrink-0 items-center gap-3 pt-1">
<RunButton <RunButton
onClick={() => { onClick={() => {
if (needsRunConfiguration) { setRunVariablesOpen(true);
setRunVariablesOpen(true);
return;
}
runRoutine.mutate({});
}} }}
disabled={runRoutine.isPending} disabled={runRoutine.isPending}
/> />
<ToggleSwitch <ToggleSwitch
size="lg" size="lg"
checked={automationEnabled} checked={automationEnabled}
onCheckedChange={() => updateRoutineStatus.mutate(automationEnabled ? "paused" : "active")} onCheckedChange={() => {
if (!automationEnabled && !routine.assigneeAgentId) {
pushToast({
title: "Default agent required",
body: "Set a default agent before enabling routine automation.",
tone: "warn",
});
return;
}
updateRoutineStatus.mutate(automationEnabled ? "paused" : "active");
}}
disabled={automationToggleDisabled} disabled={automationToggleDisabled}
aria-label={automationEnabled ? "Pause automatic triggers" : "Enable automatic triggers"} aria-label={automationEnabled ? "Pause automatic triggers" : "Enable automatic triggers"}
/> />
@ -755,6 +772,12 @@ export function RoutineDetail() {
</div> </div>
)} )}
{!routine.assigneeAgentId ? (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-4 text-sm text-amber-900 dark:text-amber-200">
Default agent required. This routine can stay as a draft and still run manually, but automation stays paused until you assign a default agent.
</div>
) : null}
{/* Assignment row */} {/* Assignment row */}
<div className="overflow-x-auto overscroll-x-contain"> <div className="overflow-x-auto overscroll-x-contain">
<div className="inline-flex min-w-full flex-wrap items-center gap-2 text-sm text-muted-foreground sm:min-w-max sm:flex-nowrap"> <div className="inline-flex min-w-full flex-wrap items-center gap-2 text-sm text-muted-foreground sm:min-w-max sm:flex-nowrap">
@ -853,7 +876,7 @@ export function RoutineDetail() {
bordered={false} bordered={false}
contentClassName="min-h-[120px] text-[15px] leading-7" contentClassName="min-h-[120px] text-[15px] leading-7"
onSubmit={() => { onSubmit={() => {
if (!saveRoutine.isPending && editDraft.title.trim() && editDraft.projectId && editDraft.assigneeAgentId) { if (!saveRoutine.isPending && editDraft.title.trim()) {
saveRoutine.mutate(); saveRoutine.mutate();
} }
}} }}
@ -921,7 +944,7 @@ export function RoutineDetail() {
)} )}
<Button <Button
onClick={() => saveRoutine.mutate()} onClick={() => saveRoutine.mutate()}
disabled={saveRoutine.isPending || !editDraft.title.trim() || !editDraft.projectId || !editDraft.assigneeAgentId} disabled={saveRoutine.isPending || !editDraft.title.trim()}
> >
<Save className="mr-2 h-4 w-4" /> <Save className="mr-2 h-4 w-4" />
Save routine Save routine
@ -1091,7 +1114,10 @@ export function RoutineDetail() {
open={runVariablesOpen} open={runVariablesOpen}
onOpenChange={setRunVariablesOpen} onOpenChange={setRunVariablesOpen}
companyId={routine.companyId} companyId={routine.companyId}
project={selectedProject} agents={agents ?? []}
projects={projects ?? []}
defaultProjectId={routine.projectId}
defaultAssigneeAgentId={routine.assigneeAgentId}
variables={routine.variables ?? []} variables={routine.variables ?? []}
isPending={runRoutine.isPending} isPending={runRoutine.isPending}
onSubmit={(data) => runRoutine.mutate(data)} onSubmit={(data) => runRoutine.mutate(data)}

View file

@ -3,7 +3,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate, useSearchParams } from "@/lib/router"; import { useNavigate, useSearchParams } from "@/lib/router";
import { Check, ChevronDown, ChevronRight, Layers, MoreHorizontal, Plus, Repeat } from "lucide-react"; import { Check, ChevronDown, ChevronRight, Layers, MoreHorizontal, Plus, Repeat } from "lucide-react";
import { routinesApi } from "../api/routines"; import { routinesApi } from "../api/routines";
import { instanceSettingsApi } from "../api/instanceSettings";
import { agentsApi } from "../api/agents"; import { agentsApi } from "../api/agents";
import { projectsApi } from "../api/projects"; import { projectsApi } from "../api/projects";
import { issuesApi } from "../api/issues"; import { issuesApi } from "../api/issues";
@ -25,7 +24,6 @@ import { InlineEntitySelector, type InlineEntityOption } from "../components/Inl
import { MarkdownEditor, type MarkdownEditorRef } from "../components/MarkdownEditor"; import { MarkdownEditor, type MarkdownEditorRef } from "../components/MarkdownEditor";
import { import {
RoutineRunVariablesDialog, RoutineRunVariablesDialog,
routineRunNeedsConfiguration,
type RoutineRunDialogSubmitData, type RoutineRunDialogSubmitData,
} from "../components/RoutineRunVariablesDialog"; } from "../components/RoutineRunVariablesDialog";
import { RoutineVariablesEditor, RoutineVariablesHint } from "../components/RoutineVariablesEditor"; import { RoutineVariablesEditor, RoutineVariablesHint } from "../components/RoutineVariablesEditor";
@ -117,6 +115,24 @@ function formatRoutineRunStatus(value: string | null | undefined) {
return value.replaceAll("_", " "); return value.replaceAll("_", " ");
} }
function buildRoutineMutationPayload(input: {
title: string;
description: string;
projectId: string;
assigneeAgentId: string;
priority: string;
concurrencyPolicy: string;
catchUpPolicy: string;
variables: RoutineVariable[];
}) {
return {
...input,
description: input.description.trim() || null,
projectId: input.projectId || null,
assigneeAgentId: input.assigneeAgentId || null,
};
}
export function buildRoutineGroups( export function buildRoutineGroups(
routines: RoutineListItem[], routines: RoutineListItem[],
groupByValue: RoutineGroupBy, groupByValue: RoutineGroupBy,
@ -186,6 +202,7 @@ function RoutineListRow({
const isStatusPending = statusMutationRoutineId === routine.id; const isStatusPending = statusMutationRoutineId === routine.id;
const project = routine.projectId ? projectById.get(routine.projectId) ?? null : null; const project = routine.projectId ? projectById.get(routine.projectId) ?? null : null;
const agent = routine.assigneeAgentId ? agentById.get(routine.assigneeAgentId) ?? null : null; const agent = routine.assigneeAgentId ? agentById.get(routine.assigneeAgentId) ?? null : null;
const isDraft = !isArchived && !routine.assigneeAgentId;
return ( return (
<div <div
@ -195,9 +212,9 @@ function RoutineListRow({
<div className="min-w-0 flex-1 space-y-1.5"> <div className="min-w-0 flex-1 space-y-1.5">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium">{routine.title}</span> <span className="truncate text-sm font-medium">{routine.title}</span>
{(isArchived || routine.status === "paused") ? ( {(isArchived || routine.status === "paused" || isDraft) ? (
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{isArchived ? "archived" : "paused"} {isArchived ? "archived" : isDraft ? "draft" : "paused"}
</span> </span>
) : null} ) : null}
</div> </div>
@ -207,11 +224,11 @@ function RoutineListRow({
className="h-2.5 w-2.5 shrink-0 rounded-sm" className="h-2.5 w-2.5 shrink-0 rounded-sm"
style={{ backgroundColor: project?.color ?? "#64748b" }} style={{ backgroundColor: project?.color ?? "#64748b" }}
/> />
<span>{project?.name ?? "Unknown project"}</span> <span>{routine.projectId ? (project?.name ?? "Unknown project") : "No project"}</span>
</span> </span>
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
{agent?.icon ? <AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0" /> : null} {agent?.icon ? <AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0" /> : null}
<span>{agent?.name ?? "Unknown agent"}</span> <span>{routine.assigneeAgentId ? (agent?.name ?? "Unknown agent") : "No default agent"}</span>
</span> </span>
<span> <span>
{formatLastRunTimestamp(routine.lastRun?.triggeredAt)} {formatLastRunTimestamp(routine.lastRun?.triggeredAt)}
@ -230,7 +247,7 @@ function RoutineListRow({
aria-label={enabled ? `Disable ${routine.title}` : `Enable ${routine.title}`} aria-label={enabled ? `Disable ${routine.title}` : `Enable ${routine.title}`}
/> />
<span className="w-12 text-xs text-muted-foreground"> <span className="w-12 text-xs text-muted-foreground">
{isArchived ? "Archived" : enabled ? "On" : "Off"} {isArchived ? "Archived" : isDraft ? "Draft" : enabled ? "On" : "Off"}
</span> </span>
</div> </div>
@ -334,11 +351,6 @@ export function Routines() {
queryFn: () => projectsApi.list(selectedCompanyId!), queryFn: () => projectsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId, enabled: !!selectedCompanyId,
}); });
const { data: experimentalSettings } = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
retry: false,
});
const { data: routineExecutionIssues, isLoading: recentRunsLoading, error: recentRunsError } = useQuery({ const { data: routineExecutionIssues, isLoading: recentRunsLoading, error: recentRunsError } = useQuery({
queryKey: [...queryKeys.issues.list(selectedCompanyId!), "routine-executions"], queryKey: [...queryKeys.issues.list(selectedCompanyId!), "routine-executions"],
queryFn: () => issuesApi.list(selectedCompanyId!, { originKind: "routine_execution" }), queryFn: () => issuesApi.list(selectedCompanyId!, { originKind: "routine_execution" }),
@ -357,10 +369,7 @@ export function Routines() {
const createRoutine = useMutation({ const createRoutine = useMutation({
mutationFn: () => mutationFn: () =>
routinesApi.create(selectedCompanyId!, { routinesApi.create(selectedCompanyId!, buildRoutineMutationPayload(draft)),
...draft,
description: draft.description.trim() || null,
}),
onSuccess: async (routine) => { onSuccess: async (routine) => {
setDraft({ setDraft({
title: "", title: "",
@ -377,7 +386,9 @@ export function Routines() {
await queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }); await queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) });
pushToast({ pushToast({
title: "Routine created", title: "Routine created",
body: "Add the first trigger to turn it into a live workflow.", body: routine.assigneeAgentId
? "Add the first trigger to turn it into a live workflow."
: "Draft saved. Add a default agent before enabling automation.",
tone: "success", tone: "success",
}); });
navigate(`/routines/${routine.id}?tab=triggers`); navigate(`/routines/${routine.id}?tab=triggers`);
@ -417,6 +428,8 @@ export function Routines() {
const runRoutine = useMutation({ const runRoutine = useMutation({
mutationFn: ({ id, data }: { id: string; data?: RoutineRunDialogSubmitData }) => routinesApi.run(id, { mutationFn: ({ id, data }: { id: string; data?: RoutineRunDialogSubmitData }) => routinesApi.run(id, {
...(data?.variables && Object.keys(data.variables).length > 0 ? { variables: data.variables } : {}), ...(data?.variables && Object.keys(data.variables).length > 0 ? { variables: data.variables } : {}),
...(data?.assigneeAgentId !== undefined ? { assigneeAgentId: data.assigneeAgentId } : {}),
...(data?.projectId !== undefined ? { projectId: data.projectId } : {}),
...(data?.executionWorkspaceId !== undefined ? { executionWorkspaceId: data.executionWorkspaceId } : {}), ...(data?.executionWorkspaceId !== undefined ? { executionWorkspaceId: data.executionWorkspaceId } : {}),
...(data?.executionWorkspacePreference !== undefined ...(data?.executionWorkspacePreference !== undefined
? { executionWorkspacePreference: data.executionWorkspacePreference } ? { executionWorkspacePreference: data.executionWorkspacePreference }
@ -497,7 +510,6 @@ export function Routines() {
), ),
[], [],
); );
const runDialogProject = runDialogRoutine?.projectId ? projectById.get(runDialogRoutine.projectId) ?? null : null;
const currentAssignee = draft.assigneeAgentId ? agentById.get(draft.assigneeAgentId) ?? null : null; const currentAssignee = draft.assigneeAgentId ? agentById.get(draft.assigneeAgentId) ?? null : null;
const currentProject = draft.projectId ? projectById.get(draft.projectId) ?? null : null; const currentProject = draft.projectId ? projectById.get(draft.projectId) ?? null : null;
@ -517,20 +529,18 @@ export function Routines() {
} }
function handleRunNow(routine: RoutineListItem) { function handleRunNow(routine: RoutineListItem) {
const project = routine.projectId ? projectById.get(routine.projectId) ?? null : null; setRunDialogRoutine(routine);
const needsConfiguration = routineRunNeedsConfiguration({
variables: routine.variables ?? [],
project,
isolatedWorkspacesEnabled: experimentalSettings?.enableIsolatedWorkspaces === true,
});
if (needsConfiguration) {
setRunDialogRoutine(routine);
return;
}
runRoutine.mutate({ id: routine.id, data: {} });
} }
function handleToggleEnabled(routine: RoutineListItem, enabled: boolean) { function handleToggleEnabled(routine: RoutineListItem, enabled: boolean) {
if (!enabled && !routine.assigneeAgentId) {
pushToast({
title: "Default agent required",
body: "Set a default agent before enabling routine automation.",
tone: "warn",
});
return;
}
updateRoutineStatus.mutate({ updateRoutineStatus.mutate({
id: routine.id, id: routine.id,
status: nextRoutineStatus(routine.status, !enabled), status: nextRoutineStatus(routine.status, !enabled),
@ -648,7 +658,7 @@ export function Routines() {
<div> <div>
<p className="text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">New routine</p> <p className="text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">New routine</p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Define the recurring work first. Trigger setup comes next on the detail page. Define the recurring work first. Default project and agent are optional for draft routines.
</p> </p>
</div> </div>
<Button <Button
@ -798,7 +808,7 @@ export function Routines() {
bordered={false} bordered={false}
contentClassName="min-h-[160px] text-sm text-muted-foreground" contentClassName="min-h-[160px] text-sm text-muted-foreground"
onSubmit={() => { onSubmit={() => {
if (!createRoutine.isPending && draft.title.trim() && draft.projectId && draft.assigneeAgentId) { if (!createRoutine.isPending && draft.title.trim()) {
createRoutine.mutate(); createRoutine.mutate();
} }
}} }}
@ -867,16 +877,14 @@ export function Routines() {
<div className="shrink-0 flex flex-col gap-3 border-t border-border/60 px-5 py-4 sm:flex-row sm:items-center sm:justify-between"> <div className="shrink-0 flex flex-col gap-3 border-t border-border/60 px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
After creation, Paperclip takes you straight to trigger setup for schedules, webhooks, or internal runs. After creation, Paperclip takes you straight to trigger setup. Draft routines stay paused until you add a default agent.
</div> </div>
<div className="flex flex-col gap-2 sm:items-end"> <div className="flex flex-col gap-2 sm:items-end">
<Button <Button
onClick={() => createRoutine.mutate()} onClick={() => createRoutine.mutate()}
disabled={ disabled={
createRoutine.isPending || createRoutine.isPending ||
!draft.title.trim() || !draft.title.trim()
!draft.projectId ||
!draft.assigneeAgentId
} }
> >
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
@ -965,7 +973,10 @@ export function Routines() {
if (!next) setRunDialogRoutine(null); if (!next) setRunDialogRoutine(null);
}} }}
companyId={selectedCompanyId} companyId={selectedCompanyId}
project={runDialogProject} agents={agents ?? []}
projects={projects ?? []}
defaultProjectId={runDialogRoutine?.projectId ?? null}
defaultAssigneeAgentId={runDialogRoutine?.assigneeAgentId ?? null}
variables={runDialogRoutine?.variables ?? []} variables={runDialogRoutine?.variables ?? []}
isPending={runRoutine.isPending} isPending={runRoutine.isPending}
onSubmit={(data) => { onSubmit={(data) => {