Hide pipeline-backed routines by default

Refs PAP-10803

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-10 13:40:15 +00:00
1 parent 058d4589d1
commit 3605034ba3
22 files changed
+22153 -40

No files matched your search

@@ -39,7 +39,7 @@ describe("routine and plugin parity commands", () => {
vi.stubGlobal("fetch", fetchMock);
await run(["routine", "list", "--company-id", COMPANY_ID, "--project-id", "p1"]);
await run(["routine", "create", "--company-id", COMPANY_ID, "--payload-json", "{}"]);
await run(["routine", "create", "--company-id", COMPANY_ID, "--payload-json", "{}", "--pipeline", "content"]);
await run(["routine", "get", ROUTINE_ID]);
await run(["routine", "update", ROUTINE_ID, "--payload-json", "{}"]);
await run(["routine", "revisions", ROUTINE_ID]);
@@ -67,6 +67,10 @@ describe("routine and plugin parity commands", () => {
["POST", `http://localhost:3100/api/routine-triggers/${TRIGGER_ID}/rotate-secret`],
["POST", "http://localhost:3100/api/routine-triggers/public/public-id/fire"],
]);
expect(JSON.parse(fetchMock.mock.calls[1][1]?.body as string)).toMatchObject({
originKind: "pipeline_automation",
originId: "content",
});
});
it("wraps deeper plugin endpoints", async () => {
+22 -4
View File
@@ -16,6 +16,7 @@ interface CompanyOptions extends BaseClientOptions {
interface JsonOptions extends CompanyOptions {
payloadJson?: string;
limit?: string;
pipeline?: string;
}
export function registerRoutineApiCommands(program: Command): void {
@@ -37,7 +38,9 @@ export function registerRoutineApiCommands(program: Command): void {
}),
{ includeCompany: false },
);
addCompanyPost(routine, "create", "Create a routine", "routines");
addCompanyPost(routine, "create", "Create a routine", "routines", (command) =>
command.option("--pipeline <idOrKey>", "Mark the routine as backing pipeline automation"),
);
addIdGet(routine, "get", "Get a routine", "routines");
addIdPatch(routine, "update", "Update a routine", "routines");
addIdGet(routine, "revisions", "List routine revisions", "routines", "revisions");
@@ -94,11 +97,22 @@ export function registerRoutineApiCommands(program: Command): void {
);
}
function addCompanyPost(parent: Command, name: string, description: string, path: string): void {
addCommonClientOptions(parent.command(name).description(description).option("-C, --company-id <id>", "Company ID").requiredOption("--payload-json <json>", "JSON payload").action(async (opts: JsonOptions) => {
function addCompanyPost(
parent: Command,
name: string,
description: string,
path: string,
configure?: (command: Command) => Command,
): void {
const command = parent.command(name).description(description).option("-C, --company-id <id>", "Company ID").requiredOption("--payload-json <json>", "JSON payload");
addCommonClientOptions((configure?.(command) ?? command).action(async (opts: JsonOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
printOutput(await ctx.api.post(`${apiPath`/api/companies/${ctx.companyId}`}/${path}`, parseJson(opts.payloadJson ?? "{}")), { json: ctx.json });
const payload = parseJson(opts.payloadJson ?? "{}");
const body = opts.pipeline && isJsonObject(payload)
? { ...payload, originKind: "pipeline_automation", originId: opts.pipeline }
: payload;
printOutput(await ctx.api.post(`${apiPath`/api/companies/${ctx.companyId}`}/${path}`, body), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
@@ -152,3 +166,7 @@ function addIdDelete(parent: Command, name: string, description: string, resourc
function parseJson(value: string): unknown {
return JSON.parse(value) as unknown;
}
function isJsonObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
+3
View File
@@ -198,6 +198,7 @@ export DRAFTING_ROUTINE_ID="$(
paperclipai routine create \
-C "$PAPERCLIP_COMPANY_ID" \
--payload-json "$(jq -c . /tmp/drafting-routine.json)" \
--pipeline "$CONTENT_PIPELINE" \
--json | jq -r '.id'
)"
@@ -209,6 +210,8 @@ paperclipai pipelines set-automation \
--note "Template-versioned with the routine prompt."
```
Routines marked with `--pipeline` are treated as pipeline automation helpers. They are still configurable from `/routines`, but the default routines view groups them under "Pipeline automation" so they do not crowd out hand-managed recurring work during setup.
**Convention:** v1 "templates" version with the routine prompt plus the batch file below, not with the pipeline. The pipeline `guidance` document carries the durable rubric. This is the accepted divergence from the long-term template-on-pipeline shape.
## Step 2: Trigger, Intake, And Gate
@@ -0,0 +1,23 @@
ALTER TABLE "pipeline_case_events" DROP CONSTRAINT "pipeline_case_events_type_check";--> statement-breakpoint
ALTER TABLE "routines" ADD COLUMN "origin_kind" text DEFAULT 'manual' NOT NULL;--> statement-breakpoint
ALTER TABLE "routines" ADD COLUMN "origin_id" text;--> statement-breakpoint
CREATE INDEX "routines_company_origin_idx" ON "routines" USING btree ("company_id","origin_kind","origin_id");--> statement-breakpoint
ALTER TABLE "pipeline_case_events" ADD CONSTRAINT "pipeline_case_events_type_check" CHECK ("pipeline_case_events"."type" in (
'ingested',
'updated',
'claimed',
'lease_released',
'lease_expired',
'transitioned',
'transition_suggested',
'suggestion_resolved',
'review_decided',
'conversation_opened',
'issue_linked',
'issue_unlinked',
'automation_executed',
'automation_failed',
'blockers_set',
'blockers_resolved',
'children_terminal'
));
File diff suppressed because it is too large. Load diff
@@ -708,6 +708,13 @@
"when": 1781059200000,
"tag": "0100_pipeline_case_issue_unlinked_event",
"breakpoints": true
},
{
"idx": 101,
"version": "7",
"when": 1781098769536,
"tag": "0101_special_bloodstorm",
"breakpoints": true
}
]
}
+3
View File
@@ -34,6 +34,8 @@ export const routines = pgTable(
status: text("status").notNull().default("active"),
concurrencyPolicy: text("concurrency_policy").notNull().default("coalesce_if_active"),
catchUpPolicy: text("catch_up_policy").notNull().default("skip_missed"),
originKind: text("origin_kind").notNull().default("manual"),
originId: text("origin_id"),
variables: jsonb("variables").$type<RoutineVariable[]>().notNull().default([]),
env: jsonb("env").$type<RoutineEnvConfig>(),
latestRevisionId: uuid("latest_revision_id"),
@@ -51,6 +53,7 @@ export const routines = pgTable(
companyStatusIdx: index("routines_company_status_idx").on(table.companyId, table.status),
companyAssigneeIdx: index("routines_company_assignee_idx").on(table.companyId, table.assigneeAgentId),
companyProjectIdx: index("routines_company_project_idx").on(table.companyId, table.projectId),
companyOriginIdx: index("routines_company_origin_idx").on(table.companyId, table.originKind, table.originId),
}),
);
+4
View File
@@ -61,6 +61,8 @@ export interface Routine {
status: string;
concurrencyPolicy: string;
catchUpPolicy: string;
originKind?: string;
originId?: string | null;
variables: RoutineVariable[];
env?: RoutineEnvConfig | null;
latestRevisionId: string | null;
@@ -101,6 +103,8 @@ export interface RoutineRevisionSnapshotRoutineV1 {
status: RoutineStatus;
concurrencyPolicy: RoutineConcurrencyPolicy;
catchUpPolicy: RoutineCatchUpPolicy;
originKind?: string;
originId?: string | null;
variables: RoutineVariable[];
env: RoutineEnvConfig | null;
}
@@ -15,6 +15,7 @@ import {
import { envConfigSchema } from "./secret.js";
const routineVariableValueSchema = z.union([z.string(), z.number().finite(), z.boolean()]);
const routineOriginKindSchema = z.string().trim().min(1).max(120).optional().default("manual");
export const routineVariableSchema = z.object({
name: z.string().trim().regex(/^[A-Za-z][A-Za-z0-9_]*$/),
@@ -60,6 +61,8 @@ export const createRoutineSchema = z.object({
status: z.enum(ROUTINE_STATUSES).optional().default("active"),
concurrencyPolicy: z.enum(ROUTINE_CONCURRENCY_POLICIES).optional().default("coalesce_if_active"),
catchUpPolicy: z.enum(ROUTINE_CATCH_UP_POLICIES).optional().default("skip_missed"),
originKind: routineOriginKindSchema,
originId: z.string().trim().max(255).optional().nullable(),
variables: z.array(routineVariableSchema).optional().default([]),
env: envConfigSchema.optional().nullable(),
});
@@ -84,6 +87,8 @@ export const routineRevisionSnapshotRoutineV1Schema = z.object({
status: z.enum(ROUTINE_STATUSES),
concurrencyPolicy: z.enum(ROUTINE_CONCURRENCY_POLICIES),
catchUpPolicy: z.enum(ROUTINE_CATCH_UP_POLICIES),
originKind: z.string().trim().min(1).default("manual"),
originId: z.string().nullable().default(null),
variables: z.array(routineVariableSchema),
env: envConfigSchema.nullable().default(null),
}).strict();
@@ -5,6 +5,7 @@ import request from "supertest";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
agents,
activityLog,
companies,
createDb,
documents,
@@ -56,6 +57,7 @@ describeEmbeddedPostgres("pipeline routes", () => {
}, 20_000);
afterEach(async () => {
await db.delete(activityLog);
await db.delete(pipelineAutomationExecutions);
await db.delete(pipelineCaseBlockers);
await db.delete(pipelineCaseIssueLinks);
@@ -242,6 +244,126 @@ describeEmbeddedPostgres("pipeline routes", () => {
await http.delete(`/api/pipelines/${pipelineId}/stages/${stageId}?moveCasesToStageId=${qaStage.body.id}`).expect(200);
});
it("stamps and clears pipeline automation routine origins when stage wiring changes", async () => {
const company = await seedCompany();
const http = request(app(boardActor));
const pipeline = await http.post(`/api/companies/${company.id}/pipelines`).send({ key: "origin", name: "Origin" }).expect(201);
const [routine] = await db.insert(routines).values({ companyId: company.id, title: "Pipeline backing routine" }).returning();
const intakeStage = pipeline.body.stages.find((stage: { key: string }) => stage.key === "intake");
await http
.patch(`/api/pipelines/${pipeline.body.id}/stages/${intakeStage.id}`)
.send({ config: { onEnter: { type: "run_routine", routineId: routine!.id } } })
.expect(200);
let [routineRow] = await db
.select({ originKind: routines.originKind, originId: routines.originId })
.from(routines)
.where(eq(routines.id, routine!.id));
expect(routineRow).toMatchObject({
originKind: "pipeline_automation",
originId: pipeline.body.id,
});
const qaStage = await http
.post(`/api/pipelines/${pipeline.body.id}/stages`)
.send({
key: "qa",
name: "QA",
kind: "working",
position: 250,
config: { onEnter: { type: "run_routine", routineId: routine!.id } },
})
.expect(201);
await http
.patch(`/api/pipelines/${pipeline.body.id}/stages/${intakeStage.id}`)
.send({ config: {} })
.expect(200);
[routineRow] = await db
.select({ originKind: routines.originKind, originId: routines.originId })
.from(routines)
.where(eq(routines.id, routine!.id));
expect(routineRow).toMatchObject({
originKind: "pipeline_automation",
originId: pipeline.body.id,
});
const secondPipeline = await http
.post(`/api/companies/${company.id}/pipelines`)
.send({ key: "origin-two", name: "Origin two" })
.expect(201);
const secondIntakeStage = secondPipeline.body.stages.find((stage: { key: string }) => stage.key === "intake");
await http
.patch(`/api/pipelines/${secondPipeline.body.id}/stages/${secondIntakeStage.id}`)
.send({ config: { onEnter: { type: "run_routine", routineId: routine!.id } } })
.expect(200);
await http
.patch(`/api/pipelines/${pipeline.body.id}/stages/${qaStage.body.id}`)
.send({ config: {} })
.expect(200);
[routineRow] = await db
.select({ originKind: routines.originKind, originId: routines.originId })
.from(routines)
.where(eq(routines.id, routine!.id));
expect(routineRow).toMatchObject({
originKind: "pipeline_automation",
originId: pipeline.body.id,
});
await http
.patch(`/api/pipelines/${secondPipeline.body.id}/stages/${secondIntakeStage.id}`)
.send({ config: {} })
.expect(200);
[routineRow] = await db
.select({ originKind: routines.originKind, originId: routines.originId })
.from(routines)
.where(eq(routines.id, routine!.id));
expect(routineRow).toMatchObject({
originKind: "manual",
originId: null,
});
const [handCuratedRoutine] = await db.insert(routines).values({
companyId: company.id,
title: "Hand curated",
originKind: "plugin:example",
originId: "plugin-routine",
}).returning();
await http
.post(`/api/pipelines/${pipeline.body.id}/stages`)
.send({
key: "custom",
name: "Custom",
kind: "working",
position: 275,
config: { onEnter: { type: "run_routine", routineId: handCuratedRoutine!.id } },
})
.expect(201);
const [handCuratedRow] = await db
.select({ originKind: routines.originKind, originId: routines.originId })
.from(routines)
.where(eq(routines.id, handCuratedRoutine!.id));
expect(handCuratedRow).toMatchObject({
originKind: "plugin:example",
originId: "plugin-routine",
});
const actions = await db
.select({ action: activityLog.action, entityId: activityLog.entityId })
.from(activityLog)
.where(eq(activityLog.companyId, company.id));
expect(actions).toEqual(expect.arrayContaining([
expect.objectContaining({ action: "routine.origin_stamped", entityId: routine!.id }),
expect.objectContaining({ action: "routine.origin_cleared", entityId: routine!.id }),
]));
});
it("writes an audit event when an agent removes a case issue link", async () => {
const company = await seedCompany();
const [agent] = await db.insert(agents).values({
+49
View File
@@ -242,6 +242,8 @@ describeEmbeddedPostgres("routine routes end-to-end", () => {
expect([200, 201]).toContain(createRes.status);
expect(createRes.body.title).toBe("Daily standup prep");
expect(createRes.body.assigneeAgentId).toBe(agentId);
expect(createRes.body.originKind).toBe("manual");
expect(createRes.body.originId).toBeNull();
const routineId = createRes.body.id as string;
@@ -274,6 +276,7 @@ describeEmbeddedPostgres("routine routes end-to-end", () => {
expect(listRes.status).toBe(200);
const listed = listRes.body.find((r: { id: string }) => r.id === routineId);
expect(listed).toBeDefined();
expect(listed.originKind).toBe("manual");
expect(listed.triggers).toHaveLength(1);
expect(listed.triggers[0].cronExpression).toBe("0 10 * * 1-5");
expect(listed.triggers[0].timezone).toBe("UTC");
@@ -327,6 +330,52 @@ describeEmbeddedPostgres("routine routes end-to-end", () => {
);
}, 15_000);
it("supports create-time routine origin marking and list origin filters", async () => {
const { companyId, agentId, projectId, userId } = await seedFixture();
const app = await createApp({
type: "board",
userId,
source: "session",
isInstanceAdmin: false,
companyIds: [companyId],
});
const manual = await request(app)
.post(`/api/companies/${companyId}/routines`)
.send({
projectId,
title: "Manual routine",
assigneeAgentId: agentId,
})
.expect(201);
const pipeline = await request(app)
.post(`/api/companies/${companyId}/routines`)
.send({
projectId,
title: "Pipeline routine",
assigneeAgentId: agentId,
originKind: "pipeline_automation",
originId: "pipeline-1",
})
.expect(201);
expect(pipeline.body).toMatchObject({
originKind: "pipeline_automation",
originId: "pipeline-1",
});
const pipelineOnly = await request(app)
.get(`/api/companies/${companyId}/routines?originKind=pipeline_automation`)
.expect(200);
expect(pipelineOnly.body.map((routine: { id: string }) => routine.id)).toEqual([pipeline.body.id]);
const manualOnly = await request(app)
.get(`/api/companies/${companyId}/routines?excludeOriginKinds=pipeline_automation`)
.expect(200);
expect(manualOnly.body.map((routine: { id: string }) => routine.id)).toEqual([manual.body.id]);
}, 15_000);
it("runs routines with variable inputs and interpolates the execution issue description", async () => {
const { companyId, agentId, projectId, userId } = await seedFixture();
const app = await createApp({
+4 -3
View File
@@ -392,7 +392,7 @@ export function pipelineRoutes(db: Db, options: Parameters<typeof pipelineServic
router.post("/pipelines/:pipelineId/stages", validate(createStageSchema), async (req, res) => {
const pipelineId = req.params.pipelineId as string;
const companyId = await assertPipelineAccess(db, req, pipelineId);
actorForMutation(req);
const actor = actorForMutation(req);
try {
const stage = await svc.createStage({
companyId,
@@ -402,6 +402,7 @@ export function pipelineRoutes(db: Db, options: Parameters<typeof pipelineServic
kind: req.body.kind,
position: req.body.position,
config: req.body.config,
actor,
});
res.status(201).json(stage);
} catch (error) {
@@ -413,9 +414,9 @@ export function pipelineRoutes(db: Db, options: Parameters<typeof pipelineServic
const pipelineId = req.params.pipelineId as string;
const stageId = req.params.stageId as string;
const companyId = await assertPipelineAccess(db, req, pipelineId);
actorForMutation(req);
const actor = actorForMutation(req);
try {
res.json(await svc.updateStage({ companyId, pipelineId, stageId, patch: req.body }));
res.json(await svc.updateStage({ companyId, pipelineId, stageId, patch: req.body, actor }));
} catch (error) {
codedConflictForUnique(error);
}
+12 -1
View File
@@ -89,7 +89,18 @@ export function routineRoutes(
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const projectId = typeof req.query.projectId === "string" ? req.query.projectId : undefined;
const result = await svc.list(companyId, { projectId });
const originKind = typeof req.query.originKind === "string" ? req.query.originKind : undefined;
const rawExcludeOriginKinds = req.query.excludeOriginKinds;
const excludeOriginKinds = Array.isArray(rawExcludeOriginKinds)
? rawExcludeOriginKinds.flatMap((value) => String(value).split(","))
: typeof rawExcludeOriginKinds === "string"
? rawExcludeOriginKinds.split(",")
: [];
const result = await svc.list(companyId, {
projectId,
originKind,
excludeOriginKinds: excludeOriginKinds.map((value) => value.trim()).filter(Boolean),
});
res.json(result);
});
@@ -4866,6 +4866,8 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
routineDefinition.catchUpPolicy && ROUTINE_CATCH_UP_POLICIES.includes(routineDefinition.catchUpPolicy as any)
? routineDefinition.catchUpPolicy as typeof ROUTINE_CATCH_UP_POLICIES[number]
: "skip_missed",
originKind: "manual",
originId: null,
variables: routineDefinition.variables ?? [],
}, {
agentId: null,
+171 -24
View File
@@ -19,6 +19,7 @@ import {
import { conflict, HttpError, notFound, unprocessable } from "../errors.js";
import { routineService } from "./routines.js";
import type { IssueAssignmentWakeupDeps } from "./issue-assignment-wakeup.js";
import { logActivity } from "./activity-log.js";
const DEFAULT_LEASE_MS = 15 * 60 * 1000;
const MAX_LEASE_MS = 24 * 60 * 60 * 1000;
@@ -89,6 +90,17 @@ function eventActorPatch(actor: PipelineActor) {
return { actorType: "system" };
}
function activityActorPatch(actor: PipelineActor) {
if (actor.type === "agent") {
assertActorProvenance(actor);
return { actorType: "agent" as const, actorId: actor.agentId, agentId: actor.agentId, runId: actor.runId };
}
if (actor.type === "user") {
return { actorType: "user" as const, actorId: actor.userId, agentId: null, runId: null };
}
return { actorType: "system" as const, actorId: "pipeline-automation", agentId: null, runId: null };
}
function assertActorProvenance(actor: PipelineActor) {
if (actor.type === "agent" && !actor.runId) {
throw unprocessable("Agent pipeline mutations require a run id", { code: "run_id_required" });
@@ -222,6 +234,13 @@ function stageAutomation(stage: typeof pipelineStages.$inferSelect) {
};
}
function stageAutomationRoutineIdFromConfig(config?: PipelineStageConfig | null) {
const onEnter = config?.onEnter;
return onEnter?.type === "run_routine" && typeof onEnter.routineId === "string"
? onEnter.routineId
: null;
}
function buildCaseDeepLink(input: { pipelineId: string; caseId: string }) {
return `/PAP/pipelines/${input.pipelineId}/cases/${input.caseId}`;
}
@@ -767,6 +786,79 @@ export function pipelineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeu
await assertRoutineInCompany(companyId, onEnter.routineId);
}
async function stampPipelineAutomationRoutine(
dbOrTx: PipelineDb,
input: { companyId: string; pipelineId: string; routineId: string; actor: PipelineActor },
) {
const updated = await dbOrTx
.update(routines)
.set({ originKind: "pipeline_automation", originId: input.pipelineId, updatedAt: nowDate() })
.where(and(
eq(routines.id, input.routineId),
eq(routines.companyId, input.companyId),
eq(routines.originKind, "manual"),
))
.returning({ id: routines.id });
if (updated.length === 0) return;
const actorPatch = activityActorPatch(input.actor);
await logActivity(dbOrTx as Db, {
companyId: input.companyId,
...actorPatch,
action: "routine.origin_stamped",
entityType: "routine",
entityId: input.routineId,
details: {
originKind: "pipeline_automation",
originId: input.pipelineId,
},
});
}
async function routineStillReferencedByAnyPipeline(
dbOrTx: PipelineDb,
input: { companyId: string; routineId: string; exceptStageId?: string | null },
) {
const stages = await dbOrTx
.select({ id: pipelineStages.id, config: pipelineStages.config })
.from(pipelineStages)
.innerJoin(pipelines, eq(pipelineStages.pipelineId, pipelines.id))
.where(eq(pipelines.companyId, input.companyId));
return stages.some((stage) =>
stage.id !== input.exceptStageId &&
stageAutomationRoutineIdFromConfig((stage.config ?? {}) as PipelineStageConfig) === input.routineId
);
}
async function clearPipelineAutomationRoutineIfUnreferenced(
dbOrTx: PipelineDb,
input: { companyId: string; pipelineId: string; routineId: string; exceptStageId?: string | null; actor: PipelineActor },
) {
const stillReferenced = await routineStillReferencedByAnyPipeline(dbOrTx, input);
if (stillReferenced) return;
const updated = await dbOrTx
.update(routines)
.set({ originKind: "manual", originId: null, updatedAt: nowDate() })
.where(and(
eq(routines.id, input.routineId),
eq(routines.companyId, input.companyId),
eq(routines.originKind, "pipeline_automation"),
))
.returning({ id: routines.id, originId: routines.originId });
if (updated.length === 0) return;
const actorPatch = activityActorPatch(input.actor);
await logActivity(dbOrTx as Db, {
companyId: input.companyId,
...actorPatch,
action: "routine.origin_cleared",
entityType: "routine",
entityId: input.routineId,
details: {
previousOriginKind: "pipeline_automation",
previousOriginId: updated[0]?.originId ?? null,
},
});
}
async function validateStageTargets(companyId: string, pipelineId: string, kind: PipelineStageKind | string, config: PipelineStageConfig) {
if (kind !== "review") return;
const rows = await db
@@ -1184,6 +1276,17 @@ export function pipelineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeu
config: stage.config ?? {},
})))
.returning();
for (const stage of insertedStages) {
const routineId = stageAutomationRoutineIdFromConfig((stage.config ?? {}) as PipelineStageConfig);
if (routineId) {
await stampPipelineAutomationRoutine(tx, {
companyId: input.companyId,
pipelineId: pipeline!.id,
routineId,
actor: input.actor,
});
}
}
if (!insertedStages.some((stage) => stage.kind === "done") || !insertedStages.some((stage) => stage.kind === "cancelled")) {
throw unprocessable("Pipeline must include at least one done stage and one cancelled stage", { code: "validation" });
@@ -1224,23 +1327,35 @@ export function pipelineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeu
kind: PipelineStageKind;
position: number;
config?: PipelineStageConfig;
actor?: PipelineActor;
}) {
await getPipelineOrThrow(db, input.companyId, input.pipelineId);
const config = normalizeStageConfig(input.kind, input.config);
await validateStageTargets(input.companyId, input.pipelineId, input.kind, config);
await validateStageAutomationConfig(input.companyId, config);
const [stage] = await db
.insert(pipelineStages)
.values({
pipelineId: input.pipelineId,
key: input.key,
name: input.name,
kind: input.kind,
position: input.position,
config,
})
.returning();
return stage!;
return db.transaction(async (tx) => {
const [stage] = await tx
.insert(pipelineStages)
.values({
pipelineId: input.pipelineId,
key: input.key,
name: input.name,
kind: input.kind,
position: input.position,
config,
})
.returning();
const routineId = stageAutomationRoutineIdFromConfig(config);
if (routineId) {
await stampPipelineAutomationRoutine(tx, {
companyId: input.companyId,
pipelineId: input.pipelineId,
routineId,
actor: input.actor ?? { type: "system" },
});
}
return stage!;
});
},
async updateStage(input: {
@@ -1254,25 +1369,47 @@ export function pipelineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeu
position?: number;
config?: PipelineStageConfig;
};
actor?: PipelineActor;
}) {
await getPipelineOrThrow(db, input.companyId, input.pipelineId);
const existing = await getStageOrThrow(db, input.pipelineId, input.stageId);
const kind = input.patch.kind ?? existing.kind;
const previousRoutineId = stageAutomationRoutineIdFromConfig(stageConfig(existing));
const config = normalizeStageConfig(kind, input.patch.config !== undefined ? input.patch.config : stageConfig(existing));
const nextRoutineId = stageAutomationRoutineIdFromConfig(config);
await validateStageTargets(input.companyId, input.pipelineId, kind, config);
await validateStageAutomationConfig(input.companyId, config);
const [updated] = await db
.update(pipelineStages)
.set({
...input.patch,
kind,
config,
updatedAt: nowDate(),
})
.where(and(eq(pipelineStages.id, input.stageId), eq(pipelineStages.pipelineId, input.pipelineId)))
.returning();
if (!updated) throw notFound("Pipeline stage not found");
return updated;
return db.transaction(async (tx) => {
const [updated] = await tx
.update(pipelineStages)
.set({
...input.patch,
kind,
config,
updatedAt: nowDate(),
})
.where(and(eq(pipelineStages.id, input.stageId), eq(pipelineStages.pipelineId, input.pipelineId)))
.returning();
if (!updated) throw notFound("Pipeline stage not found");
if (nextRoutineId) {
await stampPipelineAutomationRoutine(tx, {
companyId: input.companyId,
pipelineId: input.pipelineId,
routineId: nextRoutineId,
actor: input.actor ?? { type: "system" },
});
}
if (previousRoutineId && previousRoutineId !== nextRoutineId) {
await clearPipelineAutomationRoutineIfUnreferenced(tx, {
companyId: input.companyId,
pipelineId: input.pipelineId,
routineId: previousRoutineId,
exceptStageId: input.stageId,
actor: input.actor ?? { type: "system" },
});
}
return updated;
});
},
async deleteStage(input: {
@@ -1326,6 +1463,16 @@ export function pipelineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeu
}
await tx.delete(pipelineTransitions).where(or(eq(pipelineTransitions.fromStageId, stage.id), eq(pipelineTransitions.toStageId, stage.id)));
await tx.delete(pipelineStages).where(eq(pipelineStages.id, stage.id));
const routineId = stageAutomationRoutineIdFromConfig(stageConfig(stage));
if (routineId) {
await clearPipelineAutomationRoutineIfUnreferenced(tx, {
companyId: input.companyId,
pipelineId: input.pipelineId,
routineId,
exceptStageId: stage.id,
actor: input.actor ?? { type: "system" },
});
}
return { deleted: true };
});
},
@@ -370,6 +370,8 @@ export function pluginManagedRoutineService(
status: declaration.status ?? (refs.assigneeAgentId ? "active" : "paused"),
concurrencyPolicy: declaration.concurrencyPolicy ?? "coalesce_if_active",
catchUpPolicy: declaration.catchUpPolicy ?? "skip_missed",
originKind: "manual",
originId: null,
variables: declaration.variables ?? [],
}, { agentId: null, userId: null });
await upsertBinding(companyId, declaration, created.id);
+13 -1
View File
@@ -414,6 +414,8 @@ function routineRevisionSnapshotRoutine(routine: RoutineRow): RoutineRevisionSna
status: routine.status as RoutineRevisionSnapshotV1["routine"]["status"],
concurrencyPolicy: routine.concurrencyPolicy as RoutineRevisionSnapshotV1["routine"]["concurrencyPolicy"],
catchUpPolicy: routine.catchUpPolicy as RoutineRevisionSnapshotV1["routine"]["catchUpPolicy"],
originKind: routine.originKind,
originId: routine.originId,
variables: routine.variables ?? [],
env: routine.env ?? null,
};
@@ -1437,10 +1439,14 @@ export function routineService(
list: async (
companyId: string,
filters?: { projectId?: string | null },
filters?: { projectId?: string | null; originKind?: string | null; excludeOriginKinds?: string[] | null },
): Promise<RoutineListItem[]> => {
const conditions = [eq(routines.companyId, companyId)];
if (filters?.projectId) conditions.push(eq(routines.projectId, filters.projectId));
if (filters?.originKind) conditions.push(eq(routines.originKind, filters.originKind));
if (filters?.excludeOriginKinds?.length && !filters.originKind) {
conditions.push(not(inArray(routines.originKind, filters.excludeOriginKinds)));
}
const rows = await db
.select()
@@ -1605,6 +1611,8 @@ export function routineService(
status,
concurrencyPolicy: input.concurrencyPolicy,
catchUpPolicy: input.catchUpPolicy,
originKind: input.originKind ?? "manual",
originId: input.originId ?? null,
variables,
env,
createdByAgentId: actor.agentId ?? null,
@@ -1705,6 +1713,8 @@ export function routineService(
status: nextStatus,
concurrencyPolicy: patch.concurrencyPolicy ?? locked.concurrencyPolicy,
catchUpPolicy: patch.catchUpPolicy ?? locked.catchUpPolicy,
originKind: patch.originKind ?? locked.originKind,
originId: patch.originId === undefined ? locked.originId : patch.originId,
variables: nextVariables,
env: nextEnv,
updatedByAgentId: actor.agentId ?? null,
@@ -1754,6 +1764,8 @@ export function routineService(
status: candidate.status,
concurrencyPolicy: candidate.concurrencyPolicy,
catchUpPolicy: candidate.catchUpPolicy,
originKind: candidate.originKind,
originId: candidate.originId,
variables: candidate.variables,
env: candidate.env,
updatedByAgentId: actor.agentId ?? null,
+20
View File
@@ -0,0 +1,20 @@
import { api } from "./client";
export interface PipelineListItem {
id: string;
companyId: string;
key: string;
name: string;
description: string | null;
projectId: string | null;
enforceTransitions: boolean;
archivedAt: Date | string | null;
stageCount: number;
openCaseCount: number;
createdAt: Date | string;
updatedAt: Date | string;
}
export const pipelinesApi = {
list: (companyId: string) => api.get<PipelineListItem[]>(`/companies/${companyId}/pipelines`),
};
+3 -1
View File
@@ -35,9 +35,11 @@ export interface RestoreRoutineRevisionResponse {
}
export const routinesApi = {
list: (companyId: string, filters?: { projectId?: string | null }) => {
list: (companyId: string, filters?: { projectId?: string | null; originKind?: string | null; excludeOriginKinds?: string[] }) => {
const params = new URLSearchParams();
if (filters?.projectId) params.set("projectId", filters.projectId);
if (filters?.originKind) params.set("originKind", filters.originKind);
if (filters?.excludeOriginKinds?.length) params.set("excludeOriginKinds", filters.excludeOriginKinds.join(","));
const query = params.toString();
return api.get<RoutineListItem[]>(`/companies/${companyId}/routines${query ? `?${query}` : ""}`);
},
+11 -2
View File
@@ -89,8 +89,14 @@ export const queryKeys = {
workProducts: (issueId: string) => ["issues", "work-products", issueId] as const,
},
routines: {
list: (companyId: string, filters?: { projectId?: string | null }) =>
["routines", companyId, filters?.projectId ?? "__all-projects__"] as const,
list: (companyId: string, filters?: { projectId?: string | null; originKind?: string | null; excludeOriginKinds?: string[] }) =>
[
"routines",
companyId,
filters?.projectId ?? "__all-projects__",
filters?.originKind ?? "__all-origins__",
filters?.excludeOriginKinds?.join(",") ?? "__exclude-none__",
] as const,
detail: (id: string) => ["routines", "detail", id] as const,
runs: (id: string) => ["routines", "runs", id] as const,
revisions: (id: string) => ["routines", "revisions", id] as const,
@@ -112,6 +118,9 @@ export const queryKeys = {
list: (companyId: string) => ["projects", companyId] as const,
detail: (id: string) => ["projects", "detail", id] as const,
},
pipelines: {
list: (companyId: string) => ["pipelines", companyId] as const,
},
goals: {
list: (companyId: string) => ["goals", companyId] as const,
detail: (id: string) => ["goals", "detail", id] as const,
+85 -1
View File
@@ -5,12 +5,13 @@ import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Issue, RoutineListItem } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Routines, buildRoutineGroups, sortRoutines } from "./Routines";
import { Routines, buildPipelineAutomationGroups, buildRoutineGroups, sortRoutines } from "./Routines";
let currentSearch = "";
const navigateMock = vi.fn();
const routinesListMock = vi.fn<(companyId: string) => Promise<RoutineListItem[]>>();
const pipelinesListMock = vi.fn<(companyId: string) => Promise<Array<{ id: string; key: string; name: string }>>>();
const issuesListMock = vi.fn<(companyId: string, filters?: Record<string, unknown>) => Promise<Issue[]>>();
const markdownEditorRenderMock = vi.fn((props: { mentions?: Array<{ id: string; name: string }> }) => props);
const issuesListRenderMock = vi.fn(({ issues }: { issues: Issue[] }) => (
@@ -49,6 +50,12 @@ vi.mock("../api/routines", () => ({
},
}));
vi.mock("../api/pipelines", () => ({
pipelinesApi: {
list: (companyId: string) => pipelinesListMock(companyId),
},
}));
vi.mock("../api/issues", () => ({
issuesApi: {
list: (companyId: string, filters?: Record<string, unknown>) => issuesListMock(companyId, filters),
@@ -251,6 +258,8 @@ function createRoutine(overrides: Partial<RoutineListItem>): RoutineListItem {
status: "active",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
originKind: "manual",
originId: null,
variables: [],
latestRevisionId: null,
latestRevisionNumber: 1,
@@ -332,10 +341,12 @@ describe("Routines page", () => {
currentSearch = "";
navigateMock.mockReset();
routinesListMock.mockReset();
pipelinesListMock.mockReset();
issuesListMock.mockReset();
markdownEditorRenderMock.mockClear();
issuesListRenderMock.mockClear();
localStorage.clear();
pipelinesListMock.mockResolvedValue([{ id: "pipeline-1", key: "content", name: "Content pipeline" }]);
});
afterEach(() => {
@@ -417,6 +428,22 @@ describe("Routines page", () => {
expect(routines.map((routine) => routine.id)).toEqual(["routine-1", "routine-2"]);
});
it("groups pipeline automation routines by pipeline", () => {
const groups = buildPipelineAutomationGroups(
[
createRoutine({ id: "routine-1", title: "Draft", originKind: "pipeline_automation", originId: "pipeline-1" }),
createRoutine({ id: "routine-2", title: "Publish", originKind: "pipeline_automation", originId: "pipeline-2" }),
],
new Map([
["pipeline-1", { id: "pipeline-1", key: "content", name: "Content" }],
["pipeline-2", { id: "pipeline-2", key: "release", name: "Release" }],
]),
);
expect(groups.map((group) => group.label)).toEqual(["Content", "Release"]);
expect(groups[0]?.items.map((item) => item.title)).toEqual(["Draft"]);
});
it("renders the routines sort control before the group control", async () => {
routinesListMock.mockResolvedValue([]);
issuesListMock.mockResolvedValue([]);
@@ -536,6 +563,63 @@ describe("Routines page", () => {
});
});
it("keeps pipeline automation routines out of the default list and shows a collapsed automation group", async () => {
routinesListMock.mockResolvedValue([
createRoutine({ id: "routine-1", title: "Morning sync", status: "active" }),
createRoutine({
id: "routine-2",
title: "Pipeline draft",
status: "active",
originKind: "pipeline_automation",
originId: "pipeline-1",
}),
]);
issuesListMock.mockResolvedValue([]);
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<Routines />
</QueryClientProvider>,
);
await flush();
});
for (let attempts = 0; attempts < 5 && !container.textContent?.includes("Pipeline automation"); attempts += 1) {
await act(async () => {
await flush();
});
}
const text = container.textContent ?? "";
expect(text).toContain("1 routine, 1 pipeline automation");
expect(text).toContain("Morning sync");
expect(text).toContain("Pipeline automation");
expect(text).not.toContain("Pipeline draft");
await act(async () => {
const trigger = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Pipeline automation"),
);
trigger?.click();
await flush();
});
expect(container.textContent).toContain("Content pipeline");
expect(container.textContent).toContain("Pipeline draft");
await act(async () => {
root.unmount();
});
});
it("shows an outlined row-level run now button on the routines table", async () => {
routinesListMock.mockResolvedValue([createRoutine({ id: "routine-1", title: "Morning sync" })]);
issuesListMock.mockResolvedValue([]);
+117 -2
View File
@@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useNavigate, useSearchParams } from "@/lib/router";
import { ArrowUpDown, Check, ChevronDown, ChevronRight, Layers, Plus, Repeat } from "lucide-react";
import { routinesApi } from "../api/routines";
import { pipelinesApi, type PipelineListItem } from "../api/pipelines";
import { agentsApi } from "../api/agents";
import { projectsApi } from "../api/projects";
import { issuesApi } from "../api/issues";
@@ -74,6 +75,7 @@ type RoutineViewState = {
sortDir: RoutineSortDir;
groupBy: RoutineGroupBy;
collapsedGroups: string[];
showPipelineAutomationInline: boolean;
};
type RoutineGroup = {
@@ -87,6 +89,7 @@ const defaultRoutineViewState: RoutineViewState = {
sortDir: "asc",
groupBy: "project",
collapsedGroups: [],
showPipelineAutomationInline: false,
};
function getRoutineViewState(key: string): RoutineViewState {
@@ -195,6 +198,26 @@ export function sortRoutines(
});
}
export function buildPipelineAutomationGroups(
routines: RoutineListItem[],
pipelineById: Map<string, Pick<PipelineListItem, "id" | "key" | "name">>,
): RoutineGroup[] {
const groups = groupBy(routines, (routine) => routine.originId ?? "__unknown_pipeline");
return Object.keys(groups)
.sort((left, right) => {
const leftLabel = left === "__unknown_pipeline" ? "Unknown pipeline" : (pipelineById.get(left)?.name ?? "Unknown pipeline");
const rightLabel = right === "__unknown_pipeline" ? "Unknown pipeline" : (pipelineById.get(right)?.name ?? "Unknown pipeline");
return leftLabel.localeCompare(rightLabel);
})
.map((key) => ({
key,
label: key === "__unknown_pipeline"
? "Unknown pipeline"
: (pipelineById.get(key)?.name ?? "Unknown pipeline"),
items: groups[key]!,
}));
}
function buildRoutinesTabHref(tab: RoutinesTab) {
return tab === "runs" ? "/routines?tab=runs" : "/routines";
}
@@ -263,6 +286,11 @@ export function Routines() {
queryFn: () => projectsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: pipelines } = useQuery({
queryKey: queryKeys.pipelines.list(selectedCompanyId!),
queryFn: () => pipelinesApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: companyMembers } = useQuery({
queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!),
queryFn: () => accessApi.listUserDirectory(selectedCompanyId!),
@@ -416,11 +444,25 @@ export function Routines() {
() => new Map((projects ?? []).map((project) => [project.id, project])),
[projects],
);
const pipelineById = useMemo(
() => new Map((pipelines ?? []).map((pipeline) => [pipeline.id, pipeline])),
[pipelines],
);
const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns), [liveRuns]);
const visibleRoutines = useMemo(
const nonArchivedRoutines = useMemo(
() => (routines ?? []).filter((routine) => routine.status !== "archived"),
[routines],
);
const pipelineAutomationRoutines = useMemo(
() => nonArchivedRoutines.filter((routine) => routine.originKind === "pipeline_automation"),
[nonArchivedRoutines],
);
const visibleRoutines = useMemo(
() => routineViewState.showPipelineAutomationInline
? nonArchivedRoutines
: nonArchivedRoutines.filter((routine) => routine.originKind !== "pipeline_automation"),
[nonArchivedRoutines, routineViewState.showPipelineAutomationInline],
);
const sortedRoutines = useMemo(
() => sortRoutines(visibleRoutines, routineViewState.sortField, routineViewState.sortDir),
[routineViewState.sortDir, routineViewState.sortField, visibleRoutines],
@@ -429,6 +471,13 @@ export function Routines() {
() => buildRoutineGroups(sortedRoutines, routineViewState.groupBy, projectById, agentById),
[agentById, projectById, routineViewState.groupBy, sortedRoutines],
);
const pipelineAutomationGroups = useMemo(
() => buildPipelineAutomationGroups(
sortRoutines(pipelineAutomationRoutines, routineViewState.sortField, routineViewState.sortDir),
pipelineById,
),
[pipelineAutomationRoutines, pipelineById, routineViewState.sortDir, routineViewState.sortField],
);
const recentRunsIssueLinkState = useMemo(
() =>
createIssueDetailLocationState(
@@ -521,8 +570,23 @@ export function Routines() {
<div className="flex items-center justify-between gap-3">
<p className="text-sm text-muted-foreground">
{visibleRoutines.length} routine{visibleRoutines.length === 1 ? "" : "s"}
{!routineViewState.showPipelineAutomationInline && pipelineAutomationRoutines.length > 0
? `, ${pipelineAutomationRoutines.length} pipeline automation`
: ""}
</p>
<div className="flex items-center gap-1">
{pipelineAutomationRoutines.length > 0 ? (
<Button
variant="ghost"
size="sm"
className="text-xs"
onClick={() => updateRoutineView({
showPipelineAutomationInline: !routineViewState.showPipelineAutomationInline,
})}
>
{routineViewState.showPipelineAutomationInline ? "Group automation" : "Show automation inline"}
</Button>
) : null}
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="text-xs" title="Sort">
@@ -877,7 +941,7 @@ export function Routines() {
{activeTab === "routines" ? (
<div>
{visibleRoutines.length === 0 ? (
{visibleRoutines.length === 0 && pipelineAutomationRoutines.length === 0 ? (
<div className="py-12">
<EmptyState
icon={Repeat}
@@ -930,6 +994,57 @@ export function Routines() {
</CollapsibleContent>
</Collapsible>
))}
{!routineViewState.showPipelineAutomationInline && pipelineAutomationRoutines.length > 0 ? (
<Collapsible defaultOpen={false}>
<div className="flex items-center gap-2 border-t border-border px-3 py-2 first:border-t-0">
<CollapsibleTrigger className="flex items-center gap-1.5">
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform [[data-state=open]>&]:rotate-90" />
<span className="text-sm font-semibold uppercase tracking-wide">
Pipeline automation
</span>
</CollapsibleTrigger>
<span className="text-xs text-muted-foreground">
{pipelineAutomationRoutines.length} routines
</span>
</div>
<CollapsibleContent>
{pipelineAutomationGroups.map((group) => {
const pipeline = group.key === "__unknown_pipeline" ? null : pipelineById.get(group.key) ?? null;
return (
<div key={group.key} className="border-t border-border/70">
<div className="flex items-center justify-between gap-3 px-6 py-1.5 text-sm">
{pipeline ? (
<Link to={`/pipelines/${pipeline.id}`} className="font-medium text-foreground hover:underline">
{pipeline.name}
</Link>
) : (
<span className="font-medium text-foreground">{group.label}</span>
)}
<span className="text-xs text-muted-foreground">
{group.items.length} routine{group.items.length === 1 ? "" : "s"}
</span>
</div>
{group.items.map((routine) => (
<RoutineListRow
key={routine.id}
routine={routine}
projectById={projectById}
agentById={agentById}
runningRoutineId={runningRoutineId}
statusMutationRoutineId={statusMutationRoutineId}
href={`/routines/${routine.id}`}
runNowButton
onRunNow={handleRunNow}
onToggleEnabled={handleToggleEnabled}
onToggleArchived={handleToggleArchived}
/>
))}
</div>
);
})}
</CollapsibleContent>
</Collapsible>
) : null}
</div>
)}
</div>