From c8e730eed8a0b993e68d290da6111945d2fa04bf Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Wed, 29 Oct 2025 18:09:37 -0700 Subject: [PATCH] Delete Workflows app (thank you Vercel) --- .../migration.sql | 6 + apps/backend/prisma/schema.prisma | 95 --- .../internal/trigger/run-scheduled/route.tsx | 41 -- .../backend/src/app/api/latest/users/crud.tsx | 15 - apps/backend/src/lib/workflows.tsx | 582 ------------------ apps/backend/src/middleware.tsx | 1 - .../src/route-handlers/smart-request.tsx | 21 - .../workflows/[workflowId]/page.tsx | 128 ---- .../projects/[projectId]/workflows/page.tsx | 365 ----------- .../[projectId]/workflows/workflow-list.tsx | 159 ----- apps/dashboard/src/lib/apps-frontend.tsx | 11 +- apps/e2e/tests/backend/workflows.test.ts | 374 ----------- claude/CLAUDE-KNOWLEDGE.md | 3 + packages/stack-shared/src/apps/apps-config.ts | 6 - .../src/config/schema-fuzzer.test.ts | 10 - packages/stack-shared/src/config/schema.ts | 20 - packages/stack-shared/src/known-errors.tsx | 21 - 17 files changed, 10 insertions(+), 1848 deletions(-) create mode 100644 apps/backend/prisma/migrations/20251010120000_drop_workflows/migration.sql delete mode 100644 apps/backend/src/app/api/latest/internal/trigger/run-scheduled/route.tsx delete mode 100644 apps/backend/src/lib/workflows.tsx delete mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/workflows/[workflowId]/page.tsx delete mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/workflows/page.tsx delete mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/workflows/workflow-list.tsx delete mode 100644 apps/e2e/tests/backend/workflows.test.ts diff --git a/apps/backend/prisma/migrations/20251010120000_drop_workflows/migration.sql b/apps/backend/prisma/migrations/20251010120000_drop_workflows/migration.sql new file mode 100644 index 000000000..907d33caa --- /dev/null +++ b/apps/backend/prisma/migrations/20251010120000_drop_workflows/migration.sql @@ -0,0 +1,6 @@ +-- Drop workflow-related tables now that the workflows feature has been removed. +DROP TABLE IF EXISTS "WorkflowTrigger" CASCADE; +DROP TABLE IF EXISTS "WorkflowExecution" CASCADE; +DROP TABLE IF EXISTS "WorkflowTriggerToken" CASCADE; +DROP TABLE IF EXISTS "CurrentlyCompilingWorkflow" CASCADE; +DROP TABLE IF EXISTS "CompiledWorkflow" CASCADE; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index aa080ab43..fb9228d9c 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -828,98 +828,3 @@ model DataVaultEntry { @@unique([tenancyId, storeId, hashedKey]) @@index([tenancyId, storeId]) } - -model WorkflowTriggerToken { - tenancyId String @db.Uuid - id String @default(uuid()) @db.Uuid - - tokenHash String - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - expiresAt DateTime - - @@id([tenancyId, id]) - @@unique([tenancyId, tokenHash]) -} - -model WorkflowTrigger { - tenancyId String @db.Uuid - id String @default(uuid()) @db.Uuid - executionId String @db.Uuid - - triggerData Json - - // the following fields determine the state of the trigger: - // - scheduledAt && !compiledWorkflowId && !output && !error: the trigger is scheduled to be executed - // - !scheduledAt && !compiledWorkflowId: the trigger was scheduled, but its workflow subsequently deleted. The trigger never ran - // - !scheduledAt && compiledWorkflowId && !output && !error: the trigger is currently executing - // - !scheduledAt && compiledWorkflowId && output && !error: the trigger has successfully completed execution - // - !scheduledAt && compiledWorkflowId && !output && error: the trigger has failed execution - // All other combinations are invalid. - scheduledAt DateTime? - output Json? - error Json? - compiledWorkflowId String? @db.Uuid - compiledWorkflow CompiledWorkflow? @relation(fields: [tenancyId, compiledWorkflowId], references: [tenancyId, id]) - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - execution WorkflowExecution @relation(fields: [tenancyId, executionId], references: [tenancyId, id]) - - @@id([tenancyId, id]) -} - -model WorkflowExecution { - tenancyId String @db.Uuid - id String @default(uuid()) @db.Uuid - - workflowId String - - triggerIds String[] - triggers WorkflowTrigger[] - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@id([tenancyId, id]) -} - -model CurrentlyCompilingWorkflow { - tenancyId String @db.Uuid - workflowId String - compilationVersion Int - sourceHash String - - startedCompilingAt DateTime @default(now()) - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@id([tenancyId, workflowId, compilationVersion, sourceHash]) -} - -model CompiledWorkflow { - tenancyId String @db.Uuid - id String @default(uuid()) @db.Uuid - workflowId String // note: The workflow with this ID may have been edited or deleted in the meantime, so there may be multiple CompiledWorkflows with the same workflowId - compilationVersion Int - sourceHash String - - // exactly one of [compiledCode, compileError] must be set - compiledCode String? - compileError String? - - compiledAt DateTime @default(now()) - registeredTriggers String[] - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - workflowTriggers WorkflowTrigger[] - - @@id([tenancyId, id]) - @@unique([tenancyId, workflowId, compilationVersion, sourceHash]) -} diff --git a/apps/backend/src/app/api/latest/internal/trigger/run-scheduled/route.tsx b/apps/backend/src/app/api/latest/internal/trigger/run-scheduled/route.tsx deleted file mode 100644 index 281f0a429..000000000 --- a/apps/backend/src/app/api/latest/internal/trigger/run-scheduled/route.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { getTenancy } from "@/lib/tenancies"; -import { ensureUpstashSignature } from "@/lib/upstash"; -import { triggerScheduledWorkflows } from "@/lib/workflows"; -import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; -import { yupNumber, yupObject, yupString, yupTuple } from "@stackframe/stack-shared/dist/schema-fields"; -import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; - -export const POST = createSmartRouteHandler({ - metadata: { - hidden: true, - }, - request: yupObject({ - headers: yupObject({ - "upstash-signature": yupTuple([yupString().defined()]).defined(), - }).defined(), - body: yupObject({ - tenancyId: yupString().defined(), - }).defined(), - method: yupString().oneOf(["POST"]).defined(), - }), - response: yupObject({ - statusCode: yupNumber().oneOf([200]).defined(), - bodyType: yupString().oneOf(["success"]).defined(), - }), - handler: async (req, fullReq) => { - await ensureUpstashSignature(fullReq); - - const tenancy = await getTenancy(req.body.tenancyId); - if (!tenancy) { - throw new StackAssertionError(`Tenancy not found for scheduled trigger`, { tenancyId: req.body.tenancyId }); - } - - await triggerScheduledWorkflows(tenancy); - - return { - statusCode: 200, - bodyType: "success", - } as const; - }, -}); - diff --git a/apps/backend/src/app/api/latest/users/crud.tsx b/apps/backend/src/app/api/latest/users/crud.tsx index 538552a55..f3ae7c015 100644 --- a/apps/backend/src/app/api/latest/users/crud.tsx +++ b/apps/backend/src/app/api/latest/users/crud.tsx @@ -5,7 +5,6 @@ import { ensureTeamMembershipExists, ensureUserExists } from "@/lib/request-chec import { Tenancy, getSoleTenancyFromProjectBranch, getTenancy } from "@/lib/tenancies"; import { PrismaTransaction } from "@/lib/types"; import { sendTeamMembershipDeletedWebhook, sendUserCreatedWebhook, sendUserDeletedWebhook, sendUserUpdatedWebhook } from "@/lib/webhooks"; -import { triggerWorkflows } from "@/lib/workflows"; import { RawQuery, getPrismaClientForSourceOfTruth, getPrismaClientForTenancy, getPrismaSchemaForSourceOfTruth, getPrismaSchemaForTenancy, globalPrismaClient, rawQuery, retryTransaction, sqlQuoteIdent } from "@/prisma-client"; import { createCrudHandlers } from "@/route-handlers/crud-handler"; import { uploadAndGetUrl } from "@/s3"; @@ -649,14 +648,6 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC await createPersonalTeamIfEnabled(prisma, auth.tenancy, result); - // if the user is not an anonymous user, trigger onSignUp workflows - if (!result.is_anonymous) { - await triggerWorkflows(auth.tenancy, { - type: "sign-up", - userId: result.id, - }); - } - runAsynchronouslyAndWaitUntil(sendUserCreatedWebhook({ projectId: auth.project.id, data: result, @@ -959,12 +950,6 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC // if we went from anonymous to non-anonymous: if (oldUser.isAnonymous && data.is_anonymous === false) { - // trigger onSignUp workflows - await triggerWorkflows(auth.tenancy, { - type: "sign-up", - userId: params.user_id, - }); - // rename the personal team await tx.team.updateMany({ where: { diff --git a/apps/backend/src/lib/workflows.tsx b/apps/backend/src/lib/workflows.tsx deleted file mode 100644 index 33848f1a6..000000000 --- a/apps/backend/src/lib/workflows.tsx +++ /dev/null @@ -1,582 +0,0 @@ -import { getPrismaClientForTenancy, globalPrismaClient, retryTransaction } from "@/prisma-client"; -import { traceSpan } from "@/utils/telemetry"; -import { allPromisesAndWaitUntilEach, runAsynchronouslyAndWaitUntil } from "@/utils/vercel"; -import { CompiledWorkflow, Prisma } from "@prisma/client"; -import { isStringArray } from "@stackframe/stack-shared/dist/utils/arrays"; -import { encodeBase64 } from "@stackframe/stack-shared/dist/utils/bytes"; -import { generateSecureRandomString, hash } from "@stackframe/stack-shared/dist/utils/crypto"; -import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; -import { StackAssertionError, captureError, errorToNiceString, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; -import { bundleJavaScript, initializeEsbuild } from "@stackframe/stack-shared/dist/utils/esbuild"; -import { runAsynchronously, timeout, wait } from "@stackframe/stack-shared/dist/utils/promises"; -import { Result } from "@stackframe/stack-shared/dist/utils/results"; -import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids"; -import { Freestyle } from "./freestyle"; -import { Tenancy } from "./tenancies"; -import { upstash } from "./upstash"; - -const externalPackages: Record = {}; - -type WorkflowRegisteredTriggerType = "sign-up"; - -type WorkflowTrigger = - | { - type: "sign-up", - userId: string, - } - | { - type: "compile", - } - | { - type: "callback", - callbackId: string, - scheduledAtMillis: number, - data: unknown, - callerTriggerId: string, - executionId: string, - }; - -async function hashWorkflowSource(source: string) { - return encodeBase64(await hash({ - purpose: "stack-auth-workflow-source", - value: JSON.stringify(source), - })); -} - -export async function hashWorkflowTriggerToken(token: string) { - return encodeBase64(await hash({ - purpose: "stack-auth-workflow-trigger-token", - value: token, - })); -} - -export async function compileWorkflowSource(source: string): Promise> { - const bundleResult = await bundleJavaScript({ - "/source.tsx": source, - "/entry.js": ` - import { StackServerApp } from 'https://esm.sh/@stackframe/js@2.8.36?target=es2021&standalone'; - - globalThis.navigator.onLine = true; - - export default async () => { - globalThis.stackApp = new StackServerApp({ - tokenStore: null, - extraRequestHeaders: { - "x-stack-workflow-token": process.env.STACK_WORKFLOW_TOKEN_SECRET, - } - }); - - const registeredTriggers = new Map(); - globalThis._registerTrigger = (triggerType, func) => { - registeredTriggers.set(triggerType, func); - }; - _registerTrigger("compile", () => ({ - registeredTriggers: [...registeredTriggers.keys()], - })); - - const registeredCallbacks = new Map(); - globalThis.registerCallback = (callbackId, func) => { - registeredCallbacks.set(callbackId, func); - }; - _registerTrigger("callback", ({ callbackId, data }) => { - const callbackFunc = registeredCallbacks.get(callbackId); - if (!callbackFunc) { - throw new Error(\`Callback \${callbackId} not found. Was it maybe deleted from the workflow?\`); - } - return callbackFunc(data); - }); - let scheduledCallback = undefined; - globalThis.scheduleCallback = ({ callbackId, data, scheduleAt }) => { - if (scheduledCallback) { - throw new Error("Only one callback can be scheduled at a time!"); - } - scheduledCallback = { callbackId, data, scheduleAtMillis: scheduleAt.getTime() }; - return scheduledCallback; - }; - - function makeTriggerRegisterer(str, typeCb, argsCb) { - globalThis[str] = (...args) => _registerTrigger(typeCb(...args.slice(0, -1)), async (data) => args[args.length - 1](...await argsCb(data))); - } - - makeTriggerRegisterer("onSignUp", () => "sign-up", async (data) => [await stackApp.getUser(data.userId, { or: "throw" })]); - - await import("./source.tsx"); - - const triggerData = JSON.parse(process.env.STACK_WORKFLOW_TRIGGER_DATA); - const trigger = registeredTriggers.get(triggerData.type); - if (!trigger) { - throw new Error(\`Workflow trigger \${triggerData.type} invoked but not found. Please report this to the developers.\`); - } - const triggerOutput = await trigger(triggerData); - if (scheduledCallback !== undefined) { - if (triggerOutput !== scheduledCallback) { - throw new Error("When calling scheduleCallback, you must return its return value in the event handler!"); - } - return { - scheduledCallback: triggerOutput, - }; - } else { - return { - triggerOutput, - }; - } - } - `, - }, { - format: 'esm', - keepAsImports: Object.keys(externalPackages), - allowHttpImports: true, - }); - if (bundleResult.status === "error") { - return Result.error(bundleResult.error); - } - return Result.ok(bundleResult.data); -} - -async function compileWorkflow(tenancy: Tenancy, workflowId: string): Promise> { - return await traceSpan(`compileWorkflow ${workflowId}`, async () => { - if (!(workflowId in tenancy.config.workflows.availableWorkflows)) { - throw new StackAssertionError(`Workflow ${workflowId} not found`); - } - const workflow = tenancy.config.workflows.availableWorkflows[workflowId]; - const res = await timeout(async () => { - console.log(`Compiling workflow ${workflowId}...`); - const compiledCodeResult = await compileWorkflowSource(workflow.tsSource); - if (compiledCodeResult.status === "error") { - return Result.error({ compileError: `Failed to compile workflow: ${compiledCodeResult.error}` }); - } - - console.log(`Compiled workflow source for ${workflowId}, running compilation trigger...`, { compiledCodeLength: compiledCodeResult.data.length }); - - const compileTriggerResult = await triggerWorkflowRaw(tenancy, compiledCodeResult.data, { - type: "compile", - }); - if (compileTriggerResult.status === "error") { - return Result.error({ compileError: `Failed to initialize workflow: ${compileTriggerResult.error}` }); - } - - console.log(`Compilation trigger completed!`); - - const compileTriggerOutputResult = compileTriggerResult.data; - if (typeof compileTriggerOutputResult !== "object" || !compileTriggerOutputResult || !("triggerOutput" in compileTriggerOutputResult)) { - captureError("workflows-compile-trigger-output", new StackAssertionError(`Failed to parse compile trigger output`, { compileTriggerOutputResult })); - return Result.error({ compileError: `Failed to parse compile trigger output` }); - } - const registeredTriggers = (compileTriggerOutputResult.triggerOutput as any)?.registeredTriggers; - if (!isStringArray(registeredTriggers)) { - captureError("workflows-compile-trigger-output", new StackAssertionError(`Failed to parse compile trigger output, should be array of strings`, { compileTriggerOutputResult })); - return Result.error({ compileError: `Failed to parse compile trigger output, should be array of strings` }); - } - - console.log(`Workflow ${workflowId} compiled successfully, returning result...`, { registeredTriggers }); - - return Result.ok({ - compiledCode: compiledCodeResult.data, - registeredTriggers: registeredTriggers, - }); - }, 30_000); - - if (res.status === "error") { - console.warn(`Timed out compiling workflow ${workflowId} after ${res.error.ms}ms`, { res }); - return Result.error({ compileError: `Timed out compiling workflow ${workflowId} after ${res.error.ms}ms` }); - } - return res.data; - }); -} - -import.meta.vitest?.test("compileWorkflow", async ({ expect }) => { - const compileAndGetResult = async (tsSource: string) => { - const tenancy = { - id: "01234567-89ab-cdef-0123-456789abcdef", - project: { - id: "test-project", - }, - config: { - workflows: { - availableWorkflows: { - "test-workflow": { - enabled: true, - tsSource, - }, - }, - }, - }, - }; - - return await compileWorkflow(tenancy as any, "test-workflow"); - }; - const compileAndGetRegisteredTriggers = async (tsSource: string) => { - const res = await compileAndGetResult(tsSource); - if (res.status === "error") throw new StackAssertionError(`Failed to compile workflow: ${errorToNiceString(res.error)}`, { cause: res.error }); - return res.data.registeredTriggers; - }; - - expect(await compileAndGetRegisteredTriggers("console.log('hello, world!');")).toEqual([ - "compile", - "callback", - ]); - expect(await compileAndGetRegisteredTriggers("onSignUp(() => {}); registerCallback('test', () => {});")).toEqual([ - "compile", - "callback", - "sign-up", - ]); - expect(await compileAndGetResult("return return return return;")).toMatchInlineSnapshot(` - { - "error": { - "compileError": "Failed to compile workflow: Build failed with 1 error: - virtual:/source.tsx:1:7: ERROR: Unexpected "return"", - }, - "status": "error", - } - `); - expect(await compileAndGetResult("console.log('hello, world!'); throw new Error('test');")).toMatchInlineSnapshot(` - { - "error": { - "compileError": "Failed to initialize workflow: test", - }, - "status": "error", - } - `); -}); - -async function compileAndGetEnabledWorkflows(tenancy: Tenancy): Promise> { - // initialize ESBuild early so it doesn't count towards the 10s compilation timeout later - await initializeEsbuild(); - - const compilationVersion = 1; - const enabledWorkflows = new Map(await Promise.all(Object.entries(tenancy.config.workflows.availableWorkflows) - .filter(([_, workflow]) => workflow.enabled) - .map(async ([workflowId, workflow]) => [workflowId, { - id: workflowId, - workflow, - sourceHash: await hashWorkflowSource(workflow.tsSource), - }] as const))); - - const getWorkflowsToCompile = async (tx: Prisma.TransactionClient) => { - const compiledWorkflows = await tx.compiledWorkflow.findMany({ - where: { - tenancyId: tenancy.id, - workflowId: { in: [...enabledWorkflows.keys()] }, - compilationVersion, - sourceHash: { in: [...enabledWorkflows.values()].map(({ sourceHash }) => sourceHash) }, - }, - }); - - const found = new Map(); - const missing = new Set(enabledWorkflows.keys()); - for (const compiledWorkflow of compiledWorkflows) { - const enabledWorkflow = enabledWorkflows.get(compiledWorkflow.workflowId) ?? throwErr(`Compiled workflow ${compiledWorkflow.workflowId} not found in enabled workflows — this should not happen due to our Prisma filter!`); - if (enabledWorkflow.sourceHash === compiledWorkflow.sourceHash) { - found.set(compiledWorkflow.workflowId, compiledWorkflow); - missing.delete(compiledWorkflow.workflowId); - } - } - - const toCompile: string[] = []; - const waiting: string[] = []; - for (const workflowId of missing) { - const enabledWorkflow = enabledWorkflows.get(workflowId) ?? throwErr(`Enabled workflow ${workflowId} not found in enabled workflows — this should not happen due to our Prisma filter!`); - const currentlyCompiling = await tx.currentlyCompilingWorkflow.findUnique({ - where: { - tenancyId_workflowId_compilationVersion_sourceHash: { - tenancyId: tenancy.id, - workflowId, - compilationVersion, - sourceHash: enabledWorkflow.sourceHash, - }, - }, - }); - if (currentlyCompiling) { - waiting.push(workflowId); - } else { - toCompile.push(workflowId); - } - } - - if (toCompile.length > 0) { - await tx.currentlyCompilingWorkflow.createMany({ - data: toCompile.map((workflowId) => ({ - tenancyId: tenancy.id, - compilationVersion, - workflowId, - sourceHash: enabledWorkflows.get(workflowId)?.sourceHash ?? throwErr(`Enabled workflow ${workflowId} not found in enabled workflows — this should not happen due to our Prisma filter!`), - })), - }); - } - - return { - toCompile, - waiting, - workflows: found, - }; - }; - - let retryInfo = []; - const prisma = await getPrismaClientForTenancy(tenancy); - for (let retries = 0; retries < 10; retries++) { - const todo = await retryTransaction(prisma, async (tx) => { - return await getWorkflowsToCompile(tx); - }, { level: "serializable" }); - - retryInfo.push({ - toCompile: todo.toCompile, - waiting: todo.waiting, - done: [...todo.workflows.entries()].map(([workflowId, workflow]) => workflowId), - }); - - if (todo.toCompile.length === 0 && todo.waiting.length === 0) { - return todo.workflows; - } - - await allPromisesAndWaitUntilEach(todo.toCompile.map(async (workflowId) => { - const enabledWorkflow = enabledWorkflows.get(workflowId) ?? throwErr(`Enabled workflow ${workflowId} not found in enabled workflows — this should not happen due to our Prisma filter!`); - try { - const compiledWorkflow = await compileWorkflow(tenancy, workflowId); - await prisma.compiledWorkflow.create({ - data: { - tenancyId: tenancy.id, - compilationVersion, - workflowId, - sourceHash: enabledWorkflow.sourceHash, - ...compiledWorkflow.status === "ok" ? { - compiledCode: compiledWorkflow.data.compiledCode, - registeredTriggers: compiledWorkflow.data.registeredTriggers, - } : { - compileError: compiledWorkflow.error.compileError, - registeredTriggers: [], - }, - }, - }); - } finally { - await prisma.currentlyCompilingWorkflow.delete({ - where: { - tenancyId_workflowId_compilationVersion_sourceHash: { - tenancyId: tenancy.id, - compilationVersion, - workflowId, - sourceHash: enabledWorkflow.sourceHash, - }, - }, - }); - } - })); - - const { count } = await prisma.currentlyCompilingWorkflow.deleteMany({ - where: { - tenancyId: tenancy.id, - startedCompilingAt: { lt: new Date(Date.now() - 40_000) }, - }, - }); - if (count > 0) { - captureError("workflows-compile-timeout", new StackAssertionError(`Deleted ${count} currently compiling workflows that were compiling for more than 40 seconds; this probably indicates a bug in the workflow compilation code (as they should time out after 30 seconds)`)); - } - - await wait(1000); - } - - throw new StackAssertionError(`Timed out compiling workflows after retries`, { retryInfo }); -} - -async function triggerWorkflowRaw(tenancy: Tenancy, compiledWorkflowCode: string, trigger: WorkflowTrigger): Promise> { - return await traceSpan({ description: `triggerWorkflowRaw ${trigger.type}` }, async () => { - const workflowToken = generateSecureRandomString(); - const workflowTriggerToken = await globalPrismaClient.workflowTriggerToken.create({ - data: { - expiresAt: new Date(Date.now() + 1000 * 35), - tenancyId: tenancy.id, - tokenHash: await hashWorkflowTriggerToken(workflowToken), - }, - }); - - const tokenRefreshInterval = setInterval(() => { - runAsynchronously(async () => { - await globalPrismaClient.workflowTriggerToken.update({ - where: { - tenancyId_id: { - tenancyId: tenancy.id, - id: workflowTriggerToken.id, - }, - }, - data: { expiresAt: new Date(Date.now() + 1000 * 35) }, - }); - }); - }, 10_000); - - try { - const freestyle = new Freestyle(); - const apiUrl = new URL("/", getEnvVariable("NEXT_PUBLIC_STACK_API_URL").replace("http://localhost", "http://host.docker.internal")); - const freestyleRes = await freestyle.executeScript(compiledWorkflowCode, { - envVars: { - STACK_WORKFLOW_TRIGGER_DATA: JSON.stringify(trigger), - NEXT_PUBLIC_STACK_PROJECT_ID: tenancy.project.id, - NEXT_PUBLIC_STACK_API_URL: apiUrl.toString(), - NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY: "", - STACK_SECRET_SERVER_KEY: "", - STACK_WORKFLOW_TOKEN_SECRET: workflowToken, - }, - nodeModules: Object.fromEntries(Object.entries(externalPackages).map(([packageName, version]) => [packageName, version])), - networkPermissions: [ - { - action: "allow", - behavior: "exact", - query: apiUrl.host, - }, - ], - }); - return Result.map(freestyleRes, (data) => data.result); - } finally { - clearInterval(tokenRefreshInterval); - } - }); -} - -async function createScheduledTrigger(tenancy: Tenancy, workflowId: string, trigger: WorkflowTrigger, scheduledAt: Date) { - const executionId = trigger.type === "callback" ? trigger.executionId : generateUuid(); - - const prisma = await getPrismaClientForTenancy(tenancy); - const dbTrigger = await prisma.workflowTrigger.create({ - data: { - triggerData: trigger as any, - scheduledAt, - execution: { - connectOrCreate: { - where: { - tenancyId_id: { - tenancyId: tenancy.id, - id: executionId, - }, - }, - create: { - tenancyId: tenancy.id, - workflowId, - }, - }, - }, - }, - }); - - await upstash.publishJSON({ - url: new URL(`/api/v1/internal/trigger/run-scheduled`, getEnvVariable("NEXT_PUBLIC_STACK_API_URL").replace("http://localhost", "http://host.docker.internal")).toString(), - body: { - tenancyId: tenancy.id, - }, - notBefore: Math.floor(scheduledAt.getTime() / 1000), - }); - - return dbTrigger; -} - -export async function triggerScheduledWorkflows(tenancy: Tenancy) { - const prisma = await getPrismaClientForTenancy(tenancy); - const compiledWorkflows = await compileAndGetEnabledWorkflows(tenancy); - - const toTrigger = await retryTransaction(prisma, async (tx) => { - const triggers = await tx.workflowTrigger.findMany({ - where: { - tenancyId: tenancy.id, - scheduledAt: { lt: new Date(Date.now() + 5_000) }, - }, - include: { - execution: true, - }, - orderBy: { - scheduledAt: "asc", - }, - // let's take multiple triggers so we can catch up on the backlog, in case some triggers never went through (eg. if the queue was down) - // however, to prevent deadlocks as we are doing multiple writes in this transaction, we randomize it (so there's - // a chance that we only take one trigger, which would never deadlock) - take: Math.floor(1 + Math.random() * 3), - }); - const toTrigger = []; - for (const trigger of triggers) { - const compiledWorkflow = compiledWorkflows.get(trigger.execution.workflowId); - const updatedTrigger = await tx.workflowTrigger.update({ - where: { - tenancyId_id: { - tenancyId: tenancy.id, - id: trigger.id, - }, - }, - data: { - scheduledAt: null, - compiledWorkflowId: compiledWorkflow?.id ?? null, - output: Prisma.DbNull, - error: Prisma.DbNull, - }, - include: { - execution: true, - }, - }); - - if (compiledWorkflow) { - toTrigger.push(updatedTrigger); - } else { - // the workflow was deleted; we don't run the trigger, but we still mark it in the DB - } - } - return toTrigger; - }, { level: "serializable" }); - - await allPromisesAndWaitUntilEach(toTrigger.map(async (trigger) => { - const compiledWorkflow = compiledWorkflows.get(trigger.execution.workflowId) ?? throwErr(`Compiled workflow ${trigger.execution.workflowId} not found in trigger execution; this should not happen because we should've already checked for this in the transaction!`); - if (compiledWorkflow.compiledCode === null) { - return Result.error(`Workflow ${compiledWorkflow.id} failed to compile: ${compiledWorkflow.compileError}`); - } - - const res = await triggerWorkflowRaw(tenancy, compiledWorkflow.compiledCode, trigger.triggerData as WorkflowTrigger); - if (res.status === "error") { - // This is probably fine and just a user error, but let's log it regardless - console.log(`Compiled workflow failed to process trigger: ${res.error}`, { trigger, compiledWorkflowId: compiledWorkflow.id, res }); - } else { - if (res.data && typeof res.data === "object" && "scheduledCallback" in res.data && res.data.scheduledCallback && typeof res.data.scheduledCallback === "object") { - const scheduledCallback: any = res.data.scheduledCallback; - const callbackId = `${scheduledCallback.callbackId}`; - const scheduleAt = new Date(scheduledCallback.scheduleAtMillis); - const callbackData = scheduledCallback.data; - await createScheduledTrigger( - tenancy, - compiledWorkflow.id, - { - type: "callback", - callbackId, - data: callbackData, - scheduledAtMillis: scheduleAt.getTime(), - callerTriggerId: trigger.id, - executionId: trigger.executionId, - }, - scheduleAt - ); - } - } - - const prisma = await getPrismaClientForTenancy(tenancy); - await prisma.workflowTrigger.update({ - where: { - tenancyId_id: { - tenancyId: tenancy.id, - id: trigger.id, - }, - }, - data: { - ...res.status === "ok" ? { - output: res.data as any, - } : { - error: res.error, - }, - }, - }); - return Result.ok(undefined); - })); -} - -export async function triggerWorkflows(tenancy: Tenancy, trigger: WorkflowTrigger & { type: WorkflowRegisteredTriggerType }) { - runAsynchronouslyAndWaitUntil(async () => { - const compiledWorkflows = await compileAndGetEnabledWorkflows(tenancy); - const promises = [...compiledWorkflows] - .filter(([_, compiledWorkflow]) => compiledWorkflow.registeredTriggers.includes(trigger.type)) - .map(async ([workflowId, compiledWorkflow]) => { - await createScheduledTrigger(tenancy, workflowId, trigger, new Date()); - }); - await allPromisesAndWaitUntilEach(promises); - }); -} diff --git a/apps/backend/src/middleware.tsx b/apps/backend/src/middleware.tsx index 019269924..00cc77b31 100644 --- a/apps/backend/src/middleware.tsx +++ b/apps/backend/src/middleware.tsx @@ -30,7 +30,6 @@ const corsAllowedRequestHeaders = [ 'x-stack-secret-server-key', 'x-stack-super-secret-admin-key', 'x-stack-admin-access-token', - 'x-stack-workflow-token', // User auth 'x-stack-refresh-token', diff --git a/apps/backend/src/route-handlers/smart-request.tsx b/apps/backend/src/route-handlers/smart-request.tsx index e6035124e..530926824 100644 --- a/apps/backend/src/route-handlers/smart-request.tsx +++ b/apps/backend/src/route-handlers/smart-request.tsx @@ -6,7 +6,6 @@ import { checkApiKeySet, checkApiKeySetQuery } from "@/lib/internal-api-keys"; import { getProjectQuery, listManagedProjectIds } from "@/lib/projects"; import { DEFAULT_BRANCH_ID, Tenancy, getSoleTenancyFromProjectBranch } from "@/lib/tenancies"; import { decodeAccessToken } from "@/lib/tokens"; -import { hashWorkflowTriggerToken } from "@/lib/workflows"; import { globalPrismaClient, rawQueryAll } from "@/prisma-client"; import { KnownErrors } from "@stackframe/stack-shared"; import { ProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects"; @@ -168,7 +167,6 @@ const parseAuth = withTraceSpan('smart request parseAuth', async (req: NextReque const secretServerKey = req.headers.get("x-stack-secret-server-key"); const superSecretAdminKey = req.headers.get("x-stack-super-secret-admin-key"); const adminAccessToken = req.headers.get("x-stack-admin-access-token"); - const workflowToken = req.headers.get("x-stack-workflow-token"); const accessToken = req.headers.get("x-stack-access-token"); const developmentKeyOverride = req.headers.get("x-stack-development-override-key"); // in development, the internal project's API key can optionally be used to access any project const allowAnonymousUser = req.headers.get("x-stack-allow-anonymous-user") === "true"; @@ -277,25 +275,6 @@ const parseAuth = withTraceSpan('smart request parseAuth', async (req: NextReque } else if (adminAccessToken) { // TODO put this into the bundled queries above (not so important because this path is quite rare) await extractUserFromAdminAccessToken({ token: adminAccessToken, projectId }); // assert that the admin token is valid - } else if (workflowToken) { - // TODO put this into the bundled queries above (not so important because this path is quite rare) - if (requestType === "admin") { - throw new KnownErrors.AdminAuthenticationRequired(); - } - if (!["client", "server"].includes(requestType)) { - throw new StackAssertionError(`Unexpected request type in workflow token auth: ${requestType}. This should never happen because we should've filtered this earlier`); - } - const workflowTokenHash = await hashWorkflowTriggerToken(workflowToken); - const workflowTriggerToken = tenancy ? await globalPrismaClient.workflowTriggerToken.findUnique({ - where: { - tenancyId_tokenHash: { - tenancyId: tenancy.id, - tokenHash: workflowTokenHash, - }, - }, - }) : undefined; - if (!workflowTriggerToken) throw new KnownErrors.WorkflowTokenDoesNotExist(); - if (workflowTriggerToken.expiresAt < new Date()) throw new KnownErrors.WorkflowTokenExpired(); } else { switch (requestType) { case "client": { diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/workflows/[workflowId]/page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/workflows/[workflowId]/page.tsx deleted file mode 100644 index 8d437cfaf..000000000 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/workflows/[workflowId]/page.tsx +++ /dev/null @@ -1,128 +0,0 @@ -"use client"; - -import { useRouter } from "@/components/router"; -import { Button, Card, CardContent, CardHeader, CardTitle, toast } from "@stackframe/stack-ui"; -import { ArrowLeft, Save } from "lucide-react"; -import { useParams } from "next/navigation"; -import { useEffect, useState } from "react"; -import { AppEnabledGuard } from "../../app-enabled-guard"; -import { PageLayout } from "../../page-layout"; -import { useAdminApp } from "../../use-admin-app"; - -export default function WorkflowDetailPage() { - const params = useParams(); - const router = useRouter(); - const workflowId = params.workflowId as string; - const projectId = params.projectId as string; - - const stackAdminApp = useAdminApp(); - const project = stackAdminApp.useProject(); - const config = project.useConfig(); - - const availableWorkflows = config.workflows.availableWorkflows; - const workflow = workflowId in availableWorkflows ? availableWorkflows[workflowId] : undefined; - const [workflowContent, setWorkflowContent] = useState(""); - const [isLoading, setIsLoading] = useState(false); - const [isToggling, setIsToggling] = useState(false); - - useEffect(() => { - if (workflow && workflow.tsSource) { - setWorkflowContent(workflow.tsSource); - } - }, [workflow]); - - const handleSave = async () => { - setIsLoading(true); - try { - await project.updateConfig({ - [`workflows.availableWorkflows.${workflowId}.tsSource`]: workflowContent - }); - toast({ title: "Workflow saved successfully" }); - } catch (error) { - toast({ title: "Failed to save workflow", variant: "destructive" }); - } finally { - setIsLoading(false); - } - }; - - const handleBack = () => { - router.push(`/projects/${projectId}/workflows`); - }; - - const handleToggleEnabled = async () => { - if (!workflow) return; - setIsToggling(true); - try { - await project.updateConfig({ - [`workflows.availableWorkflows.${workflowId}.enabled`]: !workflow.enabled, - }); - toast({ title: workflow.enabled ? "Workflow disabled" : "Workflow enabled" }); - } catch (error) { - toast({ title: "Failed to toggle workflow", variant: "destructive" }); - } finally { - setIsToggling(false); - } - }; - - if (workflow === undefined) { - return ( - - -
-

The workflow {JSON.stringify(workflowId)} was not found.

- -
-
-
- ); - } - - return ( - - - - - - - } - > -
- - - Workflow Definition -

- {workflow.enabled ? "This workflow is enabled" : "This workflow is disabled"} -

-
- -
-