diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 8725947eca..942d322369 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -334,6 +334,7 @@ export type IssueTreeHoldReleasePolicyStrategy = (typeof ISSUE_TREE_HOLD_RELEASE export const ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY = "continuation-summary" as const; export const PIPELINE_CASE_BODY_DOCUMENT_KEY = "pipeline-case-body" as const; +export const PIPELINE_AUTOMATION_DEFAULT_TITLE_TEMPLATE = "{{pipeline_name}} / {{stage_name}}: {{case_title}}" as const; export const SYSTEM_ISSUE_DOCUMENT_KEYS = [ ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, PIPELINE_CASE_BODY_DOCUMENT_KEY, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ddb7ade519..ea2022a3da 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -233,6 +233,7 @@ export { WORKSPACE_OVERVIEW_DEFAULT_LIMIT, WORKSPACE_OVERVIEW_MAX_LIMIT, WORKSPACE_OVERVIEW_LINKED_ISSUE_LIMIT, + PIPELINE_AUTOMATION_DEFAULT_TITLE_TEMPLATE, PLUGIN_EVENT_TYPES, PLUGIN_BRIDGE_ERROR_CODES, type CompanyStatus, diff --git a/packages/shared/src/types/pipeline.ts b/packages/shared/src/types/pipeline.ts index 1786725814..28bc7095db 100644 --- a/packages/shared/src/types/pipeline.ts +++ b/packages/shared/src/types/pipeline.ts @@ -28,6 +28,7 @@ export interface PipelineCaseConversationSource { export interface PipelineStageAutomation { routineId: string; assigneeAgentId: string | null; + titleTemplate: string; instructionsBody: string; projectId: string | null; projectWorkspaceId: string | null; diff --git a/server/src/__tests__/pipelines-routes.test.ts b/server/src/__tests__/pipelines-routes.test.ts index d06ba78668..cfdc6c1fb4 100644 --- a/server/src/__tests__/pipelines-routes.test.ts +++ b/server/src/__tests__/pipelines-routes.test.ts @@ -39,6 +39,7 @@ import { errorHandler } from "../middleware/error-handler.js"; import { issueRoutes } from "../routes/issues.js"; import { pipelineRoutes } from "../routes/pipelines.js"; import { + PIPELINE_AUTOMATION_DEFAULT_TITLE_TEMPLATE, PIPELINE_CASE_EVENTS_MAX_LIMIT, PIPELINE_CONTEXT_PACK_EVENT_LIMIT, } from "../services/pipelines.js"; @@ -677,6 +678,7 @@ describeEmbeddedPostgres("pipeline routes", () => { const automatedStage = detail.body.stages.find((stage: { key: string }) => stage.key === "in_progress"); expect(automatedStage.config.automation).toMatchObject({ assigneeAgentId: agent.id, + titleTemplate: PIPELINE_AUTOMATION_DEFAULT_TITLE_TEMPLATE, instructionsBody: "Legacy automation body.", projectId: null, projectWorkspaceId: null, @@ -685,6 +687,14 @@ describeEmbeddedPostgres("pipeline routes", () => { executionWorkspaceSettings: null, }); + await db + .update(routines) + .set({ title: "Custom automation for {{case_key}}" }) + .where(eq(routines.id, automatedStage.config.automation.routineId)); + const detailAfterRoutineEdit = await http.get(`/api/pipelines/${pipeline.body.id}`).expect(200); + const editedStage = detailAfterRoutineEdit.body.stages.find((stage: { key: string }) => stage.key === "in_progress"); + expect(editedStage.config.automation.titleTemplate).toBe("Custom automation for {{case_key}}"); + const created = await http .post(`/api/pipelines/${pipeline.body.id}/cases`) .send({ caseKey: "legacy-automation", title: "Legacy automation" }) diff --git a/server/src/__tests__/pipelines-service.test.ts b/server/src/__tests__/pipelines-service.test.ts index ffa6070bd3..847750d182 100644 --- a/server/src/__tests__/pipelines-service.test.ts +++ b/server/src/__tests__/pipelines-service.test.ts @@ -28,7 +28,11 @@ import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; -import { pipelineService, type PipelineActor } from "../services/pipelines.ts"; +import { + PIPELINE_AUTOMATION_DEFAULT_TITLE_TEMPLATE, + pipelineService, + type PipelineActor, +} from "../services/pipelines.ts"; import { routineService } from "../services/routines.ts"; import { instanceSettingsService } from "../services/instance-settings.ts"; @@ -1471,6 +1475,105 @@ describeEmbeddedPostgres("pipelineService", () => { }); }); + it("defaults, preserves, and interpolates pipeline automation issue title templates", async () => { + const { company, pipeline, byKey } = await seedPipeline(); + const routineSeed = await seedRoutine(company.id, "Automation seed"); + const stageId = byKey.get("in_progress")!.id; + + const firstSave = await svc.updateStage({ + companyId: company.id, + pipelineId: pipeline.id, + stageId, + patch: { + config: { + automation: { + assigneeAgentId: routineSeed.assigneeAgentId, + instructionsBody: "Draft from {{body}} for {{case_title}}.", + }, + }, + }, + actor: userActor, + }); + const firstRoutineId = (firstSave.config as { onEnter?: { routineId?: string } }).onEnter?.routineId; + expect(firstRoutineId).toBeTruthy(); + const [defaultRoutine] = await db.select().from(routines).where(eq(routines.id, firstRoutineId!)); + expect(defaultRoutine!.title).toBe(PIPELINE_AUTOMATION_DEFAULT_TITLE_TEMPLATE); + expect((defaultRoutine!.variables ?? []).map((variable) => variable.name)).toEqual([ + "pipeline_name", + "stage_name", + "case_title", + "body", + ]); + + await db + .update(routines) + .set({ title: "Custom {{case_key}}: {{case_title}}" }) + .where(eq(routines.id, firstRoutineId!)); + await svc.updateStage({ + companyId: company.id, + pipelineId: pipeline.id, + stageId, + patch: { + config: { + automation: { + assigneeAgentId: routineSeed.assigneeAgentId, + instructionsBody: "Updated instructions for {{case_title}}.", + }, + }, + }, + actor: userActor, + }); + const [customRoutine] = await db.select().from(routines).where(eq(routines.id, firstRoutineId!)); + expect(customRoutine!.title).toBe("Custom {{case_key}}: {{case_title}}"); + expect((customRoutine!.variables ?? []).map((variable) => variable.name)).toContain("case_key"); + + await db + .update(routines) + .set({ title: "In progress automation" }) + .where(eq(routines.id, firstRoutineId!)); + await svc.updateStage({ + companyId: company.id, + pipelineId: pipeline.id, + stageId, + patch: { + config: { + automation: { + assigneeAgentId: routineSeed.assigneeAgentId, + instructionsBody: "Runtime interpolation for {{case_title}}.", + }, + }, + }, + actor: userActor, + }); + const [upgradedRoutine] = await db.select().from(routines).where(eq(routines.id, firstRoutineId!)); + expect(upgradedRoutine!.title).toBe(PIPELINE_AUTOMATION_DEFAULT_TITLE_TEMPLATE); + + const created = await svc.ingestCase({ + companyId: company.id, + pipelineId: pipeline.id, + caseKey: "pulpit-opinion", + title: "Pulpit opinion piece", + body: "Agentic work should be composed, not rebuilt", + actor: userActor, + }); + const moved = await svc.transitionCase({ + companyId: company.id, + caseId: created.case.id, + toStageKey: "in_progress", + expectedVersion: 1, + actor: userActor, + }); + expect(moved.automationExecution.status).toBe("succeeded"); + const executionIssueId = moved.automationExecution.status === "succeeded" + ? moved.automationExecution.execution.executionIssueId + : null; + const [issue] = await db + .select({ title: issues.title }) + .from(issues) + .where(eq(issues.id, executionIssueId!)); + expect(issue!.title).toBe("Content / In progress: Pulpit opinion piece"); + }); + it("rejects cross-company stage automation routines at save and execution", async () => { const company = await seedCompany(); const otherCompany = await seedCompany(); diff --git a/server/src/routes/pipelines.ts b/server/src/routes/pipelines.ts index 9eca9ac736..6f4f2047a8 100644 --- a/server/src/routes/pipelines.ts +++ b/server/src/routes/pipelines.ts @@ -266,6 +266,7 @@ function withDerivedStageAutomation( stage: typeof pipelineStages.$inferSelect, routineById: Map & { automation?: { routineId?: string | null; assigneeAgentId?: string | null; + titleTemplate?: string | null; instructionsBody?: string | null; projectId?: string | null; projectWorkspaceId?: string | null; @@ -822,15 +829,38 @@ function readStageAutomationRequest(config?: PipelineStageConfig | null) { const automation = config?.automation; if (!automation || typeof automation !== "object" || Array.isArray(automation)) return null; const assigneeAgentId = readOptionalTrimmedString(automation.assigneeAgentId); + const titleTemplate = + typeof automation.titleTemplate === "string" && automation.titleTemplate.trim().length > 0 + ? automation.titleTemplate.trim() + : null; const instructionsBody = typeof automation.instructionsBody === "string" ? automation.instructionsBody : ""; return { assigneeAgentId, + titleTemplate, instructionsBody, executionContext: readAutomationExecutionContext(automation), }; } +function resolvePipelineAutomationTitleTemplate(input: { + requestedTitleTemplate: string | null; + previousRoutine: typeof routines.$inferSelect | null; + stageName: string; + previousStageName: string; +}) { + if (input.requestedTitleTemplate) return input.requestedTitleTemplate; + const previousTitle = input.previousRoutine?.title; + if ( + previousTitle && + previousTitle !== legacyPipelineAutomationTitle(input.previousStageName) && + previousTitle !== legacyPipelineAutomationTitle(input.stageName) + ) { + return previousTitle; + } + return PIPELINE_AUTOMATION_DEFAULT_TITLE_TEMPLATE; +} + function persistedStageConfig(config?: PipelineStageConfig | null): PipelineStageConfig { const { automation: _automation, @@ -1103,6 +1133,7 @@ function derivedStageAutomationPayload( return { routineId: routine.id, assigneeAgentId: routine.assigneeAgentId, + titleTemplate: routine.title, instructionsBody: routine.description ?? "", ...executionContext, env: routine.env ?? null, @@ -2684,14 +2715,16 @@ export function pipelineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeu companyId: string; pipelineId: string; stage: typeof pipelineStages.$inferSelect; + previousStageName: string; + previousRoutineId: string | null; config: PipelineStageConfig; assigneeAgentId: string | null; + titleTemplate: string | null; instructionsBody: string; executionContext: PipelineAutomationExecutionContext; actor: PipelineActor; }, ): Promise { - const previousRoutineId = stageAutomationRoutineIdFromConfig(input.config); if (!input.assigneeAgentId) { const { onEnter: _onEnter, ...rest } = input.config; return rest as PipelineStageConfig; @@ -2699,23 +2732,25 @@ export function pipelineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeu await assertAssignableAgent(dbOrTx as Db, input.companyId, input.assigneeAgentId, { kind: "routine" }); const actorPatch = routineActorPatch(input.actor); - const variables = syncRoutineVariablesWithTemplate( - [input.stage.name, input.instructionsBody], - sanitizePipelineRoutineVariables(input.config.variables), - ); - const title = `${input.stage.name} automation`; - const description = input.instructionsBody.trim(); - - const previousRoutine = previousRoutineId + const previousRoutine = input.previousRoutineId ? await dbOrTx .select() .from(routines) - .where(and(eq(routines.id, previousRoutineId), eq(routines.companyId, input.companyId))) + .where(and(eq(routines.id, input.previousRoutineId), eq(routines.companyId, input.companyId))) .then((rows) => rows[0] ?? null) : null; const canReusePrevious = previousRoutine && (previousRoutine.originKind === "pipeline_automation" || previousRoutine.originKind === "manual"); + const title = resolvePipelineAutomationTitleTemplate({ + requestedTitleTemplate: input.titleTemplate, + previousRoutine: canReusePrevious ? previousRoutine : null, + stageName: input.stage.name, + previousStageName: input.previousStageName, + }); + const configWithVariables = reconcilePipelineStageConfigVariables(input.config, [title, input.instructionsBody]); + const variables = sanitizePipelineRoutineVariables(configWithVariables.variables); + const description = input.instructionsBody.trim(); if (canReusePrevious) { const now = nowDate(); @@ -2742,7 +2777,7 @@ export function pipelineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeu "Updated pipeline automation", ); return { - ...input.config, + ...configWithVariables, onEnter: { type: "run_routine" as const, routineId: revised.id, @@ -2781,7 +2816,7 @@ export function pipelineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeu "Created pipeline automation", ); return { - ...input.config, + ...configWithVariables, onEnter: { type: "run_routine" as const, routineId: revised.id, @@ -3604,7 +3639,10 @@ export function pipelineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeu const stageName = input.patch.name ?? existing.name; let config = normalizeStageConfig(kind, input.patch.config !== undefined ? input.patch.config : stageConfig(existing)); if (automationRequest) { - config = reconcilePipelineStageConfigVariables(config, [stageName, automationRequest.instructionsBody]); + config = reconcilePipelineStageConfigVariables(config, [ + automationRequest.titleTemplate ?? PIPELINE_AUTOMATION_DEFAULT_TITLE_TEMPLATE, + automationRequest.instructionsBody, + ]); } await validateStageTargets(input.companyId, input.pipelineId, kind, config); await validateStageAutomationConfig(input.companyId, config); @@ -3614,8 +3652,11 @@ export function pipelineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeu companyId: input.companyId, pipelineId: input.pipelineId, stage: { ...existing, name: stageName, kind }, + previousStageName: existing.name, + previousRoutineId, config, assigneeAgentId: automationRequest.assigneeAgentId, + titleTemplate: automationRequest.titleTemplate, instructionsBody: automationRequest.instructionsBody, executionContext: automationRequest.executionContext, actor: input.actor ?? { type: "system" }, diff --git a/ui/src/components/RoutineVariablesEditor.tsx b/ui/src/components/RoutineVariablesEditor.tsx index b93091c4b7..3965cd296a 100644 --- a/ui/src/components/RoutineVariablesEditor.tsx +++ b/ui/src/components/RoutineVariablesEditor.tsx @@ -85,7 +85,7 @@ export function RoutineVariablesEditor({

Variables

- Detected from `{"{{name}}"}` placeholders in the routine title and instructions. + Detected from `{"{{name}}"}` placeholders in the title and instructions.

{open ? : } @@ -275,7 +275,7 @@ export function RoutineVariablesHint() { <>
- Use `{"{{variable_name}}"}` placeholders in the instructions to prompt for inputs when the routine runs. + Use `{"{{variable_name}}"}` placeholders in the title or instructions to prompt for inputs when the routine runs.