import { randomUUID } from "node:crypto"; import { eq, sql } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { activityLog, agents, companies, createDb, executionWorkspaces, heartbeatRuns, instanceSettings, issueComments, issueThreadInteractions, issues, pipelineAutomationExecutions, pipelineCaseBlockers, pipelineCaseIssueLinks, pipelineCaseEvents, pipelineCases, pipelineStages, pipelineTransitions, pipelines, projectWorkspaces, projects, routineRuns, routines, } from "@paperclipai/db"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; import { PIPELINE_AUTOMATION_DEFAULT_TITLE_TEMPLATE, pipelineService, type PipelineActor, } from "../services/pipelines.ts"; import { routineService } from "../services/routines.ts"; import { instanceSettingsService } from "../services/instance-settings.ts"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; if (!embeddedPostgresSupport.supported) { console.warn( `Skipping embedded Postgres pipeline service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, ); } describeEmbeddedPostgres("pipelineService", () => { let db!: ReturnType; let svc!: ReturnType; let tempDb: Awaited> | null = null; const userActor: PipelineActor = { type: "user", userId: "board-user" }; const noopHeartbeat = { wakeup: async () => null }; beforeAll(async () => { tempDb = await startEmbeddedPostgresTestDatabase("paperclip-pipelines-service-"); db = createDb(tempDb.connectionString); svc = pipelineService(db, { heartbeat: noopHeartbeat }); }, 20_000); afterEach(async () => { await db.delete(pipelineAutomationExecutions); await db.delete(pipelineCaseBlockers); await db.delete(pipelineCaseIssueLinks); await db.delete(pipelineCaseEvents); await db.delete(pipelineCases); await db.delete(pipelineTransitions); await db.delete(pipelineStages); await db.delete(pipelines); await db.delete(issueThreadInteractions); await db.delete(issueComments); await db.delete(activityLog); await db.delete(routineRuns); await db.delete(heartbeatRuns); await db.delete(issues); await db.delete(executionWorkspaces); await db.delete(routines); await db.delete(projectWorkspaces); await db.delete(projects); await db.delete(agents); await db.delete(companies); await db.delete(instanceSettings); }); afterAll(async () => { await tempDb?.cleanup(); }); async function seedCompany() { const [company] = await db.insert(companies).values({ name: "Pipeline Co", issuePrefix: `P${randomUUID().replace(/-/g, "").slice(0, 6).toUpperCase()}`, defaultResponsibleUserId: "board-user", }).returning(); return company!; } async function seedPipeline(options?: { enforceTransitions?: boolean }) { const company = await seedCompany(); const pipeline = await svc.createPipeline({ companyId: company.id, key: `content-${randomUUID().slice(0, 8)}`, name: "Content", enforceTransitions: options?.enforceTransitions ?? false, actor: userActor, }); const stages = await svc.listStages(company.id, pipeline.id); return { company, pipeline, stages, byKey: new Map(stages.map((stage) => [stage.key, stage])) }; } async function seedRoutine(companyId: string, title = "Routine") { const [agent] = await db.insert(agents).values({ companyId, name: `${title} Agent`, role: "engineer", adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {}, }).returning(); return routineService(db, { heartbeat: noopHeartbeat }).create(companyId, { projectId: null, goalId: null, parentIssueId: null, title, description: null, assigneeAgentId: agent!.id, priority: "medium", status: "active", concurrencyPolicy: "always_enqueue", catchUpPolicy: "skip_missed", }, {}); } async function eventCount(caseId: string) { const [{ count }] = await db .select({ count: sql`count(*)::int` }) .from(pipelineCaseEvents) .where(eq(pipelineCaseEvents.caseId, caseId)); return count ?? 0; } async function seedLinkedIssue(input: { companyId: string; caseId: string; role: "origin" | "conversation" | "work" | "automation"; status?: "backlog" | "todo" | "in_progress" | "in_review" | "done" | "blocked" | "cancelled"; title?: string; }) { const [issue] = await db.insert(issues).values({ companyId: input.companyId, title: input.title ?? `${input.role} issue`, status: input.status ?? "todo", priority: "medium", }).returning(); await db.insert(pipelineCaseIssueLinks).values({ companyId: input.companyId, caseId: input.caseId, issueId: issue!.id, role: input.role, }); return issue!; } it("seeds default stages and protects non-empty stage deletion", async () => { const { company, pipeline, byKey } = await seedPipeline(); expect([...byKey.keys()]).toEqual(["intake", "in_progress", "review", "done", "cancelled"]); const created = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "stage-delete", title: "Stage delete guard", actor: userActor, }); await expect( svc.deleteStage({ companyId: company.id, pipelineId: pipeline.id, stageId: byKey.get("intake")!.id }), ).rejects.toMatchObject({ status: 422, details: { code: "stage_has_cases" } }); await svc.deleteStage({ companyId: company.id, pipelineId: pipeline.id, stageId: byKey.get("intake")!.id, moveCasesToStageId: byKey.get("in_progress")!.id, }); const [moved] = await db.select().from(pipelineCases).where(eq(pipelineCases.id, created.case.id)); expect(moved!.stageId).toBe(byKey.get("in_progress")!.id); }); it("updates parent terminal counts when deleting a stage moves child cases to done", async () => { const { company, pipeline, byKey } = await seedPipeline(); const parent = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, stageKey: "in_progress", caseKey: "delete-stage-parent", title: "Delete stage parent", actor: userActor, }); const child = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "delete-stage-child", title: "Delete stage child", parentCaseId: parent.case.id, actor: userActor, }); await svc.deleteStage({ companyId: company.id, pipelineId: pipeline.id, stageId: byKey.get("intake")!.id, moveCasesToStageId: byKey.get("done")!.id, }); const [freshParent] = await db.select().from(pipelineCases).where(eq(pipelineCases.id, parent.case.id)); const [freshChild] = await db.select().from(pipelineCases).where(eq(pipelineCases.id, child.case.id)); expect(freshParent!.childCount).toBe(1); expect(freshParent!.terminalChildCount).toBe(1); expect(freshChild!.terminalKind).toBe("done"); await expect( svc.transitionCase({ companyId: company.id, caseId: parent.case.id, toStageKey: "done", expectedVersion: parent.case.version, actor: userActor, }), ).resolves.toMatchObject({ case: { terminalKind: "done" } }); }); it("implements idempotent single and batch ingest", async () => { const { company, pipeline } = await seedPipeline(); const first = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "release-1", title: "Release 1", actor: userActor, }); const second = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "release-1", title: "Duplicate title is ignored", actor: userActor, }); expect(first.created).toBe(true); expect(second.created).toBe(false); expect(second.case.id).toBe(first.case.id); expect(await eventCount(first.case.id)).toBe(1); await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "existing-2", title: "Existing 2", actor: userActor, }); const batch = await svc.ingestCases({ companyId: company.id, pipelineId: pipeline.id, actor: userActor, items: [ { caseKey: "new-1", title: "New 1" }, { caseKey: "new-2", title: "New 2" }, { caseKey: "release-1", title: "Existing 1" }, { caseKey: "new-3", title: "New 3" }, { caseKey: "existing-2", title: "Existing 2 again" }, ], }); expect(batch).toHaveLength(5); expect(batch.filter((item) => item.ok && item.created)).toHaveLength(3); const [{ count }] = await db.select({ count: sql`count(*)::int` }).from(pipelineCases); expect(count).toBe(5); }); it("persists workspaceRef during ingest", async () => { const { company, pipeline } = await seedPipeline(); const workspaceRef = { workspacePath: "exports/pipeline-case", name: "Pipeline case files", }; const created = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "workspace-ref", title: "Workspace ref", workspaceRef, actor: userActor, }); expect(created.case.workspaceRef).toEqual(workspaceRef); const [stored] = await db .select({ workspaceRef: pipelineCases.workspaceRef }) .from(pipelineCases) .where(eq(pipelineCases.id, created.case.id)); expect(stored?.workspaceRef).toEqual(workspaceRef); }); it("rejects stale content PATCH without writing an event", async () => { const { company, pipeline } = await seedPipeline(); const created = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "patch", title: "Patch me", actor: userActor, }); await svc.patchCaseContent({ companyId: company.id, caseId: created.case.id, title: "Patched", expectedVersion: 1, actor: userActor, }); const before = await eventCount(created.case.id); await expect( svc.patchCaseContent({ companyId: company.id, caseId: created.case.id, title: "Stale", expectedVersion: 1, actor: userActor, }), ).rejects.toMatchObject({ status: 409, details: { code: "version_conflict", version: 2 } }); expect(await eventCount(created.case.id)).toBe(before); }); it("lets exactly one parallel transition with the same expectedVersion succeed", async () => { const { company, pipeline } = await seedPipeline(); const created = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "parallel", title: "Parallel transition", actor: userActor, }); const attempts = await Promise.allSettled([ svc.transitionCase({ companyId: company.id, caseId: created.case.id, toStageKey: "in_progress", expectedVersion: 1, actor: userActor, }), svc.transitionCase({ companyId: company.id, caseId: created.case.id, toStageKey: "review", expectedVersion: 1, actor: userActor, }), ]); expect(attempts.filter((attempt) => attempt.status === "fulfilled")).toHaveLength(1); expect(attempts.filter((attempt) => attempt.status === "rejected")).toHaveLength(1); const [row] = await db.select().from(pipelineCases).where(eq(pipelineCases.id, created.case.id)); expect(row!.version).toBe(2); expect(await eventCount(created.case.id)).toBe(2); }); it("enforces active leases and lets the holder transition with the lease token", async () => { const { company, pipeline } = await seedPipeline(); const created = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "lease", title: "Leased case", actor: userActor, }); const owner: PipelineActor = { type: "user", userId: "owner" }; const other: PipelineActor = { type: "user", userId: "other" }; const claimed = await svc.claimCase({ companyId: company.id, caseId: created.case.id, actor: owner }); await expect(svc.claimCase({ companyId: company.id, caseId: created.case.id, actor: other })).rejects.toMatchObject({ status: 409, details: { code: "lease_held" }, }); await expect( svc.transitionCase({ companyId: company.id, caseId: created.case.id, toStageKey: "in_progress", expectedVersion: 1, actor: other, }), ).rejects.toMatchObject({ status: 409, details: { code: "lease_held" } }); const transitioned = await svc.transitionCase({ companyId: company.id, caseId: created.case.id, toStageKey: "in_progress", expectedVersion: 1, leaseToken: claimed.leaseToken, actor: owner, }); expect(transitioned.case.version).toBe(2); expect(await eventCount(created.case.id)).toBe(3); }); it("expires leases on read before a new claim", async () => { const { company, pipeline } = await seedPipeline(); const created = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "expired-lease", title: "Expired lease", actor: userActor, }); await db.update(pipelineCases).set({ leaseOwnerType: "user", leaseUserId: "old-owner", leaseToken: randomUUID(), leaseExpiresAt: new Date(Date.now() - 5_000), }).where(eq(pipelineCases.id, created.case.id)); const claimed = await svc.claimCase({ companyId: company.id, caseId: created.case.id, actor: { type: "user", userId: "new-owner" } }); expect(claimed.leaseUserId).toBe("new-owner"); const events = await svc.listCaseEvents(company.id, created.case.id); expect(events.map((event) => event.type)).toEqual(["ingested", "lease_expired", "claimed"]); }); it("enforces transition edges only when enforceTransitions is enabled", async () => { const { company, pipeline } = await seedPipeline({ enforceTransitions: true }); const created = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "edges", title: "Transition edges", actor: userActor, }); await expect( svc.transitionCase({ companyId: company.id, caseId: created.case.id, toStageKey: "done", expectedVersion: 1, actor: userActor, }), ).rejects.toMatchObject({ status: 409, details: { code: "transition_not_allowed" } }); await db.update(pipelines).set({ enforceTransitions: false }).where(eq(pipelines.id, pipeline.id)); const moved = await svc.transitionCase({ companyId: company.id, caseId: created.case.id, toStageKey: "done", expectedVersion: 1, actor: userActor, }); expect(moved.case.terminalKind).toBe("done"); }); it("blocks transitions while blockers are not done", async () => { const { company, pipeline } = await seedPipeline(); const blocked = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "blocked", title: "Blocked case", actor: userActor, }); const blocker = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "blocker", title: "Blocking case", actor: userActor, }); await svc.replaceBlockers({ companyId: company.id, caseId: blocked.case.id, blockedByCaseIds: [blocker.case.id], actor: userActor, }); await expect( svc.transitionCase({ companyId: company.id, caseId: blocked.case.id, toStageKey: "in_progress", expectedVersion: 1, actor: userActor, }), ).rejects.toMatchObject({ status: 409, details: { code: "blocked" } }); const reviewMove = await svc.transitionCase({ companyId: company.id, caseId: blocked.case.id, toStageKey: "review", expectedVersion: 1, actor: userActor, }); expect(reviewMove.case.version).toBe(2); await expect( svc.transitionCase({ companyId: company.id, caseId: blocked.case.id, toStageKey: "done", expectedVersion: 2, actor: userActor, }), ).rejects.toMatchObject({ status: 409, details: { code: "blocked" } }); await svc.transitionCase({ companyId: company.id, caseId: blocker.case.id, toStageKey: "done", expectedVersion: 1, actor: userActor, }); const moved = await svc.transitionCase({ companyId: company.id, caseId: blocked.case.id, toStageKey: "in_progress", expectedVersion: 2, actor: userActor, }); expect(moved.case.version).toBe(3); const events = await svc.listCaseEvents(company.id, blocked.case.id); expect(events.map((event) => event.type)).toContain("blockers_resolved"); }); it("emits blockers_resolved once for each fresh blocker set", async () => { const { company, pipeline } = await seedPipeline(); const blocked = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "blocked-again", title: "Blocked again", actor: userActor, }); const firstBlocker = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "first-blocker", title: "First blocker", actor: userActor, }); const secondBlocker = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "second-blocker", title: "Second blocker", actor: userActor, }); const workIssue = await seedLinkedIssue({ companyId: company.id, caseId: blocked.case.id, role: "work", title: "Blocked work", }); await svc.replaceBlockers({ companyId: company.id, caseId: blocked.case.id, blockedByCaseIds: [firstBlocker.case.id], actor: userActor, }); await svc.transitionCase({ companyId: company.id, caseId: firstBlocker.case.id, toStageKey: "done", expectedVersion: 1, actor: userActor, }); await svc.replaceBlockers({ companyId: company.id, caseId: blocked.case.id, blockedByCaseIds: [secondBlocker.case.id], actor: userActor, }); await svc.transitionCase({ companyId: company.id, caseId: secondBlocker.case.id, toStageKey: "done", expectedVersion: 1, actor: userActor, }); const events = await svc.listCaseEvents(company.id, blocked.case.id); expect(events.filter((event) => event.type === "blockers_resolved")).toHaveLength(2); const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, workIssue.id)); expect(comments).toHaveLength(2); expect(comments.map((comment) => comment.body).join("\n")).toContain(firstBlocker.case.id); expect(comments.map((comment) => comment.body).join("\n")).toContain(secondBlocker.case.id); }); it("keeps cancelled blockers unsatisfied until replaced", async () => { const { company, pipeline } = await seedPipeline(); const blocked = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "blocked-cancelled", title: "Blocked case", actor: userActor, }); const blocker = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "blocker-cancelled", title: "Cancelled blocker", actor: userActor, }); await svc.replaceBlockers({ companyId: company.id, caseId: blocked.case.id, blockedByCaseIds: [blocker.case.id], actor: userActor, }); await svc.transitionCase({ companyId: company.id, caseId: blocker.case.id, toStageKey: "cancelled", expectedVersion: 1, actor: userActor, }); await expect( svc.transitionCase({ companyId: company.id, caseId: blocked.case.id, toStageKey: "in_progress", expectedVersion: 1, actor: userActor, }), ).rejects.toMatchObject({ status: 409, details: { code: "blocked" } }); await svc.replaceBlockers({ companyId: company.id, caseId: blocked.case.id, blockedByCaseIds: [], actor: userActor }); const moved = await svc.transitionCase({ companyId: company.id, caseId: blocked.case.id, toStageKey: "in_progress", expectedVersion: 1, actor: userActor, }); expect(moved.case.version).toBe(2); }); it("posts upstream drift notices to active dependent work issues only", async () => { const { company, pipeline } = await seedPipeline(); const upstream = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "draft", title: "Draft", actor: userActor, }); const workDependent = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "asset-work", title: "Asset work", blockedByCaseIds: [upstream.case.id], actor: userActor, }); const conversationDependent = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "asset-conversation", title: "Asset conversation", blockedByCaseIds: [upstream.case.id], actor: userActor, }); const workIssue = await seedLinkedIssue({ companyId: company.id, caseId: workDependent.case.id, role: "work", title: "Asset work issue", }); const conversationIssue = await seedLinkedIssue({ companyId: company.id, caseId: conversationDependent.case.id, role: "conversation", title: "Conversation issue", }); const updated = await svc.patchCaseContent({ companyId: company.id, caseId: upstream.case.id, title: "Draft v2", expectedVersion: 1, actor: userActor, }); expect(updated.version).toBe(2); const workComments = await db.select().from(issueComments).where(eq(issueComments.issueId, workIssue.id)); expect(workComments).toHaveLength(1); expect(workComments[0]!.authorType).toBe("system"); expect(workComments[0]!.body).toBe( `Upstream case [draft](/PAP/pipelines/${pipeline.id}/cases/${upstream.case.id}) changed (v1→v2).`, ); const conversationComments = await db.select().from(issueComments).where(eq(issueComments.issueId, conversationIssue.id)); expect(conversationComments).toHaveLength(0); }); it("skips upstream drift notices for terminal dependents and dependents without work issues", async () => { const { company, pipeline } = await seedPipeline(); const upstream = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "source", title: "Source", actor: userActor, }); const terminalDependent = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, stageKey: "done", caseKey: "terminal-dependent", title: "Terminal dependent", actor: userActor, }); await svc.replaceBlockers({ companyId: company.id, caseId: terminalDependent.case.id, blockedByCaseIds: [upstream.case.id], actor: userActor, }); const noWorkDependent = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "no-work-dependent", title: "No work dependent", blockedByCaseIds: [upstream.case.id], actor: userActor, }); const terminalIssue = await seedLinkedIssue({ companyId: company.id, caseId: terminalDependent.case.id, role: "work", title: "Terminal work issue", }); const conversationIssue = await seedLinkedIssue({ companyId: company.id, caseId: noWorkDependent.case.id, role: "conversation", title: "Non-work issue", }); await svc.patchCaseContent({ companyId: company.id, caseId: upstream.case.id, summary: "Updated source", expectedVersion: 1, actor: userActor, }); const terminalComments = await db.select().from(issueComments).where(eq(issueComments.issueId, terminalIssue.id)); expect(terminalComments).toHaveLength(0); const conversationComments = await db.select().from(issueComments).where(eq(issueComments.issueId, conversationIssue.id)); expect(conversationComments).toHaveLength(0); }); it("does not bump versions or notify dependents on no-op content patches", async () => { const { company, pipeline } = await seedPipeline(); const upstream = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "noop-source", title: "No-op source", fields: { channel: "blog" }, actor: userActor, }); const dependent = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "noop-dependent", title: "No-op dependent", blockedByCaseIds: [upstream.case.id], actor: userActor, }); const workIssue = await seedLinkedIssue({ companyId: company.id, caseId: dependent.case.id, role: "work", title: "No-op work issue", }); const beforeEvents = await eventCount(upstream.case.id); const patched = await svc.patchCaseContent({ companyId: company.id, caseId: upstream.case.id, title: "No-op source", fields: { channel: "blog" }, expectedVersion: 1, actor: userActor, }); expect(patched.version).toBe(1); expect(await eventCount(upstream.case.id)).toBe(beforeEvents); const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, workIssue.id)); expect(comments).toHaveLength(0); }); it("resolves in-batch forward blocker case keys", async () => { const { company, pipeline } = await seedPipeline(); const results = await svc.ingestCases({ companyId: company.id, pipelineId: pipeline.id, items: [ { caseKey: "tweet", title: "Tweet", blockedByCaseKeys: ["image", "post"] }, { caseKey: "image", title: "Image" }, { caseKey: "post", title: "Post" }, ], actor: userActor, }); expect(results.map((result) => result.ok)).toEqual([true, true, true]); const successful = results.filter((result): result is Extract<(typeof results)[number], { ok: true }> => result.ok); const byKey = new Map(successful .map((result) => [result.case.caseKey, result.case.id])); const blockers = await db .select() .from(pipelineCaseBlockers) .where(eq(pipelineCaseBlockers.caseId, byKey.get("tweet")!)); expect(blockers.map((row) => row.blockedByCaseId).sort()).toEqual([ byKey.get("image")!, byKey.get("post")!, ].sort()); const events = await svc.listCaseEvents(company.id, byKey.get("tweet")!); const blockersEvent = events.find((event) => event.type === "blockers_set"); expect(blockersEvent?.payload).toMatchObject({ blockedByCaseIds: expect.arrayContaining([byKey.get("image")!, byKey.get("post")!]), blockedByCaseKeys: ["image", "post"], }); }); it("resolves blocker case keys against existing cases", async () => { const { company, pipeline } = await seedPipeline(); const asset = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "asset", title: "Asset", actor: userActor, }); const created = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "tweet", title: "Tweet", blockedByCaseKeys: ["asset"], actor: userActor, }); const blockers = await db .select() .from(pipelineCaseBlockers) .where(eq(pipelineCaseBlockers.caseId, created.case.id)); expect(blockers.map((row) => row.blockedByCaseId)).toEqual([asset.case.id]); }); it("fails only unresolved blocker-key rows in batch ingest", async () => { const { company, pipeline } = await seedPipeline(); const results = await svc.ingestCases({ companyId: company.id, pipelineId: pipeline.id, items: [ { caseKey: "ok", title: "OK" }, { caseKey: "missing", title: "Missing", blockedByCaseKeys: ["does-not-exist"] }, { caseKey: "after", title: "After" }, ], actor: userActor, }); expect(results[0]).toMatchObject({ ok: true }); expect(results[1]).toMatchObject({ ok: false, caseKey: "missing", error: { status: 404, details: { code: "blocker_case_key_not_found", missingCaseKeys: ["does-not-exist"] }, }, }); expect(results[2]).toMatchObject({ ok: true }); const rows = await db.select().from(pipelineCases).where(eq(pipelineCases.pipelineId, pipeline.id)); expect(rows.map((row) => row.caseKey).sort()).toEqual(["after", "ok"]); }); it("rejects blocker cycles declared by batch case keys", async () => { const { company, pipeline } = await seedPipeline(); const results = await svc.ingestCases({ companyId: company.id, pipelineId: pipeline.id, items: [ { caseKey: "a", title: "A", blockedByCaseKeys: ["b"] }, { caseKey: "b", title: "B", blockedByCaseKeys: ["a"] }, ], actor: userActor, }); expect(results).toEqual([ expect.objectContaining({ ok: false, caseKey: "a", error: expect.objectContaining({ status: 409, details: { code: "blocker_cycle", blockedByCaseKeys: ["b"] } }), }), expect.objectContaining({ ok: false, caseKey: "b", error: expect.objectContaining({ status: 409, details: { code: "blocker_cycle", blockedByCaseKeys: ["a"] } }), }), ]); const rows = await db.select().from(pipelineCases).where(eq(pipelineCases.pipelineId, pipeline.id)); expect(rows).toHaveLength(0); }); it("rejects parent and blocker cycles and enforces parent depth", async () => { const { company, pipeline } = await seedPipeline(); const a = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "a", title: "A", actor: userActor }); const b = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "b", title: "B", parentCaseId: a.case.id, actor: userActor, }); await expect( svc.patchCaseContent({ companyId: company.id, caseId: a.case.id, parentCaseId: b.case.id, expectedVersion: 1, actor: userActor, }), ).rejects.toMatchObject({ status: 409, details: { code: "parent_cycle" } }); await svc.replaceBlockers({ companyId: company.id, caseId: a.case.id, blockedByCaseIds: [b.case.id], actor: userActor }); await expect( svc.replaceBlockers({ companyId: company.id, caseId: b.case.id, blockedByCaseIds: [a.case.id], actor: userActor }), ).rejects.toMatchObject({ status: 409, details: { code: "blocker_cycle" } }); let parentCaseId: string | null = null; for (let index = 0; index < 32; index += 1) { const created = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: `chain-${index}`, title: `Chain ${index}`, parentCaseId, actor: userActor, }); parentCaseId = created.case.id; } await expect( svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "too-deep", title: "Too deep", parentCaseId, actor: userActor, }), ).rejects.toMatchObject({ status: 422, details: { code: "parent_depth_exceeded" } }); }); it("rolls up a three-level tree, updates counters, and emits children_terminal once", async () => { const { company, pipeline } = await seedPipeline(); const root = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "root", title: "Root", actor: userActor }); const [linkedIssue] = await db.insert(issues).values({ companyId: company.id, title: "Root conversation", status: "todo", priority: "medium", }).returning(); await db.insert(pipelineCaseIssueLinks).values({ companyId: company.id, caseId: root.case.id, issueId: linkedIssue!.id, role: "conversation", }); const childA = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "child-a", title: "Child A", parentCaseId: root.case.id, actor: userActor, }); const childB = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "child-b", title: "Child B", parentCaseId: root.case.id, actor: userActor, }); const childC = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "child-c", title: "Child C", parentCaseId: root.case.id, actor: userActor, }); const grandA = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "grand-a", title: "Grand A", parentCaseId: childA.case.id, actor: userActor, }); const grandB = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "grand-b", title: "Grand B", parentCaseId: childA.case.id, actor: userActor, }); await svc.transitionCase({ companyId: company.id, caseId: childB.case.id, toStageKey: "done", expectedVersion: 1, actor: userActor }); await svc.transitionCase({ companyId: company.id, caseId: childC.case.id, toStageKey: "done", expectedVersion: 1, actor: userActor }); await svc.transitionCase({ companyId: company.id, caseId: grandA.case.id, toStageKey: "done", expectedVersion: 1, actor: userActor }); await svc.transitionCase({ companyId: company.id, caseId: grandB.case.id, toStageKey: "cancelled", expectedVersion: 1, actor: userActor }); await svc.transitionCase({ companyId: company.id, caseId: childA.case.id, toStageKey: "done", expectedVersion: 1, actor: userActor }); expect(await svc.getCaseRollup(company.id, root.case.id)).toEqual({ total: 5, done: 4, cancelled: 1, open: 0, complete: true, }); const [freshRoot] = await db.select().from(pipelineCases).where(eq(pipelineCases.id, root.case.id)); const [freshChildA] = await db.select().from(pipelineCases).where(eq(pipelineCases.id, childA.case.id)); expect(freshRoot!.childCount).toBe(3); expect(freshRoot!.terminalChildCount).toBe(3); expect(freshChildA!.childCount).toBe(2); expect(freshChildA!.terminalChildCount).toBe(2); const rootEvents = await svc.listCaseEvents(company.id, root.case.id); expect(rootEvents.filter((event) => event.type === "children_terminal")).toHaveLength(1); const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, linkedIssue!.id)); expect(comments).toHaveLength(1); expect(comments[0]!.authorType).toBe("system"); expect(comments[0]!.body).toContain("All child cases"); }); it("auto-advances a parent when all descendants are terminal", async () => { const company = await seedCompany(); const pipeline = await svc.createPipeline({ companyId: company.id, key: "auto-children", name: "Auto children", actor: userActor, stages: [ { key: "intake", name: "Intake", kind: "open", config: { autoAdvanceOnChildrenTerminal: "done" } }, { key: "done", name: "Done", kind: "done" }, { key: "cancelled", name: "Cancelled", kind: "cancelled" }, ], }); const root = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "auto-root", title: "Root", actor: userActor }); const child = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "auto-child", title: "Child", parentCaseId: root.case.id, actor: userActor, }); await svc.transitionCase({ companyId: company.id, caseId: child.case.id, toStageKey: "done", expectedVersion: 1, actor: userActor }); const [freshRoot] = await db.select().from(pipelineCases).where(eq(pipelineCases.id, root.case.id)); expect(freshRoot!.terminalKind).toBe("done"); expect(freshRoot!.version).toBe(2); const rootEvents = await svc.listCaseEvents(company.id, root.case.id); expect(rootEvents.map((event) => event.type)).toEqual(["ingested", "children_terminal", "transitioned"]); }); it("auto-advances a leased parent when child completion triggers a system transition", async () => { const company = await seedCompany(); const pipeline = await svc.createPipeline({ companyId: company.id, key: "auto-children-lease", name: "Auto children lease", actor: userActor, stages: [ { key: "intake", name: "Intake", kind: "open", config: { autoAdvanceOnChildrenTerminal: "done" } }, { key: "done", name: "Done", kind: "done" }, { key: "cancelled", name: "Cancelled", kind: "cancelled" }, ], }); const root = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "leased-root", title: "Root", actor: userActor }); const child = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "leased-child", title: "Child", parentCaseId: root.case.id, actor: userActor, }); await svc.claimCase({ companyId: company.id, caseId: root.case.id, actor: { type: "user", userId: "reviewer" }, }); await svc.transitionCase({ companyId: company.id, caseId: child.case.id, toStageKey: "done", expectedVersion: 1, actor: userActor }); const [freshRoot] = await db.select().from(pipelineCases).where(eq(pipelineCases.id, root.case.id)); expect(freshRoot!.terminalKind).toBe("done"); expect(freshRoot!.leaseToken).toBeNull(); const rootEvents = await svc.listCaseEvents(company.id, root.case.id); expect(rootEvents.map((event) => event.type)).toEqual(["ingested", "claimed", "children_terminal", "transitioned"]); }); it("keeps child completion committed when parent children-terminal auto-advance is gated", async () => { const company = await seedCompany(); const pipeline = await svc.createPipeline({ companyId: company.id, key: "auto-children-blocked", name: "Auto children blocked", actor: userActor, stages: [ { key: "intake", name: "Intake", kind: "open", config: { autoAdvanceOnChildrenTerminal: "done" } }, { key: "done", name: "Done", kind: "done" }, { key: "cancelled", name: "Cancelled", kind: "cancelled" }, ], }); const root = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "blocked-root", title: "Root", actor: userActor }); const child = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "blocked-child", title: "Child", parentCaseId: root.case.id, actor: userActor, }); const blocker = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "open-blocker", title: "Open blocker", actor: userActor, }); await svc.replaceBlockers({ companyId: company.id, caseId: root.case.id, blockedByCaseIds: [blocker.case.id], actor: userActor, }); await expect( svc.transitionCase({ companyId: company.id, caseId: child.case.id, toStageKey: "done", expectedVersion: 1, actor: userActor }), ).resolves.toMatchObject({ case: { terminalKind: "done" } }); const [freshRoot] = await db.select().from(pipelineCases).where(eq(pipelineCases.id, root.case.id)); const [freshChild] = await db.select().from(pipelineCases).where(eq(pipelineCases.id, child.case.id)); expect(freshRoot!.terminalKind).toBeNull(); expect(freshRoot!.terminalChildCount).toBe(1); expect(freshChild!.terminalKind).toBe("done"); const rootEvents = await svc.listCaseEvents(company.id, root.case.id); expect(rootEvents.map((event) => event.type)).toEqual(["ingested", "blockers_set", "children_terminal"]); }); it("records suggestion supersede, accept, and dismiss lifecycles", async () => { const { company, pipeline } = await seedPipeline(); const created = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "suggest-accept", title: "Suggestion accept", actor: userActor, }); const first = await svc.suggestTransition({ companyId: company.id, caseId: created.case.id, toStageKey: "review", rationale: "Needs review", actor: userActor, }); const second = await svc.suggestTransition({ companyId: company.id, caseId: created.case.id, toStageKey: "in_progress", rationale: "Actually draft first", actor: userActor, }); expect(second.suggestion.id).not.toBe(first.suggestion.id); const accepted = await svc.resolveSuggestion({ companyId: company.id, caseId: created.case.id, suggestionId: second.suggestion.id, decision: "accept", expectedVersion: 1, actor: userActor, }); expect(accepted.case.version).toBe(2); const acceptEvents = await svc.listCaseEvents(company.id, created.case.id); expect(acceptEvents.map((event) => event.type)).toEqual([ "ingested", "transition_suggested", "transition_suggested", "transitioned", "suggestion_resolved", ]); const dismissCase = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "suggest-dismiss", title: "Suggestion dismiss", actor: userActor, }); const suggestion = await svc.suggestTransition({ companyId: company.id, caseId: dismissCase.case.id, toStageKey: "review", rationale: "Maybe review", actor: userActor, }); await svc.resolveSuggestion({ companyId: company.id, caseId: dismissCase.case.id, suggestionId: suggestion.suggestion.id, decision: "dismiss", reason: "Not ready", actor: userActor, }); const [dismissed] = await db.select().from(pipelineCases).where(eq(pipelineCases.id, dismissCase.case.id)); expect(dismissed!.pendingSuggestion).toBeNull(); expect(dismissed!.version).toBe(1); }); it("writes an event for each case mutation and rejects agent mutations without run provenance", async () => { const { company, pipeline } = await seedPipeline(); const agentActor = { type: "agent", agentId: randomUUID() } as PipelineActor; await expect( svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "bad-agent", title: "Bad provenance", actor: agentActor, }), ).rejects.toMatchObject({ status: 422, details: { code: "run_id_required" } }); const created = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "events", title: "Events", actor: userActor, }); expect(await eventCount(created.case.id)).toBe(1); await svc.patchCaseContent({ companyId: company.id, caseId: created.case.id, title: "Updated", actor: userActor }); expect(await eventCount(created.case.id)).toBe(2); const claimed = await svc.claimCase({ companyId: company.id, caseId: created.case.id, actor: { type: "user", userId: "claimer" } }); expect(await eventCount(created.case.id)).toBe(3); await svc.releaseCase({ companyId: company.id, caseId: created.case.id, leaseToken: claimed.leaseToken, actor: { type: "user", userId: "claimer" } }); expect(await eventCount(created.case.id)).toBe(4); await svc.transitionCase({ companyId: company.id, caseId: created.case.id, toStageKey: "in_progress", expectedVersion: 2, actor: userActor, }); expect(await eventCount(created.case.id)).toBe(5); }); it("fires a stage-entry automation routine once and keeps crash-retry idempotent", async () => { const company = await seedCompany(); const routine = await seedRoutine(company.id, "Draft on enter"); const pipeline = await svc.createPipeline({ companyId: company.id, key: "automation", name: "Automation", actor: userActor, stages: [ { key: "intake", name: "Intake", kind: "open" }, { key: "drafting", name: "Drafting", kind: "working", config: { onEnter: { type: "run_routine", routineId: routine.id } } }, { key: "done", name: "Done", kind: "done" }, { key: "cancelled", name: "Cancelled", kind: "cancelled" }, ], }); const created = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "automation", title: "Automation case", actor: userActor, }); const moved = await svc.transitionCase({ companyId: company.id, caseId: created.case.id, toStageKey: "drafting", expectedVersion: 1, actor: userActor, }); expect(moved.automationLedger?.routineId).toBe(routine.id); expect(moved.automationExecution.status).toBe("succeeded"); const ledgers = await db.select().from(pipelineAutomationExecutions); expect(ledgers).toHaveLength(1); expect(ledgers[0]!.triggeringEventId).toBe(moved.event.id); expect(ledgers[0]!.executionIssueId).toBeTruthy(); const runsAfterTransition = await db.select().from(routineRuns); expect(runsAfterTransition).toHaveLength(1); const linksAfterTransition = await db.select().from(pipelineCaseIssueLinks); expect(linksAfterTransition).toHaveLength(1); expect(linksAfterTransition[0]!.role).toBe("automation"); const [issue] = await db.select().from(issues).where(eq(issues.id, ledgers[0]!.executionIssueId!)); expect(issue!.description).toContain("Pipeline Case Context"); expect(issue!.description).toContain("untrustedContent"); const triggerEvent = await db.insert(pipelineCaseEvents).values({ companyId: company.id, caseId: created.case.id, type: "transitioned", actorType: "system", toStageId: moved.case.stageId, payload: { simulatedCrash: true }, }).returning(); const automationId = ledgers[0]!.automationId; await db.insert(pipelineAutomationExecutions).values({ companyId: company.id, caseId: created.case.id, automationId, triggeringEventId: triggerEvent[0]!.id, routineId: routine.id, status: "failed", error: "pending_dispatch", }); const firstRetry = await svc.retryAutomation({ companyId: company.id, caseId: created.case.id, automationId, actor: userActor, }); const secondRetry = await svc.retryAutomation({ companyId: company.id, caseId: created.case.id, automationId, actor: userActor, }); expect(firstRetry.status).toBe("succeeded"); expect(secondRetry.status).toBe("succeeded"); const runsAfterRetries = await db.select().from(routineRuns); expect(runsAfterRetries).toHaveLength(2); const crashExecutions = await db .select() .from(pipelineAutomationExecutions) .where(eq(pipelineAutomationExecutions.triggeringEventId, triggerEvent[0]!.id)); expect(crashExecutions).toHaveLength(1); expect(crashExecutions[0]!.executionIssueId).toBeTruthy(); const crashLinks = await db .select() .from(pipelineCaseIssueLinks) .where(eq(pipelineCaseIssueLinks.issueId, crashExecutions[0]!.executionIssueId!)); expect(crashLinks).toHaveLength(1); }); it("carries saved stage automation workspace context into the execution issue", async () => { const { company, pipeline, byKey } = await seedPipeline(); const routineSeed = await seedRoutine(company.id, "Workspace automation seed"); const projectId = randomUUID(); const projectWorkspaceId = randomUUID(); const executionWorkspaceId = randomUUID(); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); await db.insert(projects).values({ id: projectId, companyId: company.id, name: "Automation project", status: "in_progress", }); await db.insert(projectWorkspaces).values({ id: projectWorkspaceId, companyId: company.id, projectId, name: "Automation workspace", isPrimary: true, sharedWorkspaceKey: "pipeline-automation-primary", }); await db.insert(executionWorkspaces).values({ id: executionWorkspaceId, companyId: company.id, projectId, projectWorkspaceId, mode: "isolated_workspace", strategyType: "git_worktree", name: "Automation worktree", status: "active", providerType: "git_worktree", }); const updatedStage = await svc.updateStage({ companyId: company.id, pipelineId: pipeline.id, stageId: byKey.get("in_progress")!.id, patch: { config: { automation: { assigneeAgentId: routineSeed.assigneeAgentId, instructionsBody: "Use the selected workspace.", projectId, projectWorkspaceId, executionWorkspaceId, executionWorkspacePreference: "reuse_existing", executionWorkspaceSettings: { mode: "isolated_workspace" }, }, }, }, actor: userActor, }); expect((updatedStage.config as { onEnter?: unknown }).onEnter).toMatchObject({ type: "run_routine", projectId, projectWorkspaceId, executionWorkspaceId, executionWorkspacePreference: "reuse_existing", executionWorkspaceSettings: { mode: "isolated_workspace" }, }); const created = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "workspace-context", title: "Workspace context case", actor: userActor, }); const moved = await svc.transitionCase({ companyId: company.id, caseId: created.case.id, toStageKey: "in_progress", expectedVersion: 1, actor: userActor, }); expect(moved.automationExecution.status).toBe("succeeded"); const executionIssueId = moved.automationExecution.status === "succeeded" ? moved.automationExecution.execution.executionIssueId : null; const [issue] = await db .select({ projectId: issues.projectId, projectWorkspaceId: issues.projectWorkspaceId, executionWorkspaceId: issues.executionWorkspaceId, executionWorkspacePreference: issues.executionWorkspacePreference, executionWorkspaceSettings: issues.executionWorkspaceSettings, }) .from(issues) .where(eq(issues.id, executionIssueId!)); expect(issue).toEqual({ projectId, projectWorkspaceId, executionWorkspaceId, executionWorkspacePreference: "reuse_existing", executionWorkspaceSettings: { mode: "isolated_workspace" }, }); }); it("defaults, preserves, and interpolates pipeline automation issue title templates", async () => { const { company, pipeline, byKey } = await seedPipeline(); const routineSeed = await seedRoutine(company.id, "Automation seed"); const stageId = byKey.get("in_progress")!.id; const firstSave = await svc.updateStage({ companyId: company.id, pipelineId: pipeline.id, stageId, patch: { config: { automation: { assigneeAgentId: routineSeed.assigneeAgentId, instructionsBody: "Draft from {{body}} for {{case_title}}.", }, }, }, actor: userActor, }); const firstRoutineId = (firstSave.config as { onEnter?: { routineId?: string } }).onEnter?.routineId; expect(firstRoutineId).toBeTruthy(); const [defaultRoutine] = await db.select().from(routines).where(eq(routines.id, firstRoutineId!)); expect(defaultRoutine!.title).toBe(PIPELINE_AUTOMATION_DEFAULT_TITLE_TEMPLATE); expect((defaultRoutine!.variables ?? []).map((variable) => variable.name)).toEqual([ "pipeline_name", "stage_name", "case_title", "body", ]); await db .update(routines) .set({ title: "Custom {{case_key}}: {{case_title}}" }) .where(eq(routines.id, firstRoutineId!)); await svc.updateStage({ companyId: company.id, pipelineId: pipeline.id, stageId, patch: { config: { automation: { assigneeAgentId: routineSeed.assigneeAgentId, instructionsBody: "Updated instructions for {{case_title}}.", }, }, }, actor: userActor, }); const [customRoutine] = await db.select().from(routines).where(eq(routines.id, firstRoutineId!)); expect(customRoutine!.title).toBe("Custom {{case_key}}: {{case_title}}"); expect((customRoutine!.variables ?? []).map((variable) => variable.name)).toContain("case_key"); await db .update(routines) .set({ title: "In progress automation" }) .where(eq(routines.id, firstRoutineId!)); await svc.updateStage({ companyId: company.id, pipelineId: pipeline.id, stageId, patch: { config: { automation: { assigneeAgentId: routineSeed.assigneeAgentId, instructionsBody: "Runtime interpolation for {{case_title}}.", }, }, }, actor: userActor, }); const [upgradedRoutine] = await db.select().from(routines).where(eq(routines.id, firstRoutineId!)); expect(upgradedRoutine!.title).toBe(PIPELINE_AUTOMATION_DEFAULT_TITLE_TEMPLATE); const created = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "pulpit-opinion", title: "Pulpit opinion piece", body: "Agentic work should be composed, not rebuilt", actor: userActor, }); const moved = await svc.transitionCase({ companyId: company.id, caseId: created.case.id, toStageKey: "in_progress", expectedVersion: 1, actor: userActor, }); expect(moved.automationExecution.status).toBe("succeeded"); const executionIssueId = moved.automationExecution.status === "succeeded" ? moved.automationExecution.execution.executionIssueId : null; const [issue] = await db .select({ title: issues.title }) .from(issues) .where(eq(issues.id, executionIssueId!)); expect(issue!.title).toBe("Content / In progress: Pulpit opinion piece"); }); it("rejects cross-company stage automation routines at save and execution", async () => { const company = await seedCompany(); const otherCompany = await seedCompany(); const routine = await seedRoutine(company.id, "Own routine"); const otherRoutine = await seedRoutine(otherCompany.id, "Other routine"); await expect(svc.createPipeline({ companyId: company.id, key: "bad-automation", name: "Bad automation", actor: userActor, stages: [ { key: "intake", name: "Intake", kind: "open" }, { key: "drafting", name: "Drafting", kind: "working", config: { onEnter: { type: "run_routine", routineId: otherRoutine.id } } }, { key: "done", name: "Done", kind: "done" }, { key: "cancelled", name: "Cancelled", kind: "cancelled" }, ], })).rejects.toMatchObject({ status: 422, details: { code: "validation" } }); const pipeline = await svc.createPipeline({ companyId: company.id, key: "execution-automation", name: "Execution automation", actor: userActor, stages: [ { key: "intake", name: "Intake", kind: "open" }, { key: "drafting", name: "Drafting", kind: "working", config: { onEnter: { type: "run_routine", routineId: routine.id } } }, { key: "done", name: "Done", kind: "done" }, { key: "cancelled", name: "Cancelled", kind: "cancelled" }, ], }); const created = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "cross-company-execution", title: "Cross-company execution", actor: userActor, }); const moved = await svc.transitionCase({ companyId: company.id, caseId: created.case.id, toStageKey: "drafting", expectedVersion: 1, actor: userActor, }); expect(moved.automationExecution.status).toBe("succeeded"); const [triggerEvent] = await db.insert(pipelineCaseEvents).values({ companyId: company.id, caseId: created.case.id, type: "transitioned", actorType: "system", toStageId: moved.case.stageId, payload: { crossCompanyRoutine: true }, }).returning(); const [badExecution] = await db.insert(pipelineAutomationExecutions).values({ companyId: company.id, caseId: created.case.id, automationId: moved.automationLedger!.automationId, triggeringEventId: triggerEvent!.id, routineId: otherRoutine.id, status: "failed", error: "pending_dispatch", }).returning(); const retried = await svc.retryAutomation({ companyId: company.id, caseId: created.case.id, automationId: moved.automationLedger!.automationId, actor: userActor, }); expect(retried.status).toBe("failed"); const [execution] = await db .select() .from(pipelineAutomationExecutions) .where(eq(pipelineAutomationExecutions.id, badExecution!.id)); expect(execution!.error).toContain("same company"); const events = await svc.listCaseEvents(company.id, created.case.id); expect(events.filter((event) => event.type === "automation_failed")).toHaveLength(1); }); it("auto-advances after retry creates a fresh terminal child rollup", async () => { const company = await seedCompany(); const routine = await seedRoutine(company.id, "Retry child cleanup"); const pipeline = await svc.createPipeline({ companyId: company.id, key: "retry-child-cleanup", name: "Retry child cleanup", actor: userActor, stages: [ { key: "build", name: "Build", kind: "working", config: { autoAdvanceOnChildrenTerminal: "review", onEnter: { type: "run_routine", id: "build-children", routineId: routine.id, }, }, }, { key: "review", name: "Review", kind: "working" }, { key: "done", name: "Done", kind: "done" }, { key: "cancelled", name: "Cancelled", kind: "cancelled" }, ], }); const parent = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "parent", title: "Parent", actor: userActor, }); const [event] = await db.insert(pipelineCaseEvents).values({ companyId: company.id, caseId: parent.case.id, type: "transitioned", actorType: "system", toStageId: parent.case.stageId, payload: { test: true }, }).returning(); const [attempt] = await db.insert(pipelineAutomationExecutions).values({ companyId: company.id, caseId: parent.case.id, automationId: "build-children", triggeringEventId: event!.id, routineId: routine.id, status: "failed", error: "boom", }).returning(); const [automationIssue] = await db.insert(issues).values({ companyId: company.id, title: "Retry-owned automation issue", status: "in_progress", priority: "medium", }).returning(); await db.insert(pipelineCaseIssueLinks).values({ companyId: company.id, caseId: parent.case.id, issueId: automationIssue!.id, role: "automation", automationAttemptId: attempt!.id, }); const [pendingInteraction] = await db.insert(issueThreadInteractions).values({ companyId: company.id, issueId: automationIssue!.id, kind: "request_confirmation", status: "pending", continuationPolicy: "none", payload: { version: 1, prompt: "Continue automation?" }, }).returning(); const child = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "child", title: "Child", parentCaseId: parent.case.id, actor: userActor, }); await db .update(pipelineCases) .set({ automationAttemptId: attempt!.id }) .where(eq(pipelineCases.id, child.case.id)); await svc.transitionCase({ companyId: company.id, caseId: child.case.id, toStageKey: "done", expectedVersion: child.case.version, actor: userActor, }); const [reviewingParent] = await db .select({ version: pipelineCases.version, stageKey: pipelineStages.key }) .from(pipelineCases) .innerJoin(pipelineStages, eq(pipelineCases.stageId, pipelineStages.id)) .where(eq(pipelineCases.id, parent.case.id)); expect(reviewingParent!.stageKey).toBe("review"); const retry = await svc.retryStageAutomation({ companyId: company.id, caseId: parent.case.id, scope: "previous_stage", targetStageId: event!.toStageId, expectedVersion: reviewingParent!.version, cleanup: { retireDirectChildren: true, retireDescendants: true, cancelLinkedAutomationIssues: true, }, actor: userActor, }); const retryChild = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "retry-child", title: "Retry child", parentCaseId: parent.case.id, actor: userActor, }); await db .update(pipelineCases) .set({ automationAttemptId: retry.automationLedger.id }) .where(eq(pipelineCases.id, retryChild.case.id)); await svc.transitionCase({ companyId: company.id, caseId: retryChild.case.id, toStageKey: "done", expectedVersion: retryChild.case.version, actor: userActor, }); const [freshParent] = await db .select({ childCount: pipelineCases.childCount, terminalChildCount: pipelineCases.terminalChildCount, stageKey: pipelineStages.key }) .from(pipelineCases) .innerJoin(pipelineStages, eq(pipelineCases.stageId, pipelineStages.id)) .where(eq(pipelineCases.id, parent.case.id)); const [freshChild] = await db.select().from(pipelineCases).where(eq(pipelineCases.id, child.case.id)); expect(freshParent!.childCount).toBe(2); expect(freshParent!.terminalChildCount).toBe(2); expect(freshParent!.stageKey).toBe("review"); expect(freshChild!.terminalKind).toBe("cancelled"); expect(freshChild!.retiredReason).toBe("automation_retry"); const [cancelledAutomationIssue] = await db .select({ status: issues.status }) .from(issues) .where(eq(issues.id, automationIssue!.id)); expect(cancelledAutomationIssue?.status).toBe("cancelled"); const [expiredInteraction] = await db .select({ status: issueThreadInteractions.status }) .from(issueThreadInteractions) .where(eq(issueThreadInteractions.id, pendingInteraction!.id)); expect(expiredInteraction?.status).toBe("expired"); const events = await svc.listCaseEvents(company.id, parent.case.id); expect(events.filter((pipelineEvent) => pipelineEvent.type === "children_terminal")).toHaveLength(2); }); it("updates intermediate terminal counts when retry retires descendants only", async () => { const company = await seedCompany(); const routine = await seedRoutine(company.id, "Retry descendants only"); const pipeline = await svc.createPipeline({ companyId: company.id, key: "retry-descendants-only", name: "Retry descendants only", actor: userActor, stages: [ { key: "build", name: "Build", kind: "working", config: { onEnter: { type: "run_routine", id: "build-descendants", routineId: routine.id, }, }, }, { key: "done", name: "Done", kind: "done" }, { key: "cancelled", name: "Cancelled", kind: "cancelled" }, ], }); const parent = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "descendants-parent", title: "Descendants parent", actor: userActor, }); const [event] = await db.insert(pipelineCaseEvents).values({ companyId: company.id, caseId: parent.case.id, type: "transitioned", actorType: "system", toStageId: parent.case.stageId, payload: { test: true }, }).returning(); const [attempt] = await db.insert(pipelineAutomationExecutions).values({ companyId: company.id, caseId: parent.case.id, automationId: "build-descendants", triggeringEventId: event!.id, routineId: routine.id, status: "failed", error: "boom", }).returning(); const child = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "descendants-child", title: "Descendants child", parentCaseId: parent.case.id, actor: userActor, }); await db .update(pipelineCases) .set({ automationAttemptId: attempt!.id }) .where(eq(pipelineCases.id, child.case.id)); const grandchild = await svc.ingestCase({ companyId: company.id, pipelineId: pipeline.id, caseKey: "descendants-grandchild", title: "Descendants grandchild", parentCaseId: child.case.id, actor: userActor, }); await svc.retryStageAutomation({ companyId: company.id, caseId: parent.case.id, scope: "current_stage", expectedVersion: parent.case.version, cleanup: { retireDirectChildren: false, retireDescendants: true, cancelLinkedAutomationIssues: false, }, actor: userActor, }); const [freshParent] = await db.select().from(pipelineCases).where(eq(pipelineCases.id, parent.case.id)); const [freshChild] = await db.select().from(pipelineCases).where(eq(pipelineCases.id, child.case.id)); const [freshGrandchild] = await db.select().from(pipelineCases).where(eq(pipelineCases.id, grandchild.case.id)); expect(freshParent!.terminalChildCount).toBe(0); expect(freshChild!.terminalKind).toBeNull(); expect(freshChild!.terminalChildCount).toBe(1); expect(freshGrandchild!.terminalKind).toBe("cancelled"); expect(freshGrandchild!.retiredReason).toBe("automation_retry"); }); });