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
19 changed files with 609 additions and 118 deletions
@@ -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;
@@ -379,6 +379,13 @@
"when": 1775604018515,
"tag": "0053_sharp_wild_child",
"breakpoints": true
},
{
"idx": 54,
"version": "7",
"when": 1775750400000,
"tag": "0054_draft_routines",
"breakpoints": true
}
]
}
+2 -2
View File
@@ -22,12 +22,12 @@ export const routines = pgTable(
{
id: uuid("id").primaryKey().defaultRandom(),
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" }),
parentIssueId: uuid("parent_issue_id").references(() => issues.id, { onDelete: "set null" }),
title: text("title").notNull(),
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"),
status: text("status").notNull().default("active"),
concurrencyPolicy: text("concurrency_policy").notNull().default("coalesce_if_active"),
+3
View File
@@ -637,8 +637,11 @@ export {
} from "./project-mentions.js";
export {
BUILTIN_ROUTINE_VARIABLE_NAMES,
extractRoutineVariableNames,
getBuiltinRoutineVariableValues,
interpolateRoutineTemplate,
isBuiltinRoutineVariable,
isValidRoutineVariableName,
stringifyRoutineVariableValue,
syncRoutineVariablesWithTemplate,
@@ -1,7 +1,10 @@
import { describe, expect, it } from "vitest";
import {
BUILTIN_ROUTINE_VARIABLE_NAMES,
extractRoutineVariableNames,
getBuiltinRoutineVariableValues,
interpolateRoutineTemplate,
isBuiltinRoutineVariable,
syncRoutineVariablesWithTemplate,
} from "./routine-variables.js";
@@ -40,4 +43,34 @@ describe("routine variable helpers", () => {
}),
).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`);
});
});
+21 -1
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;
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 {
return /^[A-Za-z][A-Za-z0-9_]*$/.test(name);
}
@@ -40,7 +60,7 @@ export function syncRoutineVariablesWithTemplate(
template: RoutineTemplateInput,
existing: RoutineVariable[] | null | undefined,
): RoutineVariable[] {
const names = extractRoutineVariableNames(template);
const names = extractRoutineVariableNames(template).filter((name) => !isBuiltinRoutineVariable(name));
const existingByName = new Map((existing ?? []).map((variable) => [variable.name, variable]));
return names.map((name) => existingByName.get(name) ?? defaultRoutineVariable(name));
}
+2 -2
View File
@@ -39,12 +39,12 @@ export interface RoutineVariable {
export interface Routine {
id: string;
companyId: string;
projectId: string;
projectId: string | null;
goalId: string | null;
parentIssueId: string | null;
title: string;
description: string | null;
assigneeAgentId: string;
assigneeAgentId: string | null;
priority: string;
status: string;
concurrencyPolicy: string;
+4 -2
View File
@@ -48,12 +48,12 @@ export const routineVariableSchema = z.object({
});
export const createRoutineSchema = z.object({
projectId: z.string().uuid(),
projectId: z.string().uuid().optional().nullable(),
goalId: z.string().uuid().optional().nullable(),
parentIssueId: z.string().uuid().optional().nullable(),
title: z.string().trim().min(1).max(200),
description: z.string().optional().nullable(),
assigneeAgentId: z.string().uuid(),
assigneeAgentId: z.string().uuid().optional().nullable(),
priority: z.enum(ISSUE_PRIORITIES).optional().default("medium"),
status: z.enum(ROUTINE_STATUSES).optional().default("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(),
payload: z.record(z.unknown()).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(),
source: z.enum(["manual", "api"]).optional().default("manual"),
executionWorkspaceId: z.string().uuid().optional().nullable(),
+47
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();
@@ -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(
+6 -2
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, {
+2 -2
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,
+62 -18
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,
+7 -2
View File
@@ -23,6 +23,8 @@ interface InlineEntitySelectorProps {
renderOption?: (option: InlineEntityOption, isSelected: boolean) => ReactNode;
/** Skip the Portal so the popover stays in the DOM tree (fixes scroll inside Dialogs). */
disablePortal?: boolean;
/** Open the popover when the trigger receives keyboard/programmatic focus. */
openOnFocus?: boolean;
}
export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySelectorProps>(
@@ -40,6 +42,7 @@ export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySe
renderTriggerValue,
renderOption,
disablePortal,
openOnFocus = true,
},
ref,
) {
@@ -103,7 +106,7 @@ export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySe
)}
onPointerDown={() => { isPointerDownRef.current = true; }}
onFocus={() => {
if (!isPointerDownRef.current) setOpen(true);
if (openOnFocus && !isPointerDownRef.current) setOpen(true);
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
// opening the virtual keyboard which reshapes the viewport and
// 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) {
inputRef.current?.focus();
}
+17 -5
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) {
const queryClient = new QueryClient({
defaultOptions: {
@@ -421,13 +433,13 @@ describe("NewIssueDialog", () => {
expect(container.textContent).not.toContain("will no longer use the parent issue workspace");
const selects = Array.from(container.querySelectorAll("select"));
const modeSelect = selects[0] as HTMLSelectElement | undefined;
expect(modeSelect).not.toBeUndefined();
const modeSelect = await waitForValue(
() => container.querySelector("select") as HTMLSelectElement | null,
);
await act(async () => {
modeSelect!.value = "shared_workspace";
modeSelect!.dispatchEvent(new Event("change", { bubbles: true }));
modeSelect.value = "shared_workspace";
modeSelect.dispatchEvent(new Event("change", { bubbles: true }));
});
await flush();
@@ -3,7 +3,7 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
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 { 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", () => {
let container: HTMLDivElement;
@@ -116,7 +143,10 @@ describe("RoutineRunVariablesDialog", () => {
open
onOpenChange={() => {}}
companyId="company-1"
project={createProject()}
projects={[createProject()]}
agents={[createAgent()]}
defaultProjectId="project-1"
defaultAssigneeAgentId="agent-1"
variables={[]}
isPending={false}
onSubmit={() => {}}
@@ -129,6 +159,8 @@ describe("RoutineRunVariablesDialog", () => {
expect(issueWorkspaceDraftCalls).toBeLessThanOrEqual(2);
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 () => {
root.unmount();
+166 -15
View File
@@ -1,9 +1,12 @@
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 { instanceSettingsApi } from "../api/instanceSettings";
import { queryKeys } from "../lib/queryKeys";
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 {
Dialog,
@@ -28,6 +31,16 @@ function buildInitialValues(variables: RoutineVariable[]) {
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) {
if (!project) return null;
return project.executionWorkspacePolicy?.defaultProjectWorkspaceId
@@ -107,6 +120,8 @@ export function routineRunNeedsConfiguration(input: {
export interface RoutineRunDialogSubmitData {
variables?: Record<string, string | number | boolean>;
assigneeAgentId?: string | null;
projectId?: string | null;
executionWorkspaceId?: string | null;
executionWorkspacePreference?: string | null;
executionWorkspaceSettings?: IssueExecutionWorkspaceSettings | null;
@@ -116,7 +131,10 @@ export function RoutineRunVariablesDialog({
open,
onOpenChange,
companyId,
project,
projects,
agents,
defaultProjectId,
defaultAssigneeAgentId,
variables,
isPending,
onSubmit,
@@ -124,13 +142,48 @@ export function RoutineRunVariablesDialog({
open: boolean;
onOpenChange: (open: boolean) => void;
companyId: string | null | undefined;
project: Project | null | undefined;
projects: Project[];
agents: Agent[];
defaultProjectId?: string | null;
defaultAssigneeAgentId?: string | null;
variables: RoutineVariable[];
isPending: boolean;
onSubmit: (data: RoutineRunDialogSubmitData) => void;
}) {
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 { data: experimentalSettings } = useQuery({
@@ -140,16 +193,18 @@ export function RoutineRunVariablesDialog({
});
const workspaceSelectionEnabled = supportsRoutineRunWorkspaceSelection(
project,
selectedProject,
experimentalSettings?.enableIsolatedWorkspaces === true,
);
useEffect(() => {
if (!open) return;
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);
}, [open, project, variables]);
}, [defaultAssigneeAgentId, defaultProjectId, open, projects, variables]);
const missingRequired = useMemo(
() =>
@@ -162,7 +217,7 @@ export function RoutineRunVariablesDialog({
const workspaceIssue = useMemo(() => ({
companyId: companyId ?? null,
projectId: project?.id ?? null,
projectId: selectedProject?.id ?? null,
projectWorkspaceId: workspaceConfig.projectWorkspaceId,
executionWorkspaceId: workspaceConfig.executionWorkspaceId,
executionWorkspacePreference: workspaceConfig.executionWorkspacePreference,
@@ -170,14 +225,17 @@ export function RoutineRunVariablesDialog({
currentExecutionWorkspace: null,
}), [
companyId,
project?.id,
selectedProject?.id,
workspaceConfig.executionWorkspaceId,
workspaceConfig.executionWorkspacePreference,
workspaceConfig.executionWorkspaceSettings,
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>) => {
setWorkspaceConfig((current) => applyWorkspaceDraft(current, data));
@@ -197,11 +255,100 @@ export function RoutineRunVariablesDialog({
<DialogHeader>
<DialogTitle>Run routine</DialogTitle>
<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>
</DialogHeader>
<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) => (
<div key={variable.name} className="space-y-1.5">
<Label className="text-xs">
@@ -259,11 +406,11 @@ export function RoutineRunVariablesDialog({
</div>
))}
{workspaceSelectionEnabled && project && companyId ? (
{workspaceSelectionEnabled && selectedProject && companyId ? (
<IssueWorkspaceCard
key={`${open ? "open" : "closed"}:${project.id}`}
key={`${open ? "open" : "closed"}:${selectedProject.id}`}
issue={workspaceIssue}
project={project}
project={selectedProject}
initialEditing
livePreview
onUpdate={handleWorkspaceUpdate}
@@ -273,7 +420,9 @@ export function RoutineRunVariablesDialog({
</div>
<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">
Missing: {missingRequired.join(", ")}
</p>
@@ -303,6 +452,8 @@ export function RoutineRunVariablesDialog({
}
onSubmit({
variables: nextVariables,
assigneeAgentId: selection.assigneeAgentId,
projectId: selection.projectId || null,
...(workspaceSelectionEnabled
? {
executionWorkspaceId: workspaceConfig.executionWorkspaceId,
+55 -29
View File
@@ -17,7 +17,6 @@ import {
} from "lucide-react";
import { routinesApi, type RoutineTriggerResponse, type RotateRoutineTriggerResponse } from "../api/routines";
import { heartbeatsApi } from "../api/heartbeats";
import { instanceSettingsApi } from "../api/instanceSettings";
import { LiveRunWidget } from "../components/LiveRunWidget";
import { agentsApi } from "../api/agents";
import { projectsApi } from "../api/projects";
@@ -35,7 +34,6 @@ import { InlineEntitySelector, type InlineEntityOption } from "../components/Inl
import { MarkdownEditor, type MarkdownEditorRef } from "../components/MarkdownEditor";
import {
RoutineRunVariablesDialog,
routineRunNeedsConfiguration,
type RoutineRunDialogSubmitData,
} from "../components/RoutineRunVariablesDialog";
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({
trigger,
onSave,
@@ -333,11 +349,6 @@ export function RoutineDetail() {
queryFn: () => projectsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: experimentalSettings } = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
retry: false,
});
const routineDefaults = useMemo(
() =>
@@ -345,8 +356,8 @@ export function RoutineDetail() {
? {
title: routine.title,
description: routine.description ?? "",
projectId: routine.projectId,
assigneeAgentId: routine.assigneeAgentId,
projectId: routine.projectId ?? "",
assigneeAgentId: routine.assigneeAgentId ?? "",
priority: routine.priority,
concurrencyPolicy: routine.concurrencyPolicy,
catchUpPolicy: routine.catchUpPolicy,
@@ -418,10 +429,7 @@ export function RoutineDetail() {
const saveRoutine = useMutation({
mutationFn: () => {
return routinesApi.update(routineId!, {
...editDraft,
description: editDraft.description.trim() || null,
});
return routinesApi.update(routineId!, buildRoutineMutationPayload(editDraft));
},
onSuccess: async () => {
await Promise.all([
@@ -443,6 +451,8 @@ export function RoutineDetail() {
mutationFn: (data?: RoutineRunDialogSubmitData) =>
routinesApi.run(routineId!, {
...(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?.executionWorkspacePreference !== undefined
? { executionWorkspacePreference: data.executionWorkspacePreference }
@@ -657,14 +667,15 @@ export function RoutineDetail() {
}
const automationEnabled = routine.status === "active";
const selectedProject = projects?.find((project) => project.id === routine.projectId) ?? null;
const needsRunConfiguration = routineRunNeedsConfiguration({
variables: routine.variables ?? [],
project: selectedProject,
isolatedWorkspacesEnabled: experimentalSettings?.enableIsolatedWorkspaces === true,
});
const selectedProject = routine.projectId ? (projects?.find((project) => project.id === routine.projectId) ?? null) : null;
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"
? "text-muted-foreground"
: automationEnabled
@@ -708,18 +719,24 @@ export function RoutineDetail() {
<div className="flex shrink-0 items-center gap-3 pt-1">
<RunButton
onClick={() => {
if (needsRunConfiguration) {
setRunVariablesOpen(true);
return;
}
runRoutine.mutate({});
setRunVariablesOpen(true);
}}
disabled={runRoutine.isPending}
/>
<ToggleSwitch
size="lg"
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}
aria-label={automationEnabled ? "Pause automatic triggers" : "Enable automatic triggers"}
/>
@@ -755,6 +772,12 @@ export function RoutineDetail() {
</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 */}
<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">
@@ -853,7 +876,7 @@ export function RoutineDetail() {
bordered={false}
contentClassName="min-h-[120px] text-[15px] leading-7"
onSubmit={() => {
if (!saveRoutine.isPending && editDraft.title.trim() && editDraft.projectId && editDraft.assigneeAgentId) {
if (!saveRoutine.isPending && editDraft.title.trim()) {
saveRoutine.mutate();
}
}}
@@ -921,7 +944,7 @@ export function RoutineDetail() {
)}
<Button
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 routine
@@ -1091,7 +1114,10 @@ export function RoutineDetail() {
open={runVariablesOpen}
onOpenChange={setRunVariablesOpen}
companyId={routine.companyId}
project={selectedProject}
agents={agents ?? []}
projects={projects ?? []}
defaultProjectId={routine.projectId}
defaultAssigneeAgentId={routine.assigneeAgentId}
variables={routine.variables ?? []}
isPending={runRoutine.isPending}
onSubmit={(data) => runRoutine.mutate(data)}
+47 -36
View File
@@ -3,7 +3,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate, useSearchParams } from "@/lib/router";
import { Check, ChevronDown, ChevronRight, Layers, MoreHorizontal, Plus, Repeat } from "lucide-react";
import { routinesApi } from "../api/routines";
import { instanceSettingsApi } from "../api/instanceSettings";
import { agentsApi } from "../api/agents";
import { projectsApi } from "../api/projects";
import { issuesApi } from "../api/issues";
@@ -25,7 +24,6 @@ import { InlineEntitySelector, type InlineEntityOption } from "../components/Inl
import { MarkdownEditor, type MarkdownEditorRef } from "../components/MarkdownEditor";
import {
RoutineRunVariablesDialog,
routineRunNeedsConfiguration,
type RoutineRunDialogSubmitData,
} from "../components/RoutineRunVariablesDialog";
import { RoutineVariablesEditor, RoutineVariablesHint } from "../components/RoutineVariablesEditor";
@@ -117,6 +115,24 @@ function formatRoutineRunStatus(value: string | null | undefined) {
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(
routines: RoutineListItem[],
groupByValue: RoutineGroupBy,
@@ -186,6 +202,7 @@ function RoutineListRow({
const isStatusPending = statusMutationRoutineId === routine.id;
const project = routine.projectId ? projectById.get(routine.projectId) ?? null : null;
const agent = routine.assigneeAgentId ? agentById.get(routine.assigneeAgentId) ?? null : null;
const isDraft = !isArchived && !routine.assigneeAgentId;
return (
<div
@@ -195,9 +212,9 @@ function RoutineListRow({
<div className="min-w-0 flex-1 space-y-1.5">
<div className="flex flex-wrap items-center gap-2">
<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">
{isArchived ? "archived" : "paused"}
{isArchived ? "archived" : isDraft ? "draft" : "paused"}
</span>
) : null}
</div>
@@ -207,11 +224,11 @@ function RoutineListRow({
className="h-2.5 w-2.5 shrink-0 rounded-sm"
style={{ backgroundColor: project?.color ?? "#64748b" }}
/>
<span>{project?.name ?? "Unknown project"}</span>
<span>{routine.projectId ? (project?.name ?? "Unknown project") : "No project"}</span>
</span>
<span className="flex items-center gap-2">
{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>
{formatLastRunTimestamp(routine.lastRun?.triggeredAt)}
@@ -230,7 +247,7 @@ function RoutineListRow({
aria-label={enabled ? `Disable ${routine.title}` : `Enable ${routine.title}`}
/>
<span className="w-12 text-xs text-muted-foreground">
{isArchived ? "Archived" : enabled ? "On" : "Off"}
{isArchived ? "Archived" : isDraft ? "Draft" : enabled ? "On" : "Off"}
</span>
</div>
@@ -334,11 +351,6 @@ export function Routines() {
queryFn: () => projectsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: experimentalSettings } = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
retry: false,
});
const { data: routineExecutionIssues, isLoading: recentRunsLoading, error: recentRunsError } = useQuery({
queryKey: [...queryKeys.issues.list(selectedCompanyId!), "routine-executions"],
queryFn: () => issuesApi.list(selectedCompanyId!, { originKind: "routine_execution" }),
@@ -357,10 +369,7 @@ export function Routines() {
const createRoutine = useMutation({
mutationFn: () =>
routinesApi.create(selectedCompanyId!, {
...draft,
description: draft.description.trim() || null,
}),
routinesApi.create(selectedCompanyId!, buildRoutineMutationPayload(draft)),
onSuccess: async (routine) => {
setDraft({
title: "",
@@ -377,7 +386,9 @@ export function Routines() {
await queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) });
pushToast({
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",
});
navigate(`/routines/${routine.id}?tab=triggers`);
@@ -417,6 +428,8 @@ export function Routines() {
const runRoutine = useMutation({
mutationFn: ({ id, data }: { id: string; data?: RoutineRunDialogSubmitData }) => routinesApi.run(id, {
...(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?.executionWorkspacePreference !== undefined
? { 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 currentProject = draft.projectId ? projectById.get(draft.projectId) ?? null : null;
@@ -517,20 +529,18 @@ export function Routines() {
}
function handleRunNow(routine: RoutineListItem) {
const project = routine.projectId ? projectById.get(routine.projectId) ?? null : null;
const needsConfiguration = routineRunNeedsConfiguration({
variables: routine.variables ?? [],
project,
isolatedWorkspacesEnabled: experimentalSettings?.enableIsolatedWorkspaces === true,
});
if (needsConfiguration) {
setRunDialogRoutine(routine);
return;
}
runRoutine.mutate({ id: routine.id, data: {} });
setRunDialogRoutine(routine);
}
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({
id: routine.id,
status: nextRoutineStatus(routine.status, !enabled),
@@ -648,7 +658,7 @@ export function Routines() {
<div>
<p className="text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">New routine</p>
<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>
</div>
<Button
@@ -798,7 +808,7 @@ export function Routines() {
bordered={false}
contentClassName="min-h-[160px] text-sm text-muted-foreground"
onSubmit={() => {
if (!createRoutine.isPending && draft.title.trim() && draft.projectId && draft.assigneeAgentId) {
if (!createRoutine.isPending && draft.title.trim()) {
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="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 className="flex flex-col gap-2 sm:items-end">
<Button
onClick={() => createRoutine.mutate()}
disabled={
createRoutine.isPending ||
!draft.title.trim() ||
!draft.projectId ||
!draft.assigneeAgentId
!draft.title.trim()
}
>
<Plus className="mr-2 h-4 w-4" />
@@ -965,7 +973,10 @@ export function Routines() {
if (!next) setRunDialogRoutine(null);
}}
companyId={selectedCompanyId}
project={runDialogProject}
agents={agents ?? []}
projects={projects ?? []}
defaultProjectId={runDialogRoutine?.projectId ?? null}
defaultAssigneeAgentId={runDialogRoutine?.assigneeAgentId ?? null}
variables={runDialogRoutine?.variables ?? []}
isPending={runRoutine.isPending}
onSubmit={(data) => {