Delete Workflows app (thank you Vercel)

This commit is contained in:
Konstantin Wohlwend 2025-10-29 18:09:37 -07:00
parent 243ba48cb0
commit c8e730eed8
17 changed files with 10 additions and 1848 deletions

View File

@ -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;

View File

@ -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])
}

View File

@ -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;
},
});

View File

@ -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: {

View File

@ -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<string, string> = {};
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<Result<string, string>> {
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<Result<{ compiledCode: string, registeredTriggers: string[] }, { compileError?: string }>> {
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<Map<string, CompiledWorkflow>> {
// 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<string, CompiledWorkflow>();
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<Result<unknown, string>> {
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: "<placeholder publishable client key; the actual auth happens with the workflow token>",
STACK_SECRET_SERVER_KEY: "<placeholder secret server key; the actual auth happens with the workflow token>",
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);
});
}

View File

@ -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',

View File

@ -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": {

View File

@ -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 (
<AppEnabledGuard appId="workflows">
<PageLayout title="Workflow Not Found">
<div className="flex flex-col items-center justify-center h-full">
<p className="text-muted-foreground mb-4">The workflow {JSON.stringify(workflowId)} was not found.</p>
<Button onClick={handleBack} variant="outline">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Workflows
</Button>
</div>
</PageLayout>
</AppEnabledGuard>
);
}
return (
<AppEnabledGuard appId="workflows">
<PageLayout
title={workflow.displayName || workflowId}
actions={
<div className="flex gap-2">
<Button onClick={handleBack} variant="outline" size="sm">
<ArrowLeft className="h-4 w-4 mr-2" />
Back
</Button>
<Button onClick={handleToggleEnabled} size="sm" variant={workflow.enabled ? "outline" : "default"} disabled={isToggling}>
{workflow.enabled ? "Disable" : "Enable"}
</Button>
<Button onClick={handleSave} size="sm" disabled={isLoading}>
<Save className="h-4 w-4 mr-2" />
{isLoading ? "Saving..." : "Save"}
</Button>
</div>
}
>
<div className="flex gap-6 flex-1" style={{ flexBasis: "0px", overflow: "scroll" }}>
<Card className="w-full">
<CardHeader>
<CardTitle className="text-base">Workflow Definition</CardTitle>
<p className="text-sm text-muted-foreground">
{workflow.enabled ? "This workflow is enabled" : "This workflow is disabled"}
</p>
</CardHeader>
<CardContent>
<div className="relative">
<textarea
value={workflowContent}
onChange={(e) => setWorkflowContent(e.target.value)}
className="w-full min-h-[600px] p-4 font-mono text-sm bg-muted/50 border rounded-md resize-none focus:outline-none focus:ring-2 focus:ring-primary"
spellCheck={false}
placeholder="Enter your workflow code here..."
disabled={isLoading}
/>
</div>
</CardContent>
</Card>
</div>
</PageLayout>
</AppEnabledGuard>
);
}

View File

@ -1,365 +0,0 @@
"use client";
import { deindent } from "@stackframe/stack-shared/dist/utils/strings";
import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids";
import { Button, Card, CardContent, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, Input, Label, Textarea, toast } from "@stackframe/stack-ui";
import { Plus } from "lucide-react";
import { useState } from "react";
import { IllustratedInfo } from "../../../../../../components/illustrated-info";
import { AppEnabledGuard } from "../app-enabled-guard";
import { PageLayout } from "../page-layout";
import { useAdminApp } from "../use-admin-app";
import { WorkflowList } from "./workflow-list";
function EmptyState({ onCreateWorkflow }: { onCreateWorkflow: () => void }) {
return (
<div className="flex flex-col items-center justify-center h-full px-4 py-12 max-w-3xl mx-auto">
<IllustratedInfo
illustration={
<div className="grid grid-cols-2 gap-3">
<div className="bg-background rounded p-3 shadow-sm border">
<div className="h-2 bg-muted rounded mb-2"></div>
<div className="space-y-2">
<div className="flex gap-2">
<div className="h-6 w-6 bg-primary/20 rounded"></div>
<div className="h-6 flex-1 bg-muted rounded"></div>
</div>
<div className="flex gap-2 ml-6">
<div className="h-6 w-6 bg-primary/20 rounded"></div>
<div className="h-6 flex-1 bg-muted rounded"></div>
</div>
<div className="flex gap-2 ml-12">
<div className="h-6 w-6 bg-primary/20 rounded"></div>
<div className="h-6 flex-1 bg-muted rounded"></div>
</div>
</div>
</div>
<div className="bg-background rounded p-3 shadow-sm border">
<div className="h-2 bg-muted rounded mb-2"></div>
<div className="space-y-2">
<div className="flex gap-2">
<div className="h-6 w-6 bg-primary/20 rounded"></div>
<div className="h-6 flex-1 bg-muted rounded"></div>
</div>
<div className="flex gap-2 ml-6">
<div className="h-6 w-6 bg-primary/20 rounded"></div>
<div className="h-6 flex-1 bg-muted rounded"></div>
</div>
</div>
</div>
</div>
}
title="Welcome to Workflows!"
description={[
<>Workflows help you automate complex business processes.</>,
<>Create your first workflow to get started with automation.</>,
]}
/>
<Button onClick={onCreateWorkflow}>
<Plus className="h-4 w-4 mr-2" />
Create Your First Workflow
</Button>
</div>
);
}
function CreateWorkflowDialog({
open,
onOpenChange,
onSave
}: {
open: boolean,
onOpenChange: (open: boolean) => void,
onSave: (id: string, displayName: string, tsSource: string, enabled: boolean) => Promise<void>,
}) {
const [displayName, setDisplayName] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async () => {
if (!displayName) {
alert("Please fill in all required fields");
return;
}
setIsSubmitting(true);
try {
await onSave(generateUuid(), displayName, deindent`
onSignUp(async (user) => {
await stackApp.sendEmail({ userIds: [user.id], subject: "Welcome to the app!", html: "<p>Example email</p>" });
return scheduleCallback({
scheduleAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
data: { "userId": user.id },
callbackId: "in-7-days",
});
});
registerCallback("in-7-days", async (data) => {
await stackApp.sendEmail({ userIds: [data.userId], subject: "Welcome to the app!", html: "<p>Example email</p>" });
});
`, false);
onOpenChange(false);
setDisplayName("");
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Create New Workflow</DialogTitle>
</DialogHeader>
<div className="space-y-4 mb-4 mt-4">
<div>
<Label htmlFor="workflow-name">Display Name</Label>
<Input
id="workflow-name"
placeholder="e.g., User Onboarding"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
width="200px"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting ? "Creating..." : "Create Workflow"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default function WorkflowsPage() {
const stackAdminApp = useAdminApp();
const project = stackAdminApp.useProject();
const config = project.useConfig();
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [showEditDialog, setShowEditDialog] = useState(false);
const [editingWorkflow, setEditingWorkflow] = useState<any>(null);
const workflows = Object.entries(config.workflows.availableWorkflows).map(([id, workflow]) => ({
id,
...workflow
}));
const handleCreateWorkflow = () => {
setShowCreateDialog(true);
};
const handleSaveWorkflow = async (id: string, displayName: string, tsSource: string, enabled: boolean) => {
await project.updateConfig({
[`workflows.availableWorkflows.${id}`]: {
displayName,
tsSource,
enabled,
}
});
toast({ title: "Workflow created successfully" });
};
const handleEditWorkflow = (workflow: any) => {
setEditingWorkflow(workflow);
setShowEditDialog(true);
};
const handleUpdateWorkflow = async (id: string, displayName: string, tsSource: string) => {
await project.updateConfig({
[`workflows.availableWorkflows.${id}`]: {
displayName,
tsSource,
enabled: editingWorkflow?.enabled ?? true
}
});
toast({ title: "Workflow updated successfully" });
setShowEditDialog(false);
setEditingWorkflow(null);
};
const handleDeleteWorkflow = async (workflowId: string) => {
if (confirm(`Are you sure you want to delete the workflow "${workflowId}"?`)) {
await project.updateConfig({
[`workflows.availableWorkflows.${workflowId}`]: null
});
toast({ title: "Workflow deleted" });
}
};
const handleDuplicateWorkflow = async (workflow: any) => {
const newId = `${workflow.id}_copy`;
let finalId = newId;
let counter = 1;
// Find unique ID
const availableWorkflows = config.workflows.availableWorkflows;
while (finalId in availableWorkflows) {
finalId = `${newId}_${counter}`;
counter++;
}
await project.updateConfig({
[`workflows.availableWorkflows.${finalId}`]: {
displayName: `${workflow.displayName} (Copy)`,
tsSource: workflow.tsSource,
enabled: false
}
});
toast({ title: "Workflow duplicated successfully" });
};
const handleToggleEnabled = async (workflow: any) => {
await project.updateConfig({
[`workflows.availableWorkflows.${workflow.id}.enabled`]: !workflow.enabled
});
toast({
title: workflow.enabled ? "Workflow disabled" : "Workflow enabled"
});
};
const content = workflows.length === 0 ? (
<>
<EmptyState onCreateWorkflow={handleCreateWorkflow} />
<CreateWorkflowDialog
open={showCreateDialog}
onOpenChange={setShowCreateDialog}
onSave={handleSaveWorkflow}
/>
</>
) : (
<>
<PageLayout title="Workflows">
<div className="flex gap-6 flex-1" style={{ flexBasis: "0px", overflow: "scroll" }}>
<Card className="flex w-full">
<CardContent className="flex w-full p-0">
<div className="flex-1">
<WorkflowList
workflows={workflows}
projectId={project.id}
onAddClick={handleCreateWorkflow}
onEdit={handleEditWorkflow}
onDelete={(id) => {
handleDeleteWorkflow(id).catch(() => {
toast({ title: "Failed to delete workflow", variant: "destructive" });
});
}}
onDuplicate={(workflow) => {
handleDuplicateWorkflow(workflow).catch(() => {
toast({ title: "Failed to duplicate workflow", variant: "destructive" });
});
}}
onToggleEnabled={(workflow) => {
handleToggleEnabled(workflow).catch(() => {
toast({ title: "Failed to toggle workflow", variant: "destructive" });
});
}}
/>
</div>
</CardContent>
</Card>
</div>
</PageLayout>
<CreateWorkflowDialog
open={showCreateDialog}
onOpenChange={setShowCreateDialog}
onSave={handleSaveWorkflow}
/>
{editingWorkflow && (
<Dialog open={showEditDialog} onOpenChange={setShowEditDialog}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Edit Workflow</DialogTitle>
</DialogHeader>
<EditWorkflowForm
workflow={editingWorkflow}
onSave={handleUpdateWorkflow}
onCancel={() => {
setShowEditDialog(false);
setEditingWorkflow(null);
}}
/>
</DialogContent>
</Dialog>
)}
</>
);
return (
<AppEnabledGuard appId="workflows">
{content}
</AppEnabledGuard>
);
}
function EditWorkflowForm({
workflow,
onSave,
onCancel
}: {
workflow: any,
onSave: (id: string, displayName: string, tsSource: string) => Promise<void>,
onCancel: () => void,
}) {
const [displayName, setDisplayName] = useState(workflow.displayName);
const [tsSource, setTsSource] = useState(workflow.tsSource);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async () => {
if (!displayName) {
toast({ title: "Please fill in all required fields", variant: "destructive" });
return;
}
setIsSubmitting(true);
try {
await onSave(workflow.id, displayName, tsSource);
} finally {
setIsSubmitting(false);
}
};
return (
<>
<div className="space-y-4">
<div>
<Label htmlFor="edit-workflow-id">Workflow ID</Label>
<Input
id="edit-workflow-id"
value={workflow.id}
disabled
className="bg-muted"
/>
</div>
<div>
<Label htmlFor="edit-workflow-name">Display Name</Label>
<Input
id="edit-workflow-name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
/>
</div>
<div>
<Label htmlFor="edit-workflow-source">TypeScript Source</Label>
<Textarea
id="edit-workflow-source"
className="font-mono text-sm min-h-[200px]"
value={tsSource}
onChange={(e) => setTsSource(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onCancel} disabled={isSubmitting}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting ? "Saving..." : "Save Changes"}
</Button>
</DialogFooter>
</>
);
}

View File

@ -1,159 +0,0 @@
import { useRouter } from "@/components/router";
import { cn } from "@/lib/utils";
import { Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@stackframe/stack-ui";
import { MoreVertical } from "lucide-react";
import { useState } from "react";
import { ListSection } from "../payments/products/list-section";
type Workflow = {
id: string,
displayName: string,
tsSource: string,
enabled: boolean,
};
type WorkflowListItemProps = {
workflow: Workflow,
projectId: string,
onEdit?: () => void,
onDelete?: () => void,
onDuplicate?: () => void,
onToggleEnabled?: () => void,
};
function WorkflowListItem({
workflow,
projectId,
onEdit,
onDelete,
onDuplicate,
onToggleEnabled,
}: WorkflowListItemProps) {
const router = useRouter();
const [isMenuOpen, setIsMenuOpen] = useState(false);
const handleClick = () => {
router.push(`/projects/${projectId}/workflows/${workflow.id}`);
};
return (
<div
className={cn(
"px-3 py-3 cursor-pointer relative duration-200 hover:duration-0 hover:bg-primary/10 transition-colors flex items-center justify-between group"
)}
onClick={handleClick}
>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-sm">{workflow.displayName}</span>
<span
className={cn(
"px-2 py-0.5 text-xs rounded-full border",
workflow.enabled
? "bg-green-500/10 text-green-600 border-green-500/20"
: "bg-gray-500/10 text-gray-600 border-gray-500/20"
)}
>
{workflow.enabled ? "Enabled" : "Disabled"}
</span>
</div>
<div className="text-xs text-muted-foreground font-mono mb-1">
{workflow.id}
</div>
<div className="text-xs text-muted-foreground">
{workflow.tsSource ? `${workflow.tsSource.split('\n').length} lines of code` : "No source code"}
</div>
</div>
<div
onClick={(e) => e.stopPropagation()}
onMouseEnter={() => setIsMenuOpen(true)}
onMouseLeave={() => setIsMenuOpen(false)}
>
<DropdownMenu open={isMenuOpen} onOpenChange={setIsMenuOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className={cn(
"h-8 w-8 p-0 relative",
"hover:bg-secondary/80",
isMenuOpen && "bg-secondary/80"
)}
>
<MoreVertical className="h-4 w-4" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[150px]">
<DropdownMenuItem onClick={onToggleEnabled}>
{workflow.enabled ? "Disable" : "Enable"}
</DropdownMenuItem>
<DropdownMenuItem onClick={onEdit}>Edit</DropdownMenuItem>
<DropdownMenuItem onClick={onDuplicate}>Duplicate</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={onDelete}
className="text-destructive focus:text-destructive"
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
}
export function WorkflowList({
workflows,
projectId,
onAddClick,
onEdit,
onDelete,
onDuplicate,
onToggleEnabled
}: {
workflows: Workflow[],
projectId: string,
onAddClick?: () => void,
onEdit?: (workflow: Workflow) => void,
onDelete?: (workflowId: string) => void,
onDuplicate?: (workflow: Workflow) => void,
onToggleEnabled?: (workflow: Workflow) => void,
}) {
const [searchQuery, setSearchQuery] = useState("");
const filteredWorkflows = workflows.filter((workflow) => {
const query = searchQuery.toLowerCase();
return (
workflow.id.toLowerCase().includes(query) ||
workflow.displayName.toLowerCase().includes(query) ||
(workflow.tsSource && workflow.tsSource.toLowerCase().includes(query))
);
});
return (
<ListSection
title="Workflows"
titleTooltip="Workflows automate complex business processes and integrations"
onAddClick={onAddClick}
searchValue={searchQuery}
onSearchChange={setSearchQuery}
searchPlaceholder="Search workflows..."
>
<div>
{filteredWorkflows.map((workflow) => (
<WorkflowListItem
key={workflow.id}
workflow={workflow}
projectId={projectId}
onEdit={() => onEdit?.(workflow)}
onDelete={() => onDelete?.(workflow.id)}
onDuplicate={() => onDuplicate?.(workflow)}
onToggleEnabled={() => onToggleEnabled?.(workflow)}
/>
))}
</div>
</ListSection>
);
}

View File

@ -2,7 +2,7 @@ import { Link } from "@/components/link";
import { StackAdminApp } from "@stackframe/stack";
import { AppId } from "@stackframe/stack-shared/dist/apps/apps-config";
import { getRelativePart, isChildUrl } from "@stackframe/stack-shared/dist/utils/urls";
import { CreditCard, KeyRound, Mail, Mails, Rocket, ShieldEllipsis, Sparkles, Triangle, Tv, UserCog, Users, Vault, Webhook, Workflow } from "lucide-react";
import { CreditCard, KeyRound, Mail, Mails, Rocket, ShieldEllipsis, Sparkles, Triangle, Tv, UserCog, Users, Vault, Webhook } from "lucide-react";
import Image from "next/image";
import ConvexLogo from "../../public/convex-logo.png";
import LogoBright from "../../public/logo-bright.svg";
@ -147,15 +147,6 @@ export const ALL_APPS_FRONTEND = {
screenshots: [],
storeDescription: <></>,
},
workflows: {
icon: Workflow,
href: "workflows",
navigationItems: [
{ displayName: "Workflows", href: "." },
],
screenshots: [],
storeDescription: <></>,
},
webhooks: {
icon: Webhook,
href: "webhooks",

View File

@ -1,374 +0,0 @@
import { wait } from "@stackframe/stack-shared/dist/utils/promises";
import { Mailbox, test } from "../helpers";
import { withPortPrefix } from "../helpers/ports";
import { Auth, InternalApiKey, Project, bumpEmailAddress, createMailbox, niceBackendFetch } from "./backend-helpers";
async function configureEmailAndWorkflow(workflowId: string, tsSource: string, enabled = true) {
await Project.updateConfig({
emails: {
server: {
isShared: false,
host: "localhost",
port: Number(withPortPrefix("29")),
username: "test",
password: "test",
senderEmail: "test@example.com",
senderName: "Test Project",
},
},
workflows: {
availableWorkflows: {
[workflowId]: {
displayName: workflowId,
tsSource,
enabled,
},
},
},
});
}
const waitRetries = 25;
async function waitForMailboxSubject(mailbox: Mailbox, subject: string) {
for (let i = 0; i < waitRetries; i++) {
const messages = await mailbox.fetchMessages();
const message = messages.find((m) => m.subject === subject);
if (message) return;
await wait(1_000);
}
throw new Error(`Message with subject ${subject} not found after ${waitRetries} tries`);
}
async function waitForServerMetadataNotNull(userId: string, key: string) {
for (let i = 0; i < waitRetries; i++) {
const user = await niceBackendFetch(`/api/v1/users/${userId}`, { accessType: "server" });
if (user.body.server_metadata?.[key]) return;
await wait(1_000);
}
throw new Error(`Server metadata for user ${userId} with key ${key} not found after ${waitRetries} tries`);
}
test("onSignUp workflow sends email for client sign-up", async ({ expect }) => {
await Project.createAndSwitch();
await InternalApiKey.createAndSetProjectKeys();
const mailbox = await bumpEmailAddress({ unindexed: true });
const subject = `WF client signup ${crypto.randomUUID()}`;
await configureEmailAndWorkflow("wf-email", `
onSignUp(async (user) => {
await stackApp.sendEmail({ userIds: [user.id], subject: ${JSON.stringify(subject)}, html: "<p>hi</p>" });
// schedule a callback as an example (we don't actually test whether it executed successfully)
return scheduleCallback({
scheduleAt: new Date(Date.now() + 7_000),
data: { "example": "data" },
callbackId: "my-callback",
});
});
registerCallback("my-callback", async (data) => {
console.log("my-callback", data);
});
`);
await Auth.Password.signUpWithEmail({ password: "password" });
await waitForMailboxSubject(mailbox, subject);
expect(await mailbox.fetchMessages()).toMatchInlineSnapshot(`
[
MailboxMessage {
"attachments": [],
"body": {
"html": "http://localhost:12345/some-callback-url?code=%3Cstripped+query+param%3E",
"text": "http://localhost:12345/some-callback-url?code=%3Cstripped+query+param%3E",
},
"from": "Test Project <test@example.com>",
"subject": "Verify your email at New Project",
"to": ["<unindexed-mailbox--<stripped UUID>@stack-generated.example.com>"],
<some fields may have been hidden>,
},
MailboxMessage {
"attachments": [],
"body": {
"html": "<!DOCTYPE html PUBLIC \\"-//W3C//DTD XHTML 1.0 Transitional//EN\\" \\"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\\"><html dir=\\"ltr\\" lang=\\"en\\"><head><meta content=\\"text/html; charset=UTF-8\\" http-equiv=\\"Content-Type\\"/><meta name=\\"x-apple-disable-message-reformatting\\"/></head><body style=\\"background-color:rgb(250,251,251);font-family:ui-sans-serif, system-ui, sans-serif, &quot;Apple Color Emoji&quot;, &quot;Segoe UI Emoji&quot;, &quot;Segoe UI Symbol&quot;, &quot;Noto Color Emoji&quot;;font-size:1rem;line-height:1.5rem\\"><!--$--><table align=\\"center\\" width=\\"100%\\" border=\\"0\\" cellPadding=\\"0\\" cellSpacing=\\"0\\" role=\\"presentation\\" style=\\"background-color:rgb(255,255,255);padding:45px;border-radius:0.5rem;max-width:37.5em\\"><tbody><tr style=\\"width:100%\\"><td><div><p>hi</p></div></td></tr></tbody></table><!--7--><!--/$--></body></html>",
"text": "hi",
},
"from": "Test Project <test@example.com>",
"subject": "WF client signup <stripped UUID>",
"to": ["<unindexed-mailbox--<stripped UUID>@stack-generated.example.com>"],
<some fields may have been hidden>,
},
]
`);
}, {
timeout: 60_000,
});
test("onSignUp workflow can schedule callbacks", async ({ expect }) => {
await Project.createAndSwitch();
await InternalApiKey.createAndSetProjectKeys();
const mailbox = await bumpEmailAddress({ unindexed: true });
const subject = `WF client signup ${crypto.randomUUID()}`;
await configureEmailAndWorkflow("wf-email", `
onSignUp(async (user) => {
return scheduleCallback({
scheduleAt: new Date(Date.now() + 7_000),
data: { "userId": user.id },
callbackId: "my-callback",
});
});
registerCallback("my-callback", async (data) => {
await stackApp.sendEmail({ userIds: [data.userId], subject: ${JSON.stringify(subject)}, html: "<p>hi</p>" });
});
`);
await Auth.Password.signUpWithEmail({ password: "password" });
// since we wait for the callback, add some extra time
await wait(10_000);
await waitForMailboxSubject(mailbox, subject);
expect(await mailbox.fetchMessages()).toMatchInlineSnapshot(`
[
MailboxMessage {
"attachments": [],
"body": {
"html": "http://localhost:12345/some-callback-url?code=%3Cstripped+query+param%3E",
"text": "http://localhost:12345/some-callback-url?code=%3Cstripped+query+param%3E",
},
"from": "Test Project <test@example.com>",
"subject": "Verify your email at New Project",
"to": ["<unindexed-mailbox--<stripped UUID>@stack-generated.example.com>"],
<some fields may have been hidden>,
},
MailboxMessage {
"attachments": [],
"body": {
"html": "<!DOCTYPE html PUBLIC \\"-//W3C//DTD XHTML 1.0 Transitional//EN\\" \\"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\\"><html dir=\\"ltr\\" lang=\\"en\\"><head><meta content=\\"text/html; charset=UTF-8\\" http-equiv=\\"Content-Type\\"/><meta name=\\"x-apple-disable-message-reformatting\\"/></head><body style=\\"background-color:rgb(250,251,251);font-family:ui-sans-serif, system-ui, sans-serif, &quot;Apple Color Emoji&quot;, &quot;Segoe UI Emoji&quot;, &quot;Segoe UI Symbol&quot;, &quot;Noto Color Emoji&quot;;font-size:1rem;line-height:1.5rem\\"><!--$--><table align=\\"center\\" width=\\"100%\\" border=\\"0\\" cellPadding=\\"0\\" cellSpacing=\\"0\\" role=\\"presentation\\" style=\\"background-color:rgb(255,255,255);padding:45px;border-radius:0.5rem;max-width:37.5em\\"><tbody><tr style=\\"width:100%\\"><td><div><p>hi</p></div></td></tr></tbody></table><!--7--><!--/$--></body></html>",
"text": "hi",
},
"from": "Test Project <test@example.com>",
"subject": "WF client signup <stripped UUID>",
"to": ["<unindexed-mailbox--<stripped UUID>@stack-generated.example.com>"],
<some fields may have been hidden>,
},
]
`);
}, {
timeout: 60_000,
});
test("onSignUp workflow sends email for server-created user", async ({ expect }) => {
await Project.createAndSwitch();
await InternalApiKey.createAndSetProjectKeys();
const mailbox = createMailbox(`wf-server-${crypto.randomUUID()}@stack-generated.example.com`);
const subject = `WF server create ${crypto.randomUUID()}`;
await configureEmailAndWorkflow("wf-email-server", `
onSignUp(async (user) => {
await stackApp.sendEmail({ userIds: [user.id], subject: ${JSON.stringify(subject)}, html: "<p>server</p>" });
});
`);
const createUserRes = await niceBackendFetch("/api/v1/users", {
method: "POST",
accessType: "server",
body: {
primary_email: mailbox.emailAddress,
primary_email_verified: true,
},
});
expect(createUserRes.status).toBe(201);
await waitForMailboxSubject(mailbox, subject);
expect(await mailbox.fetchMessages()).toMatchInlineSnapshot(`
[
MailboxMessage {
"attachments": [],
"body": {
"html": "<!DOCTYPE html PUBLIC \\"-//W3C//DTD XHTML 1.0 Transitional//EN\\" \\"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\\"><html dir=\\"ltr\\" lang=\\"en\\"><head><meta content=\\"text/html; charset=UTF-8\\" http-equiv=\\"Content-Type\\"/><meta name=\\"x-apple-disable-message-reformatting\\"/></head><body style=\\"background-color:rgb(250,251,251);font-family:ui-sans-serif, system-ui, sans-serif, &quot;Apple Color Emoji&quot;, &quot;Segoe UI Emoji&quot;, &quot;Segoe UI Symbol&quot;, &quot;Noto Color Emoji&quot;;font-size:1rem;line-height:1.5rem\\"><!--$--><table align=\\"center\\" width=\\"100%\\" border=\\"0\\" cellPadding=\\"0\\" cellSpacing=\\"0\\" role=\\"presentation\\" style=\\"background-color:rgb(255,255,255);padding:45px;border-radius:0.5rem;max-width:37.5em\\"><tbody><tr style=\\"width:100%\\"><td><div><p>server</p></div></td></tr></tbody></table><!--7--><!--/$--></body></html>",
"text": "server",
},
"from": "Test Project <test@example.com>",
"subject": "WF server create <stripped UUID>",
"to": ["<wf-server-<stripped UUID>@stack-generated.example.com>"],
<some fields may have been hidden>,
},
]
`);
}, {
timeout: 60_000,
});
test("disabled workflows do not trigger", async ({ expect }) => {
await Project.createAndSwitch();
await InternalApiKey.createAndSetProjectKeys();
const mailbox = await bumpEmailAddress({ unindexed: true });
const subject = `WF disabled ${crypto.randomUUID()}`;
await configureEmailAndWorkflow("wf-disabled", `
onSignUp(async (user) => {
await stackApp.sendEmail({ userIds: [user.id], subject: ${JSON.stringify(subject)}, html: "<p>nope</p>" });
});
`, /* enabled */ false);
await Auth.Password.signUpWithEmail({ password: "password" });
await wait(waitRetries * 1_000 * 1.3);
await Auth.refreshAccessToken();
expect(await mailbox.fetchMessages()).toMatchInlineSnapshot(`
[
MailboxMessage {
"attachments": [],
"body": {
"html": "http://localhost:12345/some-callback-url?code=%3Cstripped+query+param%3E",
"text": "http://localhost:12345/some-callback-url?code=%3Cstripped+query+param%3E",
},
"from": "Test Project <test@example.com>",
"subject": "Verify your email at New Project",
"to": ["<unindexed-mailbox--<stripped UUID>@stack-generated.example.com>"],
<some fields may have been hidden>,
},
]
`);
}, {
timeout: 90_000,
});
test("compile/runtime errors in one workflow don't block others", async ({ expect }) => {
await Project.createAndSwitch();
await InternalApiKey.createAndSetProjectKeys();
const mailbox = await bumpEmailAddress({ unindexed: true });
const subject = `WF ok ${crypto.randomUUID()}`;
// bad compile
await configureEmailAndWorkflow("wf-bad-compile", `return return`);
// bad runtime
await configureEmailAndWorkflow("wf-bad-runtime", `onSignUp(() => { throw new Error('boom') });`);
// good one
await configureEmailAndWorkflow("wf-good", `
onSignUp(async (user) => {
await stackApp.sendEmail({ userIds: [user.id], subject: ${JSON.stringify(subject)}, html: "<p>ok</p>" });
});
`);
await Auth.Password.signUpWithEmail({ password: "password" });
await waitForMailboxSubject(mailbox, subject);
expect(await mailbox.fetchMessages()).toMatchInlineSnapshot(`
[
MailboxMessage {
"attachments": [],
"body": {
"html": "http://localhost:12345/some-callback-url?code=%3Cstripped+query+param%3E",
"text": "http://localhost:12345/some-callback-url?code=%3Cstripped+query+param%3E",
},
"from": "Test Project <test@example.com>",
"subject": "Verify your email at New Project",
"to": ["<unindexed-mailbox--<stripped UUID>@stack-generated.example.com>"],
<some fields may have been hidden>,
},
MailboxMessage {
"attachments": [],
"body": {
"html": "<!DOCTYPE html PUBLIC \\"-//W3C//DTD XHTML 1.0 Transitional//EN\\" \\"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\\"><html dir=\\"ltr\\" lang=\\"en\\"><head><meta content=\\"text/html; charset=UTF-8\\" http-equiv=\\"Content-Type\\"/><meta name=\\"x-apple-disable-message-reformatting\\"/></head><body style=\\"background-color:rgb(250,251,251);font-family:ui-sans-serif, system-ui, sans-serif, &quot;Apple Color Emoji&quot;, &quot;Segoe UI Emoji&quot;, &quot;Segoe UI Symbol&quot;, &quot;Noto Color Emoji&quot;;font-size:1rem;line-height:1.5rem\\"><!--$--><table align=\\"center\\" width=\\"100%\\" border=\\"0\\" cellPadding=\\"0\\" cellSpacing=\\"0\\" role=\\"presentation\\" style=\\"background-color:rgb(255,255,255);padding:45px;border-radius:0.5rem;max-width:37.5em\\"><tbody><tr style=\\"width:100%\\"><td><div><p>ok</p></div></td></tr></tbody></table><!--7--><!--/$--></body></html>",
"text": "ok",
},
"from": "Test Project <test@example.com>",
"subject": "WF ok <stripped UUID>",
"to": ["<unindexed-mailbox--<stripped UUID>@stack-generated.example.com>"],
<some fields may have been hidden>,
},
]
`);
}, {
timeout: 60_000,
});
test("anonymous sign-up does not trigger; upgrade triggers workflow", async ({ expect }) => {
await Project.createAndSwitch();
await InternalApiKey.createAndSetProjectKeys();
const markerKey = `wfMarker-${crypto.randomUUID()}`;
await Project.updateConfig({
workflows: {
availableWorkflows: {
"wf-anon-upgrade": {
displayName: "wf-anon-upgrade",
enabled: true,
tsSource: `onSignUp(async (user) => { await user.update({ serverMetadata: { ${JSON.stringify(markerKey)}: user.primaryEmail } }); });`,
},
},
},
});
// create anonymous session/user
const { userId: anonUserId } = await Auth.Anonymous.signUp();
// ensure marker not present yet
await wait(waitRetries * 1_000 * 1.3);
await Auth.refreshAccessToken();
const me1 = await niceBackendFetch("/api/v1/users/me", { accessType: "client" });
expect(me1.body.server_metadata?.[markerKey]).toBeUndefined();
// upgrade via password sign-up
const { userId } = await Auth.Password.signUpWithEmail({ password: "password" });
expect(userId).toEqual(anonUserId);
await waitForServerMetadataNotNull(anonUserId, markerKey);
const me2 = await niceBackendFetch("/api/v1/users/me", { accessType: "server" });
expect(me2.body.is_anonymous).toBe(false);
expect(me2.body.server_metadata?.[markerKey]).toBe(me2.body.primary_email);
}, {
timeout: 90_000,
});
test("workflow source changes take effect for subsequent sign-ups", async ({ expect }) => {
await Project.createAndSwitch();
await InternalApiKey.createAndSetProjectKeys();
const markerKey = `versionMarker-${crypto.randomUUID()}`;
// v1
await Project.updateConfig({
workflows: {
availableWorkflows: {
"wf-versioned": {
displayName: "wf-versioned",
enabled: true,
tsSource: `onSignUp(async (user) => { await user.update({ serverMetadata: { ${JSON.stringify(markerKey)}: "v1" } }); });`,
},
},
},
});
await bumpEmailAddress({ unindexed: true });
await Auth.Password.signUpWithEmail({ password: "password" });
await waitForServerMetadataNotNull("me", markerKey);
const me1 = await niceBackendFetch("/api/v1/users/me", { accessType: "server" });
expect(me1.body.server_metadata?.[markerKey]).toBe("v1");
// v2
await Project.updateConfig({
workflows: {
availableWorkflows: {
"wf-versioned": {
displayName: "wf-versioned",
enabled: true,
tsSource: `onSignUp(async (user) => { await user.update({ serverMetadata: { ${JSON.stringify(markerKey)}: "v2" } }); });`,
},
},
},
});
await bumpEmailAddress({ unindexed: true });
await Auth.Password.signUpWithEmail({ password: "password" });
await waitForServerMetadataNotNull("me", markerKey);
const me2 = await niceBackendFetch("/api/v1/users/me", { accessType: "server" });
expect(me2.body.server_metadata?.[markerKey]).toBe("v2");
}, {
timeout: 90_000,
});

View File

@ -5,3 +5,6 @@ A: Host ports use `${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}` plus the two-digit suff
Q: How can I show helper text beneath metadata text areas in the dashboard?
A: Use the shared `TextAreaField` component's `helperText` prop in `apps/dashboard/src/components/form-fields.tsx`; it now renders the helper content in a secondary Typography line under the textarea.
Q: Why did `pnpm typecheck` fail after deleting a Next.js route?
A: The generated `.next/types/validator.ts` can keep stale imports for removed routes. Deleting that file (or regenerating Next build output) clears the outdated references so `pnpm typecheck` succeeds again.

View File

@ -96,12 +96,6 @@ export const ALL_APPS = {
tags: ["security", "storage"],
stage: "beta",
},
"workflows": {
displayName: "Workflows",
subtitle: "Automated business process orchestration",
tags: ["automation"],
stage: "beta",
},
"webhooks": {
displayName: "Webhooks",
subtitle: "Real-time event notifications and integrations",

View File

@ -124,15 +124,6 @@ const branchSchemaFuzzerConfig = [{
}],
}],
}],
workflows: [{
availableWorkflows: [{
"some-workflow-id": [{
displayName: ["Some Workflow", "Some Other Workflow"],
tsSource: ["", "some typescript source code"],
enabled: [true, false],
}],
}],
}],
teams: [{
createPersonalTeamOnSignUp: [true, false],
allowClientTeamCreation: [true, false],
@ -315,4 +306,3 @@ import.meta.vitest?.test("fuzz schemas", async ({ expect }) => {
}
}
});

View File

@ -167,16 +167,6 @@ const branchDomain = yupObject({
allowLocalhost: yupBoolean(),
});
const branchWorkflowsSchema = yupObject({
availableWorkflows: yupRecord(
userSpecifiedIdSchema("workflowId"),
yupObject({
displayName: yupString(),
tsSource: yupString(),
enabled: yupBoolean(),
}),
),
});
export const branchConfigSchema = canNoLongerBeOverridden(projectConfigSchema, ["sourceOfTruth"]).concat(yupObject({
rbac: branchRbacSchema,
@ -214,8 +204,6 @@ export const branchConfigSchema = canNoLongerBeOverridden(projectConfigSchema, [
}),
),
}),
workflows: branchWorkflowsSchema,
}));
@ -572,14 +560,6 @@ const organizationConfigDefaults = {
displayName: "Unnamed Vault",
}),
},
workflows: {
availableWorkflows: (key: string) => ({
displayName: "Unnamed Workflow",
tsSource: "Error: Workflow config is missing TypeScript source code.",
enabled: false,
}),
},
} as const satisfies DefaultsType<OrganizationRenderedConfigBeforeDefaults, [typeof environmentConfigDefaults, typeof branchConfigDefaults, typeof projectConfigDefaults]>;
type _DeepOmitDefaultsImpl<T, U> = T extends object ? (

View File

@ -1566,25 +1566,6 @@ const StripeAccountInfoNotFound = createKnownErrorConstructor(
() => [] as const,
);
const WorkflowTokenDoesNotExist = createKnownErrorConstructor(
KnownError,
"WORKFLOW_TOKEN_DOES_NOT_EXIST",
() => [
400,
"The workflow token you specified does not exist. Make sure the value in x-stack-workflow-token is correct.",
] as const,
() => [] as const,
);
const WorkflowTokenExpired = createKnownErrorConstructor(
KnownError,
"WORKFLOW_TOKEN_EXPIRED",
() => [
400,
"The workflow token you specified has expired. Make sure the value in x-stack-workflow-token is correct.",
] as const,
() => [] as const,
);
export type KnownErrors = {
[K in keyof typeof KnownErrors]: InstanceType<typeof KnownErrors[K]>;
@ -1711,8 +1692,6 @@ export const KnownErrors = {
StripeAccountInfoNotFound,
DataVaultStoreDoesNotExist,
DataVaultStoreHashedKeyDoesNotExist,
WorkflowTokenDoesNotExist,
WorkflowTokenExpired,
} satisfies Record<string, KnownErrorConstructor<any, any>>;