/// import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { api } from "./_generated/api.js"; import { initConvexTest } from "./setup.test.js"; import type { Id } from "./_generated/dataModel.js"; describe("workflow", () => { beforeEach(async () => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); test("can create a workflow async", async () => { const t = initConvexTest(); const id = await t.mutation(api.workflow.create, { workflowName: "test", workflowHandle: "function://internal.example.exampleWorkflow", workflowArgs: { location: "San Francisco" }, startAsync: true, }); const workflow = await t.query(api.workflow.getStatus, { workflowId: id }); expect(workflow.workflow.name).toBe("test"); expect(workflow.workflow.args).toEqual({ location: "San Francisco" }); expect(workflow.workflow.runResult).toBeUndefined(); expect(workflow.inProgress).toHaveLength(0); await t.run(async (ctx) => { const storedWorkflow = await ctx.db.get(id); expect(storedWorkflow?.args).toMatchObject({ kind: "@convex-dev/workflow/payloadRef", }); }); }); test("list APIs hydrate offloaded payloads", async () => { const t = initConvexTest(); const id = await t.mutation(api.workflow.create, { workflowName: "test", workflowHandle: "function://internal.example.exampleWorkflow", workflowArgs: { location: "San Francisco" }, startAsync: true, }); await t.mutation(api.journal.startSteps, { workflowId: id, generationNumber: 0, steps: [ { step: { kind: "function" as const, functionType: "mutation" as const, handle: "function://test", name: "step0", inProgress: false, argsSize: 0, args: { hello: "world" }, runResult: { kind: "success" as const, returnValue: { ok: true }, }, startedAt: Date.now(), completedAt: Date.now(), }, }, ], }); const workflows = await t.query(api.workflow.list, { order: "asc", paginationOpts: { cursor: null, numItems: 10 }, }); const listedWorkflow = workflows.page.find((workflow) => workflow.workflowId === id); expect(listedWorkflow).toBeDefined(); expect(listedWorkflow?.args).toEqual({ location: "San Francisco" }); const steps = await t.query(api.workflow.listSteps, { workflowId: id, order: "asc", paginationOpts: { cursor: null, numItems: 10 }, }); expect(steps.page).toHaveLength(1); expect(steps.page[0].args).toEqual({ hello: "world", }); expect(steps.page[0].runResult).toEqual({ kind: "success", returnValue: { ok: true }, }); }); test("can cancel a workflow", async () => { const t = initConvexTest(); const id = await t.mutation(api.workflow.create, { workflowName: "test", workflowHandle: "function://internal.example.exampleWorkflow", workflowArgs: { location: "San Francisco" }, startAsync: true, }); const workflow = await t.query(api.workflow.getStatus, { workflowId: id }); expect(workflow.workflow.runResult).toBeUndefined(); await t.mutation(api.workflow.cancel, { workflowId: id }); const workflow2 = await t.query(api.workflow.getStatus, { workflowId: id }); expect(workflow2.workflow.runResult).toMatchObject({ kind: "canceled" }); }); test("retry a failed workflow", async () => { const t = initConvexTest(); const id = await t.mutation(api.workflow.create, { workflowName: "test", workflowHandle: "function://internal.example.exampleWorkflow", workflowArgs: { location: "San Francisco" }, startAsync: true, }); // Complete with failure await t.mutation(api.workflow.complete, { workflowId: id, generationNumber: 0, runResult: { kind: "failed", error: "something went wrong" }, }); const before = await t.query(api.workflow.getStatus, { workflowId: id }); expect(before.workflow.runResult).toEqual({ kind: "failed", error: "something went wrong", }); await t.run(async (ctx) => { const workflow = await ctx.db.get(id); expect(workflow?.generationNumber).toBe(0); }); // Retry await t.mutation(api.workflow.restart, { workflowId: id, startAsync: true, }); const after = await t.query(api.workflow.getStatus, { workflowId: id }); expect(after.workflow.runResult).toBeUndefined(); await t.run(async (ctx) => { const workflow = await ctx.db.get(id); expect(workflow?.generationNumber).toBe(1); }); }); test("retry deletes the previous workflow result payload", async () => { const t = initConvexTest(); const id = await t.mutation(api.workflow.create, { workflowName: "test", workflowHandle: "function://internal.example.exampleWorkflow", workflowArgs: { location: "San Francisco" }, startAsync: true, }); await t.mutation(api.workflow.complete, { workflowId: id, generationNumber: 0, runResult: { kind: "failed", error: "something went wrong" }, }); const payloadId = await t.run(async (ctx) => { const workflow = await ctx.db.get(id); return (workflow?.runResult as { error: { payloadId: string } }).error .payloadId; }); await t.mutation(api.workflow.restart, { workflowId: id, startAsync: true, }); await t.run(async (ctx) => { const payload = await ctx.db.get(payloadId as Id<"payloads">); expect(payload).toBeNull(); }); }); test("retry deletes payload docs for truncated steps", async () => { const t = initConvexTest(); const id = await t.mutation(api.workflow.create, { workflowName: "test", workflowHandle: "function://internal.example.exampleWorkflow", workflowArgs: {}, startAsync: true, }); const entries = await t.mutation(api.journal.startSteps, { workflowId: id, generationNumber: 0, steps: [ { step: { kind: "function" as const, functionType: "mutation" as const, handle: "function://test", name: "persisted-step", inProgress: false, argsSize: 0, args: { hello: "world" }, runResult: { kind: "success" as const, returnValue: { ok: true }, }, startedAt: Date.now(), completedAt: Date.now(), }, }, ], }); const step = entries[0].step as { args: { payloadId: string }; runResult: { kind: "success"; returnValue: { payloadId: string } }; }; await t.mutation(api.workflow.complete, { workflowId: id, generationNumber: 0, runResult: { kind: "failed", error: "oops" }, }); await t.mutation(api.workflow.restart, { workflowId: id, from: 0, startAsync: true, }); await t.run(async (ctx) => { const argsPayload = await ctx.db.get(step.args.payloadId as Id<"payloads">); const resultPayload = await ctx.db.get( step.runResult.returnValue.payloadId as Id<"payloads">, ); expect(argsPayload).toBeNull(); expect(resultPayload).toBeNull(); }); }); test("retry throws if workflow is still running", async () => { const t = initConvexTest(); const id = await t.mutation(api.workflow.create, { workflowName: "test", workflowHandle: "function://internal.example.exampleWorkflow", workflowArgs: {}, startAsync: true, }); await expect( t.mutation(api.workflow.restart, { workflowId: id, startAsync: true }), ).rejects.toThrow("still running"); }); test("retry from step number deletes steps from that point", async () => { const t = initConvexTest(); const id = await t.mutation(api.workflow.create, { workflowName: "test", workflowHandle: "function://internal.example.exampleWorkflow", workflowArgs: {}, startAsync: true, }); // Insert some steps await t.run(async (ctx) => { for (let i = 0; i < 3; i++) { await ctx.db.insert("steps", { workflowId: id, stepNumber: i, step: { kind: "function" as const, functionType: "mutation" as const, handle: "function://test", name: `step${i}`, inProgress: false, argsSize: 0, args: {}, runResult: { kind: "success", returnValue: null }, startedAt: Date.now(), completedAt: Date.now(), }, }); } }); // Complete with failure await t.mutation(api.workflow.complete, { workflowId: id, generationNumber: 0, runResult: { kind: "failed", error: "step2 failed" }, }); // Retry from step 1 await t.mutation(api.workflow.restart, { workflowId: id, from: 1, startAsync: true, }); // Only step0 should remain await t.run(async (ctx) => { const steps = await ctx.db .query("steps") .withIndex("workflow", (q) => q.eq("workflowId", id)) .collect(); expect(steps).toHaveLength(1); expect(steps[0].stepNumber).toBe(0); expect(steps[0].step.name).toBe("step0"); }); }); test("retry from step name deletes that step and subsequent ones", async () => { const t = initConvexTest(); const id = await t.mutation(api.workflow.create, { workflowName: "test", workflowHandle: "function://internal.example.exampleWorkflow", workflowArgs: {}, startAsync: true, }); // Insert steps with names await t.run(async (ctx) => { const names = ["fetch", "process", "save"]; for (let i = 0; i < names.length; i++) { await ctx.db.insert("steps", { workflowId: id, stepNumber: i, step: { kind: "function" as const, functionType: "action" as const, handle: "function://test", name: names[i], inProgress: false, argsSize: 0, args: {}, runResult: { kind: "success", returnValue: null }, startedAt: Date.now(), completedAt: Date.now(), }, }); } }); await t.mutation(api.workflow.complete, { workflowId: id, generationNumber: 0, runResult: { kind: "failed", error: "save failed" }, }); // Retry from "process" await t.mutation(api.workflow.restart, { workflowId: id, from: "process", startAsync: true, }); // Only "fetch" (step 0) should remain await t.run(async (ctx) => { const steps = await ctx.db .query("steps") .withIndex("workflow", (q) => q.eq("workflowId", id)) .collect(); expect(steps).toHaveLength(1); expect(steps[0].step.name).toBe("fetch"); }); }); test("retry from unknown step name throws", async () => { const t = initConvexTest(); const id = await t.mutation(api.workflow.create, { workflowName: "test", workflowHandle: "function://internal.example.exampleWorkflow", workflowArgs: {}, startAsync: true, }); await t.mutation(api.workflow.complete, { workflowId: id, generationNumber: 0, runResult: { kind: "failed", error: "oops" }, }); await expect( t.mutation(api.workflow.restart, { workflowId: id, from: "nonexistent", startAsync: true, }), ).rejects.toThrow('Step "nonexistent" not found'); }); test("retry from negative step number throws", async () => { const t = initConvexTest(); const id = await t.mutation(api.workflow.create, { workflowName: "test", workflowHandle: "function://internal.example.exampleWorkflow", workflowArgs: {}, startAsync: true, }); await t.mutation(api.workflow.complete, { workflowId: id, generationNumber: 0, runResult: { kind: "failed", error: "oops" }, }); await expect( t.mutation(api.workflow.restart, { workflowId: id, from: -1, startAsync: true, }), ).rejects.toThrow("Step number cannot be negative: -1"); }); test("retry deletes associated event steps", async () => { const t = initConvexTest(); const id = await t.mutation(api.workflow.create, { workflowName: "test", workflowHandle: "function://internal.example.exampleWorkflow", workflowArgs: {}, startAsync: true, }); // Insert an event and an event step referencing it let eventId: any; await t.run(async (ctx) => { eventId = await ctx.db.insert("events", { workflowId: id, name: "approval", state: { kind: "created" }, }); await ctx.db.insert("steps", { workflowId: id, stepNumber: 0, step: { kind: "event" as const, name: "waitForApproval", inProgress: false, argsSize: 0, args: { eventId }, eventId, startedAt: Date.now(), completedAt: Date.now(), runResult: { kind: "success", returnValue: null }, }, }); }); await t.mutation(api.workflow.complete, { workflowId: id, generationNumber: 0, runResult: { kind: "failed", error: "oops" }, }); // Retry from step 0 — should delete the event too await t.mutation(api.workflow.restart, { workflowId: id, from: 0, startAsync: true, }); await t.run(async (ctx) => { const steps = await ctx.db .query("steps") .withIndex("workflow", (q) => q.eq("workflowId", id)) .collect(); expect(steps).toHaveLength(0); const event = await ctx.db.get(eventId); expect(event).toBeNull(); }); }); test("cleaning up a workflow", async () => { const t = initConvexTest(); const id = await t.mutation(api.workflow.create, { workflowName: "test", workflowHandle: "function://internal.example.exampleWorkflow", workflowArgs: { location: "San Francisco" }, startAsync: true, }); const argsPayloadId = await t.run(async (ctx) => { const workflow = await ctx.db.get(id); return (workflow?.args as { payloadId: string }).payloadId; }); const workflow = await t.query(api.workflow.getStatus, { workflowId: id }); expect(workflow.workflow.runResult).toBeUndefined(); await t.mutation(api.workflow.cancel, { workflowId: id }); const workflow2 = await t.query(api.workflow.getStatus, { workflowId: id }); expect(workflow2.workflow.runResult).toMatchObject({ kind: "canceled" }); const cleaned = await t.mutation(api.workflow.cleanup, { workflowId: id }); expect(cleaned).toBe(true); await t.run(async (ctx) => { const workflow = await ctx.db.get(id); const argsPayload = await ctx.db.get(argsPayloadId as Id<"payloads">); expect(workflow).toBeNull(); expect(argsPayload).toBeNull(); }); }); test("cleanup deletes associated events", async () => { const t = initConvexTest(); // Create a workflow const workflowId = await t.mutation(api.workflow.create, { workflowName: "test-with-event", workflowHandle: "function://internal.example.exampleWorkflow", workflowArgs: { location: "San Francisco" }, startAsync: true, }); // Create an event associated with the workflow const eventId = await t.mutation(api.event.create, { name: "test-event", workflowId, }); // Create a step entry with kind "event" that references the event using startSteps API const entries = await t.mutation(api.journal.startSteps, { workflowId, generationNumber: 0, steps: [ { step: { kind: "event" as const, name: "test-event-step", inProgress: true, argsSize: 0, args: { eventId }, startedAt: Date.now(), }, }, ], }); const stepId = entries[0]._id as Id<"steps">; // Verify event exists await t.run(async (ctx) => { const event = await ctx.db.get(eventId); expect(event).not.toBeNull(); }); // Cancel the workflow so it can be cleaned up await t.mutation(api.workflow.cancel, { workflowId }); // Clean up the workflow const cleaned = await t.mutation(api.workflow.cleanup, { workflowId, }); expect(cleaned).toBe(true); // Verify the workflow is deleted await t.run(async (ctx) => { const workflow = await ctx.db.get(workflowId); expect(workflow).toBeNull(); }); // Verify the step is deleted await t.run(async (ctx) => { const step = await ctx.db.get(stepId); expect(step).toBeNull(); }); // Verify the event is deleted await t.run(async (ctx) => { const event = await ctx.db.get(eventId); expect(event).toBeNull(); }); }); test("cleanup enqueues cleanup for nested workflows", async () => { const t = initConvexTest(); // Create a parent workflow const parentWorkflowId = await t.mutation(api.workflow.create, { workflowName: "parent-workflow", workflowHandle: "function://internal.example.exampleWorkflow", workflowArgs: { location: "San Francisco" }, startAsync: true, }); // Create a workflow step which will automatically create a nested workflow via startSteps // The startSteps handler creates the nested workflow when kind is "workflow" const entries = await t.mutation(api.journal.startSteps, { workflowId: parentWorkflowId, generationNumber: 0, steps: [ { step: { kind: "workflow" as const, name: "nested-workflow-step", handle: "function://internal.example.exampleWorkflow", inProgress: true, argsSize: 0, args: { location: "New York" }, startedAt: Date.now(), }, }, ], }); const stepId = entries[0]._id as Id<"steps">; // Get the nested workflow ID that was created by startSteps let nestedWorkflowId: Id<"workflows"> | undefined; await t.run(async (ctx) => { const step = await ctx.db.get(stepId); if (step && step.step.kind === "workflow") { nestedWorkflowId = step.step.workflowId; } }); expect(nestedWorkflowId).toBeDefined(); // Cancel parent workflow - this should automatically cancel nested workflows await t.mutation(api.workflow.cancel, { workflowId: parentWorkflowId }); // Verify nested workflow was also canceled automatically await t.run(async (ctx) => { const nested = await ctx.db.get(nestedWorkflowId!); expect(nested?.runResult).toBeDefined(); }); // Verify both workflows exist before cleanup await t.run(async (ctx) => { const parent = await ctx.db.get(parentWorkflowId); const nested = await ctx.db.get(nestedWorkflowId!); expect(parent).not.toBeNull(); expect(nested).not.toBeNull(); }); // Clean up the parent workflow const cleaned = await t.mutation(api.workflow.cleanup, { workflowId: parentWorkflowId, }); expect(cleaned).toBe(true); // Verify the parent workflow is deleted await t.run(async (ctx) => { const parent = await ctx.db.get(parentWorkflowId); expect(parent).toBeNull(); }); // Verify the step is deleted await t.run(async (ctx) => { const step = await ctx.db.get(stepId); expect(step).toBeNull(); }); // The nested workflow cleanup is enqueued via workpool. // Run scheduled functions to process the cleanup. await t.finishAllScheduledFunctions(vi.runAllTimers); // Verify the nested workflow is also cleaned up await t.run(async (ctx) => { const nested = await ctx.db.get(nestedWorkflowId!); expect(nested).toBeNull(); }); }); test("cleanup handles workflow step without workflowId", async () => { const t = initConvexTest(); // Create a workflow const workflowId = await t.mutation(api.workflow.create, { workflowName: "test-workflow", workflowHandle: "function://internal.example.exampleWorkflow", workflowArgs: { location: "San Francisco" }, startAsync: true, }); // Create a workflow step without workflowId (not yet started nested workflow) using startSteps await t.mutation(api.journal.startSteps, { workflowId, generationNumber: 0, steps: [ { step: { kind: "workflow" as const, name: "pending-nested-workflow", handle: "function://internal.example.exampleWorkflow", inProgress: true, argsSize: 0, args: { location: "Boston" }, startedAt: Date.now(), }, }, ], }); // Cancel the workflow await t.mutation(api.workflow.cancel, { workflowId }); // Cleanup should succeed without errors const cleaned = await t.mutation(api.workflow.cleanup, { workflowId }); expect(cleaned).toBe(true); // Verify the workflow is deleted await t.run(async (ctx) => { const workflow = await ctx.db.get(workflowId); expect(workflow).toBeNull(); }); }); test("cleanup handles event step without eventId", async () => { const t = initConvexTest(); // Create a workflow const workflowId = await t.mutation(api.workflow.create, { workflowName: "test-workflow", workflowHandle: "function://internal.example.exampleWorkflow", workflowArgs: { location: "San Francisco" }, startAsync: true, }); // Create an event step that starts waiting (no event sent yet, so it won't have a response) await t.mutation(api.journal.startSteps, { workflowId, generationNumber: 0, steps: [ { step: { kind: "event" as const, name: "pending-event", inProgress: true, argsSize: 0, args: {}, startedAt: Date.now(), }, }, ], }); // Cancel the workflow await t.mutation(api.workflow.cancel, { workflowId }); // Cleanup should succeed without errors (eventId is set by awaitEvent) const cleaned = await t.mutation(api.workflow.cleanup, { workflowId }); expect(cleaned).toBe(true); // Verify the workflow is deleted await t.run(async (ctx) => { const workflow = await ctx.db.get(workflowId); expect(workflow).toBeNull(); }); }); test("cleanup with mixed event and workflow steps", async () => { const t = initConvexTest(); // Create parent workflow const parentWorkflowId = await t.mutation(api.workflow.create, { workflowName: "parent-workflow", workflowHandle: "function://internal.example.exampleWorkflow", workflowArgs: { location: "San Francisco" }, startAsync: true, }); // Create an event const eventId = await t.mutation(api.event.create, { name: "test-event", workflowId: parentWorkflowId, }); // Create mixed steps using startSteps API // The workflow step will automatically create a nested workflow const entries = await t.mutation(api.journal.startSteps, { workflowId: parentWorkflowId, generationNumber: 0, steps: [ { step: { kind: "event" as const, name: "event-step", inProgress: true, argsSize: 0, args: { eventId }, startedAt: Date.now(), }, }, { step: { kind: "workflow" as const, name: "workflow-step", handle: "function://internal.example.exampleWorkflow", inProgress: true, argsSize: 0, args: { location: "New York" }, startedAt: Date.now(), }, }, ], }); // Get the nested workflow ID that was created by startSteps const workflowStepId = entries[1]._id as Id<"steps">; let nestedWorkflowId: Id<"workflows"> | undefined; await t.run(async (ctx) => { const step = await ctx.db.get(workflowStepId); if (step && step.step.kind === "workflow") { nestedWorkflowId = step.step.workflowId; } }); expect(nestedWorkflowId).toBeDefined(); // Cancel parent workflow - this should automatically cancel nested workflows await t.mutation(api.workflow.cancel, { workflowId: parentWorkflowId }); // Verify nested workflow was also canceled automatically await t.run(async (ctx) => { const nested = await ctx.db.get(nestedWorkflowId!); expect(nested?.runResult).toBeDefined(); }); // Verify resources exist before cleanup await t.run(async (ctx) => { const parent = await ctx.db.get(parentWorkflowId); const nested = await ctx.db.get(nestedWorkflowId!); const event = await ctx.db.get(eventId); expect(parent).not.toBeNull(); expect(nested).not.toBeNull(); expect(event).not.toBeNull(); }); // Clean up the parent workflow const cleaned = await t.mutation(api.workflow.cleanup, { workflowId: parentWorkflowId, }); expect(cleaned).toBe(true); // Run scheduled functions to process nested cleanup await t.finishAllScheduledFunctions(vi.runAllTimers); // Verify all resources are cleaned up await t.run(async (ctx) => { const parent = await ctx.db.get(parentWorkflowId); const nested = await ctx.db.get(nestedWorkflowId!); const event = await ctx.db.get(eventId); expect(parent).toBeNull(); expect(nested).toBeNull(); expect(event).toBeNull(); }); }); });