diff --git a/.vscode/settings.json b/.vscode/settings.json index eda6e4f46..9d0b243f8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -76,6 +76,7 @@ "Proxied", "psql", "qrcode", + "QSTASH", "quetzallabs", "rehype", "reqs", diff --git a/apps/backend/.env b/apps/backend/.env index 6be4449b2..c824e0988 100644 --- a/apps/backend/.env +++ b/apps/backend/.env @@ -63,7 +63,13 @@ STACK_AWS_REGION= STACK_AWS_KMS_ENDPOINT= STACK_AWS_ACCESS_KEY_ID= STACK_AWS_SECRET_ACCESS_KEY= +STACK_AWS_VERCEL_OIDC_ROLE_ARN= +# Upstash configuration +STACK_QSTASH_URL= +STACK_QSTASH_TOKEN= +STACK_QSTASH_CURRENT_SIGNING_KEY= +STACK_QSTASH_NEXT_SIGNING_KEY= # Misc, optional diff --git a/apps/backend/.env.development b/apps/backend/.env.development index bebe26f78..42fd65b02 100644 --- a/apps/backend/.env.development +++ b/apps/backend/.env.development @@ -62,3 +62,9 @@ STACK_AWS_REGION=us-east-1 STACK_AWS_KMS_ENDPOINT=http://localhost:8124 STACK_AWS_ACCESS_KEY_ID=test STACK_AWS_SECRET_ACCESS_KEY=test + +# Upstash defaults to one of the pre-build test users of the local emulator +STACK_QSTASH_URL=http://localhost:8125 +STACK_QSTASH_TOKEN=eyJVc2VySUQiOiJkZWZhdWx0VXNlciIsIlBhc3N3b3JkIjoiZGVmYXVsdFBhc3N3b3JkIn0= +STACK_QSTASH_CURRENT_SIGNING_KEY=sig_7kYjw48mhY7kAjqNGcy6cr29RJ6r +STACK_QSTASH_NEXT_SIGNING_KEY=sig_5ZB6DVzB1wjE8S6rZ7eenA8Pdnhs diff --git a/apps/backend/package.json b/apps/backend/package.json index 882c49dd3..4da345b09 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -64,6 +64,7 @@ "@sentry/nextjs": "^8.40.0", "@simplewebauthn/server": "^11.0.0", "@stackframe/stack-shared": "workspace:*", + "@upstash/qstash": "^2.8.2", "@vercel/functions": "^2.0.0", "@vercel/otel": "^1.10.4", "ai": "^4.3.17", diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 374da8a1c..67b5bdc86 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -810,6 +810,7 @@ model WorkflowTrigger { // 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 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 new file mode 100644 index 000000000..281f0a429 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/trigger/run-scheduled/route.tsx @@ -0,0 +1,41 @@ +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/lib/upstash.tsx b/apps/backend/src/lib/upstash.tsx new file mode 100644 index 000000000..9744ce7fc --- /dev/null +++ b/apps/backend/src/lib/upstash.tsx @@ -0,0 +1,35 @@ +import { SmartRequest } from "@/route-handlers/smart-request"; +import { getEnvVariable, getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env"; +import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; +import { Client, Receiver } from "@upstash/qstash"; + +export const upstash = new Client({ + baseUrl: getEnvVariable("STACK_QSTASH_URL", ""), + token: getEnvVariable("STACK_QSTASH_TOKEN", ""), +}); + +export const upstashReceiver = new Receiver({ + currentSigningKey: getEnvVariable("STACK_QSTASH_CURRENT_SIGNING_KEY", ""), + nextSigningKey: getEnvVariable("STACK_QSTASH_NEXT_SIGNING_KEY", ""), +}); + +export async function ensureUpstashSignature(fullReq: SmartRequest): Promise { + const upstashSignature = fullReq.headers["upstash-signature"]?.[0]; + if (!upstashSignature) { + throw new StatusError(400, "upstash-signature header is required"); + } + + const url = new URL(fullReq.url); + if ((getNodeEnvironment().includes("development") || getNodeEnvironment().includes("test")) && url.hostname === "localhost") { + url.hostname = "host.docker.internal"; + } + + const isValid = await upstashReceiver.verify({ + signature: upstashSignature, + url: url.toString(), + body: new TextDecoder().decode(fullReq.bodyBuffer), + }); + if (!isValid) { + throw new StatusError(400, "Invalid Upstash signature"); + } +} diff --git a/apps/backend/src/lib/workflows.tsx b/apps/backend/src/lib/workflows.tsx index de084ef89..33848f1a6 100644 --- a/apps/backend/src/lib/workflows.tsx +++ b/apps/backend/src/lib/workflows.tsx @@ -7,16 +7,15 @@ 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 } from "@stackframe/stack-shared/dist/utils/esbuild"; +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 = { - '@stackframe/stack': 'latest', -}; +const externalPackages: Record = {}; type WorkflowRegisteredTriggerType = "sign-up"; @@ -55,7 +54,9 @@ export async function compileWorkflowSource(source: string): Promise { globalThis.stackApp = new StackServerApp({ @@ -82,7 +83,7 @@ export async function compileWorkflowSource(source: string): Promise { @@ -124,6 +125,7 @@ export async function compileWorkflowSource(source: string): Promise { + 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 })); @@ -160,13 +168,16 @@ async function compileWorkflow(tenancy: Tenancy, workflowId: string): Promise { }); 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) @@ -333,7 +347,6 @@ async function compileAndGetEnabledWorkflows(tenancy: Tenancy): Promise 0) { - captureError("workflows-compile-timeout", new StackAssertionError(`Deleted ${count} currently compiling workflows that were compiling for more than 20 seconds; this probably indicates a bug in the workflow compilation code`)); + 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); @@ -365,46 +378,56 @@ async function compileAndGetEnabledWorkflows(tenancy: Tenancy): Promise> { - 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 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: getEnvVariable("NEXT_PUBLIC_STACK_API_URL").replace("http://localhost", "http://host.docker.internal"), // the replace is a hardcoded hack for the Freestyle mock server - NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY: "", - STACK_SECRET_SERVER_KEY: "", - STACK_WORKFLOW_TOKEN_SECRET: workflowToken, + 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), }, - nodeModules: Object.fromEntries(Object.entries(externalPackages).map(([packageName, version]) => [packageName, version])), }); - return Result.map(freestyleRes, (data) => data.result); - } finally { - clearInterval(tokenRefreshInterval); - } + + 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) { @@ -431,74 +454,119 @@ async function createScheduledTrigger(tenancy: Tenancy, workflowId: string, trig }, }, }); + + 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; } -async function triggerWorkflow(tenancy: Tenancy, compiledWorkflow: CompiledWorkflow, triggerId: string): Promise> { - if (compiledWorkflow.compiledCode === null) { - return Result.error(`Workflow ${compiledWorkflow.id} failed to compile: ${compiledWorkflow.compileError}`); - } - +export async function triggerScheduledWorkflows(tenancy: Tenancy) { const prisma = await getPrismaClientForTenancy(tenancy); - const trigger = await prisma.workflowTrigger.update({ - where: { - tenancyId_id: { - tenancyId: tenancy.id, - id: triggerId, - }, - }, - data: { - compiledWorkflowId: compiledWorkflow.id, - scheduledAt: null, - output: Prisma.DbNull, - error: Prisma.DbNull, - }, - }); + const compiledWorkflows = await compileAndGetEnabledWorkflows(tenancy); - const res = await triggerWorkflowRaw(tenancy, compiledWorkflow.compiledCode, trigger.triggerData as WorkflowTrigger); - if (res.status === "error") { - 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: triggerId, - executionId: trigger.executionId, + 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, + }, }, - scheduleAt - ); + 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 + } } - } - await prisma.workflowTrigger.update({ - where: { - tenancyId_id: { - tenancyId: tenancy.id, - id: triggerId, - }, - }, - data: { - ...res.status === "ok" ? { - output: res.data as any, - } : { - error: res.error, - }, - }, - }); - return Result.ok(undefined); -} + return toTrigger; + }, { level: "serializable" }); -export async function triggerScheduledCallbacks(tenancy: Tenancy) { + 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 }) { @@ -507,9 +575,8 @@ export async function triggerWorkflows(tenancy: Tenancy, trigger: WorkflowTrigge const promises = [...compiledWorkflows] .filter(([_, compiledWorkflow]) => compiledWorkflow.registeredTriggers.includes(trigger.type)) .map(async ([workflowId, compiledWorkflow]) => { - const dbTrigger = await createScheduledTrigger(tenancy, workflowId, trigger, new Date()); - await triggerWorkflow(tenancy, compiledWorkflow, dbTrigger.id); + await createScheduledTrigger(tenancy, workflowId, trigger, new Date()); }); - await Promise.all(promises); + await allPromisesAndWaitUntilEach(promises); }); } diff --git a/apps/backend/src/prisma-client.tsx b/apps/backend/src/prisma-client.tsx index 7b138ae38..bfe1f6fba 100644 --- a/apps/backend/src/prisma-client.tsx +++ b/apps/backend/src/prisma-client.tsx @@ -125,6 +125,16 @@ export async function retryTransaction(client: PrismaClient, fn: (tx: PrismaC // serializable transactions are currently off by default, later we may turn them on const enableSerializable = options.level === "serializable"; + const isRetryablePrismaError = (e: unknown) => { + if (e instanceof Prisma.PrismaClientKnownRequestError) { + return [ + "P2028", // Serializable/repeatable read conflict + "P2034", // Transaction already closed (eg. timeout) + ]; + } + return false; + }; + return await traceSpan('Prisma transaction', async (span) => { const res = await Result.retry(async (attemptIndex) => { return await traceSpan(`transaction attempt #${attemptIndex}`, async (attemptSpan) => { @@ -140,7 +150,7 @@ export async function retryTransaction(client: PrismaClient, fn: (tx: PrismaC // to other (nested) transactions failing // however, we make an exception for "Transaction already closed", as those are (annoyingly) thrown on // the actual query, not the $transaction function itself - if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2028") { // Transaction already closed + if (isRetryablePrismaError(e)) { throw new TransactionErrorThatShouldBeRetried(e); } throw new TransactionErrorThatShouldNotBeRetried(e); @@ -165,11 +175,7 @@ export async function retryTransaction(client: PrismaClient, fn: (tx: PrismaC if (e instanceof TransactionErrorThatShouldNotBeRetried) { throw e.cause; } - if ([ - "Transaction failed due to a write conflict or a deadlock. Please retry your transaction", - "Transaction already closed: A commit cannot be executed on an expired transaction. The timeout for this transaction", - ].some(s => e instanceof Prisma.PrismaClientKnownRequestError && e.message.includes(s))) { - // transaction timeout, retry + if (isRetryablePrismaError(e)) { return Result.error(e); } throw e; diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client.tsx index ca8309c6a..175c4d0ba 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client.tsx @@ -5,7 +5,7 @@ import { yupResolver } from "@hookform/resolvers/yup"; import { AuthPage, TeamSwitcher, useUser } from "@stackframe/stack"; import { allProviders } from "@stackframe/stack-shared/dist/utils/oauth"; import { runAsynchronouslyWithAlert, wait } from "@stackframe/stack-shared/dist/utils/promises"; -import { BrowserFrame, Button, Form, FormControl, FormField, FormItem, FormMessage, Label, Separator, Typography } from "@stackframe/stack-ui"; +import { BrowserFrame, Button, Form, FormControl, FormField, FormItem, FormMessage, Separator, Typography } from "@stackframe/stack-ui"; import { useSearchParams } from "next/navigation"; import { useState } from "react"; import { useForm } from "react-hook-form"; @@ -47,7 +47,7 @@ export default function PageClient() { credentialEnabled: form.watch("signInMethods").includes("credential"), magicLinkEnabled: form.watch("signInMethods").includes("magicLink"), passkeyEnabled: form.watch("signInMethods").includes("passkey"), - oauthProviders: form.watch('signInMethods').filter((method) => ["google", "github", "microsoft", "spotify"].includes(method)).map(provider => ({ id: provider, type: 'shared' })), + oauthProviders: form.watch('signInMethods').filter((method) => ["google", "github", "microsoft"].includes(method)).map(provider => ({ id: provider, type: 'shared' })), } }; @@ -133,7 +133,6 @@ export default function PageClient() { { value: "google", label: "Google" }, { value: "github", label: "GitHub" }, { value: "microsoft", label: "Microsoft" }, - { value: "spotify", label: "Spotify" }, ]} info="More sign-in methods are available on the dashboard later." /> diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/data-vault/page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/data-vault/page.tsx index 6d4c3aaaa..7335b2318 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/data-vault/page.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/data-vault/page.tsx @@ -1,5 +1,5 @@ import { redirect } from "next/navigation"; export default function Page() { - redirect("stores"); + redirect("./data-vault/stores"); } 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 index 885058b24..9ae82b2e8 100644 --- 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 @@ -22,6 +22,7 @@ export default function WorkflowDetailPage() { 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) { @@ -47,6 +48,21 @@ export default function WorkflowDetailPage() { 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 ( @@ -70,6 +86,9 @@ export default function WorkflowDetailPage() { Back +