mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 18:19:05 +02:00
Hide pipeline-backed routines by default
Refs PAP-10803 Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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 () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`),
|
||||
};
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user