From 8bef3d6a14ef016d2bd927370c51d14e1c10ade2 Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Wed, 8 Jul 2026 18:18:25 +1000 Subject: [PATCH] Fix manifest completion race in build workflow --- compose.dev.yml | 5 +- web/.env.example | 3 + .../api/v1/agent/builds/[id]/status/route.ts | 1 + web/lib/inngest/functions/build-workflow.ts | 80 ++++++++++++++++--- web/next.config.ts | 5 ++ 5 files changed, 80 insertions(+), 14 deletions(-) diff --git a/compose.dev.yml b/compose.dev.yml index 4419b83a..070d6302 100644 --- a/compose.dev.yml +++ b/compose.dev.yml @@ -19,8 +19,11 @@ services: build: context: ./registry dockerfile: Dockerfile + environment: + REGISTRY_USERNAME: docker + REGISTRY_PASSWORD: docker ports: - - "5000:5000" + - "5002:5000" volumes: - registry-data:/var/lib/registry healthcheck: diff --git a/web/.env.example b/web/.env.example index 212cc136..24da6e3e 100644 --- a/web/.env.example +++ b/web/.env.example @@ -11,6 +11,9 @@ ENCRYPTION_KEY=your-64-character-hex-string # Public URL APP_URL=http://localhost:3000 +# Local dev origins allowed by Next.js, comma-separated (optional) +NEXT_ALLOWED_DEV_ORIGINS=192.168.1.10,dev.local + # Runtime release tag used for control-plane update checks (tip/dev disables prompts) TECHULUS_CLOUD_VERSION=dev diff --git a/web/app/api/v1/agent/builds/[id]/status/route.ts b/web/app/api/v1/agent/builds/[id]/status/route.ts index da67cf2f..0e53d83e 100644 --- a/web/app/api/v1/agent/builds/[id]/status/route.ts +++ b/web/app/api/v1/agent/builds/[id]/status/route.ts @@ -241,6 +241,7 @@ export async function POST( images: [archImageUri], finalImageUri: baseImageUri, serviceId: shouldDeploy ? build.serviceId : undefined, + buildId, }); await db diff --git a/web/lib/inngest/functions/build-workflow.ts b/web/lib/inngest/functions/build-workflow.ts index d9c1ec43..b66ebb03 100644 --- a/web/lib/inngest/functions/build-workflow.ts +++ b/web/lib/inngest/functions/build-workflow.ts @@ -1,10 +1,46 @@ import { and, eq, isNull } from "drizzle-orm"; import { db } from "@/db"; -import { builds, serviceReplicas, services } from "@/db/schema"; +import { builds, serviceReplicas, services, workQueue } from "@/db/schema"; import { deployServiceInternal } from "@/lib/deploy-service"; import { inngest } from "../client"; import { inngestEvents } from "../events"; +async function hasCompletedManifestWorkItem({ + serviceId, + buildId, + buildGroupId, +}: { + serviceId: string; + buildId?: string; + buildGroupId?: string | null; +}) { + const completedManifestItems = await db + .select({ payload: workQueue.payload }) + .from(workQueue) + .where( + and( + eq(workQueue.type, "create_manifest"), + eq(workQueue.status, "completed"), + ), + ); + + return completedManifestItems.some((item) => { + try { + const payload = JSON.parse(item.payload) as { + serviceId?: string; + buildId?: string; + buildGroupId?: string; + }; + + if (payload.serviceId !== serviceId) return false; + if (buildGroupId) return payload.buildGroupId === buildGroupId; + return !payload.buildGroupId && payload.buildId === buildId; + } catch { + return false; + } + }); +} + export const buildWorkflow = inngest.createFunction( { id: "build-workflow", @@ -42,14 +78,23 @@ export const buildWorkflow = inngest.createFunction( return { status: "failed", reason: result.data.error, buildId }; } - const manifestResult = await step.waitForEvent("wait-manifest", { - event: inngestEvents.manifestCompleted, - timeout: "10m", - if: `async.data.serviceId == "${serviceId}"`, + const manifestAlreadyCompleted = await step.run("check-existing-manifest", async () => { + return hasCompletedManifestWorkItem({ + serviceId, + buildId, + }); }); - if (!manifestResult) { - return { status: "completed_no_manifest", buildId }; + if (!manifestAlreadyCompleted) { + const manifestResult = await step.waitForEvent("wait-manifest", { + event: inngestEvents.manifestCompleted, + timeout: "10m", + if: `async.data.serviceId == "${serviceId}"`, + }); + + if (!manifestResult) { + return { status: "completed_no_manifest", buildId }; + } } const shouldDeploy = await step.run("check-auto-deploy", async () => { @@ -118,14 +163,23 @@ export const buildWorkflow = inngest.createFunction( return { status: "failed", reason: "build_failed", buildGroupId }; } - const manifestResult = await step.waitForEvent("wait-group-manifest", { - event: inngestEvents.manifestCompleted, - timeout: "10m", - if: `async.data.buildGroupId == "${buildGroupId}"`, + const manifestAlreadyCompleted = await step.run("check-existing-group-manifest", async () => { + return hasCompletedManifestWorkItem({ + serviceId, + buildGroupId, + }); }); - if (!manifestResult) { - return { status: "completed_no_manifest", buildGroupId }; + if (!manifestAlreadyCompleted) { + const manifestResult = await step.waitForEvent("wait-group-manifest", { + event: inngestEvents.manifestCompleted, + timeout: "10m", + if: `async.data.buildGroupId == "${buildGroupId}"`, + }); + + if (!manifestResult) { + return { status: "completed_no_manifest", buildGroupId }; + } } const shouldDeploy = await step.run("check-auto-deploy-group", async () => { diff --git a/web/next.config.ts b/web/next.config.ts index 398b0d5c..4629536c 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -1,7 +1,12 @@ import type { NextConfig } from "next"; +const allowedDevOrigins = process.env.NEXT_ALLOWED_DEV_ORIGINS?.split(",") + .map((origin) => origin.trim()) + .filter(Boolean); + const nextConfig: NextConfig = { output: "standalone", + ...(allowedDevOrigins?.length ? { allowedDevOrigins } : {}), }; export default nextConfig;