From 5d021583befc7e3493b3179b45c3b314dd83fba8 Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 9 Apr 2026 10:19:52 -0500 Subject: [PATCH] Add draft routine defaults and run-time overrides --- .../db/src/migrations/0054_draft_routines.sql | 2 + packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/schema/routines.ts | 4 +- packages/shared/src/index.ts | 3 + packages/shared/src/routine-variables.test.ts | 33 ++++ packages/shared/src/routine-variables.ts | 22 ++- packages/shared/src/types/routine.ts | 4 +- packages/shared/src/validators/routine.ts | 6 +- server/src/__tests__/routines-e2e.test.ts | 47 +++++ server/src/__tests__/routines-service.test.ts | 92 +++++++++ server/src/routes/routines.ts | 8 +- server/src/services/company-portability.ts | 4 +- server/src/services/routines.ts | 80 ++++++-- ui/src/components/InlineEntitySelector.tsx | 9 +- .../RoutineRunVariablesDialog.test.tsx | 36 +++- .../components/RoutineRunVariablesDialog.tsx | 181 ++++++++++++++++-- ui/src/pages/RoutineDetail.tsx | 84 +++++--- ui/src/pages/Routines.tsx | 83 ++++---- 18 files changed, 592 insertions(+), 113 deletions(-) create mode 100644 packages/db/src/migrations/0054_draft_routines.sql diff --git a/packages/db/src/migrations/0054_draft_routines.sql b/packages/db/src/migrations/0054_draft_routines.sql new file mode 100644 index 00000000..3169f86a --- /dev/null +++ b/packages/db/src/migrations/0054_draft_routines.sql @@ -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; diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 5fa8cfce..61d03ee4 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -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 } ] } diff --git a/packages/db/src/schema/routines.ts b/packages/db/src/schema/routines.ts index 77bb1b0d..211ce0b4 100644 --- a/packages/db/src/schema/routines.ts +++ b/packages/db/src/schema/routines.ts @@ -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"), diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 78678509..9b125165 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -637,8 +637,11 @@ export { } from "./project-mentions.js"; export { + BUILTIN_ROUTINE_VARIABLE_NAMES, extractRoutineVariableNames, + getBuiltinRoutineVariableValues, interpolateRoutineTemplate, + isBuiltinRoutineVariable, isValidRoutineVariableName, stringifyRoutineVariableValue, syncRoutineVariablesWithTemplate, diff --git a/packages/shared/src/routine-variables.test.ts b/packages/shared/src/routine-variables.test.ts index 9169dbfa..a079ae62 100644 --- a/packages/shared/src/routine-variables.test.ts +++ b/packages/shared/src/routine-variables.test.ts @@ -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`); + }); }); diff --git a/packages/shared/src/routine-variables.ts b/packages/shared/src/routine-variables.ts index 3c12b51c..610af481 100644 --- a/packages/shared/src/routine-variables.ts +++ b/packages/shared/src/routine-variables.ts @@ -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; +/** + * 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 { + 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)); } diff --git a/packages/shared/src/types/routine.ts b/packages/shared/src/types/routine.ts index 774961f2..4efdc748 100644 --- a/packages/shared/src/types/routine.ts +++ b/packages/shared/src/types/routine.ts @@ -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; diff --git a/packages/shared/src/validators/routine.ts b/packages/shared/src/validators/routine.ts index 308ba0ce..2498e47b 100644 --- a/packages/shared/src/validators/routine.ts +++ b/packages/shared/src/validators/routine.ts @@ -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(), diff --git a/server/src/__tests__/routines-e2e.test.ts b/server/src/__tests__/routines-e2e.test.ts index f722699c..12a3d837 100644 --- a/server/src/__tests__/routines-e2e.test.ts +++ b/server/src/__tests__/routines-e2e.test.ts @@ -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(); diff --git a/server/src/__tests__/routines-service.test.ts b/server/src/__tests__/routines-service.test.ts index 13ce62e0..abaac6ac 100644 --- a/server/src/__tests__/routines-service.test.ts +++ b/server/src/__tests__/routines-service.test.ts @@ -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( diff --git a/server/src/routes/routines.ts b/server/src/routes/routines.ts index fc237a51..6a17c06c 100644 --- a/server/src/routes/routines.ts +++ b/server/src/routes/routines.ts @@ -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, { diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index 1c0fc90e..9dc3262f 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -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, diff --git a/server/src/services/routines.ts b/server/src/services/routines.ts index 8cd5ebb7..db117653 100644 --- a/server/src/services/routines.ts +++ b/server/src/services/routines.ts @@ -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 | 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 | null; variables?: Record | null; + projectId?: string | null; + assigneeAgentId?: string | null; idempotencyKey?: string | null; executionWorkspaceId?: string | null; executionWorkspacePreference?: string | null; executionWorkspaceSettings?: Record | 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 => { - 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 => { 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 | null | undefined, variables: input.variables as Record | null | undefined, + projectId: input.projectId ?? null, + assigneeAgentId: input.assigneeAgentId ?? null, idempotencyKey: input.idempotencyKey, executionWorkspaceId: input.executionWorkspaceId ?? null, executionWorkspacePreference: input.executionWorkspacePreference ?? null, diff --git a/ui/src/components/InlineEntitySelector.tsx b/ui/src/components/InlineEntitySelector.tsx index db453b7d..16d62a0d 100644 --- a/ui/src/components/InlineEntitySelector.tsx +++ b/ui/src/components/InlineEntitySelector.tsx @@ -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( @@ -40,6 +42,7 @@ export const InlineEntitySelector = forwardRef { 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 { 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(); diff --git a/ui/src/components/RoutineRunVariablesDialog.tsx b/ui/src/components/RoutineRunVariablesDialog.tsx index 5521c738..0a6f419b 100644 --- a/ui/src/components/RoutineRunVariablesDialog.tsx +++ b/ui/src/components/RoutineRunVariablesDialog.tsx @@ -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; + 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>({}); - 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( + () => + 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( + () => 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) => { setWorkspaceConfig((current) => applyWorkspaceDraft(current, data)); @@ -197,11 +255,100 @@ export function RoutineRunVariablesDialog({ Run routine - 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't be changed.
+
+
+ + { + if (assigneeAgentId) trackRecentAssignee(assigneeAgentId); + setSelection((current) => ({ ...current, assigneeAgentId })); + }} + renderTriggerValue={(option) => + option ? ( + currentAssignee ? ( + <> + + {option.label} + + ) : ( + {option.label} + ) + ) : ( + Select an agent + ) + } + renderOption={(option) => { + if (!option.id) return {option.label}; + const assignee = agents.find((agent) => agent.id === option.id); + return ( + <> + {assignee ? : null} + {option.label} + + ); + }} + /> +
+
+ + { + const project = projects.find((entry) => entry.id === projectId) ?? null; + setSelection((current) => ({ ...current, projectId })); + setWorkspaceConfig(buildInitialWorkspaceConfig(project)); + setWorkspaceConfigValid(true); + }} + renderTriggerValue={(option) => + option && selectedProject ? ( + <> + + {option.label} + + ) : ( + No project + ) + } + renderOption={(option) => { + if (!option.id) return {option.label}; + const project = projects.find((entry) => entry.id === option.id); + return ( + <> + + {option.label} + + ); + }} + /> +
+
+ {variables.map((variable) => (
))} - {workspaceSelectionEnabled && project && companyId ? ( + {workspaceSelectionEnabled && selectedProject && companyId ? ( - {missingRequired.length > 0 ? ( + {!selection.assigneeAgentId ? ( +

Default agent required for this run.

+ ) : missingRequired.length > 0 ? (

Missing: {missingRequired.join(", ")}

@@ -303,6 +452,8 @@ export function RoutineRunVariablesDialog({ } onSubmit({ variables: nextVariables, + assigneeAgentId: selection.assigneeAgentId, + projectId: selection.projectId || null, ...(workspaceSelectionEnabled ? { executionWorkspaceId: workspaceConfig.executionWorkspaceId, diff --git a/ui/src/pages/RoutineDetail.tsx b/ui/src/pages/RoutineDetail.tsx index 18e4fa6f..6356b4b9 100644 --- a/ui/src/pages/RoutineDetail.tsx +++ b/ui/src/pages/RoutineDetail.tsx @@ -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() {
{ - if (needsRunConfiguration) { - setRunVariablesOpen(true); - return; - } - runRoutine.mutate({}); + setRunVariablesOpen(true); }} disabled={runRoutine.isPending} /> 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() {
)} + {!routine.assigneeAgentId ? ( +
+ Default agent required. This routine can stay as a draft and still run manually, but automation stays paused until you assign a default agent. +
+ ) : null} + {/* Assignment row */}
@@ -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() { )}