From 3e31bf09bc35fb3e2c7d15308c75ad765b1df866 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 30 Jun 2026 09:52:40 -0500 Subject: [PATCH] [codex] Add pipeline automation title templates (#8787) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Pipeline automations let operators standardize repeated issue and workflow actions. > - Pipeline-created issues currently need a way to derive useful titles from routine variables. > - Without a configurable title template, automated pipeline output is harder to scan and distinguish. > - This pull request adds a title-template field through shared contracts, server persistence, API routes, and the pipeline settings UI. > - The benefit is clearer issue titles for pipeline-created work while preserving the existing pipeline behavior when no template is configured. ## Linked Issues or Issue Description Refs #8790 This PR adds configurable generated-issue title templates for pipeline automations. ## What Changed - Added `issueTitleTemplate` to the shared pipeline automation contract and field constants. - Persisted and returned the title template through pipeline service and route code. - Applied title-template rendering when pipeline automations create issue work. - Added pipeline settings UI controls for editing the title template and reusing routine variables. - Moved title-token cursor restoration out of the React state updater and into a layout effect. - Added server and UI coverage for storing, returning, and rendering pipeline title templates. ## Verification - `NODE_ENV=test pnpm run preflight:workspace-links && NODE_ENV=test pnpm exec vitest run server/src/__tests__/pipelines-service.test.ts server/src/__tests__/pipelines-routes.test.ts ui/src/pages/PipelineSettings.test.ts` - Result before review follow-up: 3 files passed, 58 tests passed. - `pnpm --filter @paperclipai/ui exec vitest run src/pages/PipelineSettings.test.ts` - Result after review follow-up: 1 file passed, 7 tests passed. - Branch was merged with current `paperclipai:master` at `f019f54bb3` before opening this PR. - Searched existing PRs for the same head branch and for pipeline title-template duplicates; no matching existing PR was found. - Note: GitHub could not open a PR directly from `cryppadotta/paperclip` because that repository is not a fork of `paperclipai/paperclip`. The same updated branch SHA was pushed to `paperclipai/paperclip` so this PR can compare normally against `master`. ## Risks Low to moderate risk. The change touches pipeline automation persistence and generated issue creation, so regressions would most likely appear as missing or incorrectly rendered generated issue titles. Existing behavior should remain unchanged when `issueTitleTemplate` is unset. ## Model Used OpenAI Codex, GPT-5-based coding agent, tool-enabled execution in a Paperclip heartbeat, with repository inspection, Git, GitHub CLI, and local test execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- packages/shared/src/constants.ts | 1 + packages/shared/src/index.ts | 1 + packages/shared/src/types/pipeline.ts | 1 + server/src/__tests__/pipelines-routes.test.ts | 10 ++ .../src/__tests__/pipelines-service.test.ts | 105 +++++++++++- server/src/routes/pipelines.ts | 6 + server/src/services/pipelines.ts | 67 ++++++-- ui/src/components/RoutineVariablesEditor.tsx | 4 +- ui/src/pages/IssueDetail.tsx | 6 +- ui/src/pages/PipelineSettings.test.ts | 52 ++++++ ui/src/pages/PipelineSettings.tsx | 156 ++++++++++++++---- 11 files changed, 362 insertions(+), 47 deletions(-) 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.