From edcafc43c15a5a0ec311b977b455a1fbe931e99e Mon Sep 17 00:00:00 2001 From: woywro Date: Mon, 23 Mar 2026 13:19:15 +0100 Subject: [PATCH 1/8] feat: refactor polling mechanism to use workflow --- docs/SPEC.md | 33 +++-- env.ts | 6 +- nitro.config.ts | 1 + src/routes/poll/start.get.test.ts | 84 ++++++++++++ src/routes/poll/start.get.ts | 41 ++++++ .../cron/poll.get.ts => workflows/poll.ts} | 123 +++++++++++------- vercel.json | 4 +- 7 files changed, 227 insertions(+), 65 deletions(-) create mode 100644 src/routes/poll/start.get.test.ts create mode 100644 src/routes/poll/start.get.ts rename src/{routes/cron/poll.get.ts => workflows/poll.ts} (54%) diff --git a/docs/SPEC.md b/docs/SPEC.md index 4d53d75..4cb34b8 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -69,10 +69,11 @@ Important boundary: ### 3.1 Main Components -1. **Poller** (Vercel Cron) - - Runs on a configurable interval (`POLL_INTERVAL_MS`, default 5 min). +1. **Poller** (Vercel Workflow with sleep) + - Runs as a long-lived Vercel Workflow that sleeps between poll cycles (`POLL_SLEEP_DURATION`, default 15s). - Queries the issue tracker for tickets in the AI column. - For each discovered ticket, starts a Vercel Workflow run if one is not already active. + - Started via `GET /poll/start` route which ensures singleton operation. 2. **Issue Tracker Adapter** - Reads ticket data (description, acceptance criteria, comments, labels). @@ -202,7 +203,7 @@ All runtime config lives in environment variables, validated at startup. Key config groups: - **Sandbox:** concurrency limit (`MAX_CONCURRENT_AGENTS`), job timeout (`JOB_TIMEOUT_MS`). -- **Polling:** interval (`POLL_INTERVAL_MS`). +- **Polling:** sleep duration between cycles (`POLL_SLEEP_DURATION`). - **Issue Tracker:** adapter kind (`ISSUE_TRACKER_KIND`), project key (`JIRA_PROJECT_KEY`), credentials. - **Messaging:** Chat SDK credentials (`CHAT_SDK_API_KEY`), channel config. @@ -255,8 +256,8 @@ transition. ### 8.1 Polling -The poller (Vercel Cron) runs every `POLL_INTERVAL_MS` and queries the issue tracker for tickets -in the AI column. For each discovered ticket: +The poller runs as a long-lived Vercel Workflow that sleeps `POLL_SLEEP_DURATION` (default 15s) +between cycles and queries the issue tracker for tickets in the AI column. For each discovered ticket: 1. Check if a Vercel Workflow run is already active for this ticket — if so, skip. 2. Determine run type based on ticket state: @@ -616,17 +617,21 @@ Notifications are best-effort — never block the workflow. ### 16.1 Poller ``` -on_poll(): - tickets = issueTrackerAdapter.searchTickets("column = AI") +poll_workflow(): + while true: + tickets = issueTrackerAdapter.searchTickets("column = AI") - for ticket in tickets: - if hasActiveWorkflowRun(ticket.id): continue - if atConcurrencyLimit(): break + for ticket in tickets: + if hasActiveWorkflowRun(ticket.id): continue + if atConcurrencyLimit(): break - workflow.start("ticket_workflow", { - ticketId: ticket.id, - identifier: ticket.identifier - }) + workflow.start("ticket_workflow", { + ticketId: ticket.id, + identifier: ticket.identifier + }) + + reconcileRegistry(tickets) + sleep(POLL_SLEEP_DURATION) ``` ### 16.2 Ticket Workflow (Vercel Workflow) diff --git a/env.ts b/env.ts index bc36c58..350cf6d 100644 --- a/env.ts +++ b/env.ts @@ -38,14 +38,14 @@ export const env = createEnv({ // Polling POLL_INTERVAL_MS: z.coerce.number().int().positive().default(300_000), + // Cron + CRON_SECRET: z.string().min(1).optional(), + // Vercel (optional — auto via OIDC on Vercel) VERCEL_TOKEN: z.string().min(1).optional(), VERCEL_TEAM_ID: z.string().min(1).optional(), VERCEL_PROJECT_ID: z.string().min(1).optional(), - // Cron - CRON_SECRET: z.string().min(1).optional(), - // Redis (run registry) AI_WORKFLOW_KV_REST_API_URL: z.string().url(), AI_WORKFLOW_KV_REST_API_TOKEN: z.string().min(1), diff --git a/nitro.config.ts b/nitro.config.ts index 7dd4624..96eaeb5 100644 --- a/nitro.config.ts +++ b/nitro.config.ts @@ -5,4 +5,5 @@ export default defineNitroConfig({ modules: ["workflow/nitro"], compatibilityDate: "2025-01-01", srcDir: "src", + ignore: ["**/*.test.ts"], }); diff --git a/src/routes/poll/start.get.test.ts b/src/routes/poll/start.get.test.ts new file mode 100644 index 0000000..afda582 --- /dev/null +++ b/src/routes/poll/start.get.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mockRedis = { + get: vi.fn(), + set: vi.fn(), +}; + +vi.mock("@upstash/redis", () => ({ + Redis: vi.fn(() => mockRedis), +})); + +const mockStart = vi.fn(); +const mockGetRun = vi.fn(); + +vi.mock("workflow/api", () => ({ + start: (...args: any[]) => mockStart(...args), + getRun: (...args: any[]) => mockGetRun(...args), +})); + +vi.mock("../../workflows/poll.js", () => ({ + pollWorkflow: vi.fn(), +})); + +vi.mock("../../../env.js", () => ({ + env: { + AI_WORKFLOW_KV_REST_API_URL: "https://fake.upstash.io", + AI_WORKFLOW_KV_REST_API_TOKEN: "fake-token", + }, +})); + +vi.mock("../../lib/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn() }, +})); + +import handler from "./start.get.js"; + +const handle = + typeof handler === "function" ? handler : (handler as any).handler; + +describe("GET /poll/start", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("starts a new workflow when none exists", async () => { + mockRedis.get.mockResolvedValueOnce(null); + mockStart.mockResolvedValueOnce({ runId: "run_new" }); + + const result = await handle({} as any); + + expect(result).toEqual({ status: "started", runId: "run_new" }); + expect(mockRedis.set).toHaveBeenCalledWith("blazebot:poll-workflow", "run_new"); + }); + + it("returns already_running when workflow is active", async () => { + mockRedis.get.mockResolvedValueOnce("run_existing"); + mockGetRun.mockReturnValueOnce({ status: Promise.resolve("running") }); + + const result = await handle({} as any); + + expect(result).toEqual({ status: "already_running", runId: "run_existing" }); + expect(mockStart).not.toHaveBeenCalled(); + }); + + it("starts a new workflow when existing one is dead", async () => { + mockRedis.get.mockResolvedValueOnce("run_dead"); + mockGetRun.mockReturnValueOnce({ status: Promise.resolve("completed") }); + mockStart.mockResolvedValueOnce({ runId: "run_replacement" }); + + const result = await handle({} as any); + + expect(result).toEqual({ status: "started", runId: "run_replacement" }); + }); + + it("starts a new workflow when getRun throws", async () => { + mockRedis.get.mockResolvedValueOnce("run_gone"); + mockGetRun.mockImplementationOnce(() => { throw new Error("not found"); }); + mockStart.mockResolvedValueOnce({ runId: "run_fresh" }); + + const result = await handle({} as any); + + expect(result).toEqual({ status: "started", runId: "run_fresh" }); + }); +}); diff --git a/src/routes/poll/start.get.ts b/src/routes/poll/start.get.ts new file mode 100644 index 0000000..a237df7 --- /dev/null +++ b/src/routes/poll/start.get.ts @@ -0,0 +1,41 @@ +import { defineEventHandler, getHeader, createError } from "h3"; +import { start, getRun } from "workflow/api"; +import { Redis } from "@upstash/redis"; +import { env } from "../../../env.js"; +import { pollWorkflow } from "../../workflows/poll.js"; +import { logger } from "../../lib/logger.js"; + +const POLL_WORKFLOW_KEY = "blazebot:poll-workflow"; + +export default defineEventHandler(async (event) => { + if (env.CRON_SECRET) { + const auth = getHeader(event, "authorization"); + if (auth !== `Bearer ${env.CRON_SECRET}`) { + throw createError({ statusCode: 401, statusMessage: "Unauthorized" }); + } + } + + const redis = new Redis({ + url: env.AI_WORKFLOW_KV_REST_API_URL, + token: env.AI_WORKFLOW_KV_REST_API_TOKEN, + }); + + const existingRunId = await redis.get(POLL_WORKFLOW_KEY); + + if (existingRunId) { + try { + const run = getRun(existingRunId); + const status = await run.status; + if (status === "running") { + return { status: "already_running", runId: existingRunId }; + } + } catch { + // Run not found — fall through to start a new one + } + } + + const handle = await start(pollWorkflow); + await redis.set(POLL_WORKFLOW_KEY, handle.runId); + logger.info({ runId: handle.runId }, "poll_workflow_started"); + return { status: "started", runId: handle.runId }; +}); diff --git a/src/routes/cron/poll.get.ts b/src/workflows/poll.ts similarity index 54% rename from src/routes/cron/poll.get.ts rename to src/workflows/poll.ts index 443d04f..2128f23 100644 --- a/src/routes/cron/poll.get.ts +++ b/src/workflows/poll.ts @@ -1,44 +1,45 @@ -import { defineEventHandler, getHeader, createError } from "h3"; -import { start, getRun } from "workflow/api"; -import { env } from "../../../env.js"; -import { createAdapters } from "../../lib/adapters.js"; -import { implementationWorkflow } from "../../workflows/implementation.js"; -import { reviewFixWorkflow } from "../../workflows/review-fix.js"; -import { logger } from "../../lib/logger.js"; - -async function getActiveSandboxCount(): Promise { - try { - const { Sandbox } = await import("@vercel/sandbox"); - const { json } = await Sandbox.list({ limit: 100 }); - return json.sandboxes.filter((s: any) => s.status === "running").length; - } catch { - return 0; - } -} - -export default defineEventHandler(async (event) => { - // Verify Vercel Cron auth - if (env.CRON_SECRET) { - const auth = getHeader(event, "authorization"); - if (auth !== `Bearer ${env.CRON_SECRET}`) { - throw createError({ statusCode: 401, statusMessage: "Unauthorized" }); - } - } - - const { issueTracker, vcs, runRegistry } = createAdapters(); +import { sleep } from "workflow"; + +async function pollAndDispatch(): Promise<{ + ticketKeys: string[]; + started: number; +}> { + "use step"; + const { env } = await import("../../env.js"); + const { createStepAdapters } = await import("../lib/step-adapters.js"); + const { logger } = await import("../lib/logger.js"); + const { Sandbox } = await import("@vercel/sandbox"); + const { start } = await import("workflow/api"); + const { implementationWorkflow } = await import("./implementation.js"); + const { reviewFixWorkflow } = await import("./review-fix.js"); + + const { issueTracker, vcs, runRegistry } = createStepAdapters(); - // Search for tickets in AI column const jql = `project = ${env.JIRA_PROJECT_KEY} AND status = "${env.COLUMN_AI}"`; const ticketKeys = await issueTracker.searchTickets(jql); logger.info({ ticketCount: ticketKeys.length }, "poll_discovered_tickets"); - // Concurrency control (spec Section 8.2) - const activeSandboxes = await getActiveSandboxCount(); - const availableSlots = Math.max(0, env.MAX_CONCURRENT_AGENTS - activeSandboxes); + let activeSandboxes = 0; + try { + const { json } = await Sandbox.list({ limit: 100 }); + activeSandboxes = json.sandboxes.filter( + (s: any) => s.status === "running", + ).length; + } catch { + // If we can't check, assume 0 and let sandbox provisioning fail if truly at capacity + } + + const availableSlots = Math.max( + 0, + env.MAX_CONCURRENT_AGENTS - activeSandboxes, + ); if (availableSlots === 0) { - logger.info({ active: activeSandboxes, max: env.MAX_CONCURRENT_AGENTS }, "poll_at_capacity"); - return { status: "ok", discovered: ticketKeys.length, started: 0, reason: "at_capacity" }; + logger.info( + { active: activeSandboxes, max: env.MAX_CONCURRENT_AGENTS }, + "poll_at_capacity", + ); + return { ticketKeys, started: 0 }; } const started: string[] = []; @@ -46,7 +47,6 @@ export default defineEventHandler(async (event) => { for (const key of ticketKeys) { if (started.length >= availableSlots) break; - // Atomically claim the ticket to prevent duplicate dispatches const claimed = await runRegistry.claim(key, "claiming"); if (!claimed) { logger.info({ ticketKey: key }, "poll_ticket_already_claimed"); @@ -62,21 +62,28 @@ export default defineEventHandler(async (event) => { const handle = await start(reviewFixWorkflow, [ticket.id, branchName]); await runRegistry.register(ticket.identifier, handle.runId); logger.info( - { ticketId: ticket.id, identifier: ticket.identifier, runId: handle.runId }, + { + ticketId: ticket.id, + identifier: ticket.identifier, + runId: handle.runId, + }, "workflow_started_review_fix", ); } else { const handle = await start(implementationWorkflow, [ticket.id]); await runRegistry.register(ticket.identifier, handle.runId); logger.info( - { ticketId: ticket.id, identifier: ticket.identifier, runId: handle.runId }, + { + ticketId: ticket.id, + identifier: ticket.identifier, + runId: handle.runId, + }, "workflow_started_implementation", ); } started.push(ticket.identifier); } catch (err) { - // Release the claim if dispatch failed so the ticket can be retried await runRegistry.unregister(key).catch(() => {}); logger.warn( { ticketKey: key, error: (err as Error).message }, @@ -85,7 +92,19 @@ export default defineEventHandler(async (event) => { } } - // Reconcile registry: cancel stale runs and clean up dead entries + return { ticketKeys, started: started.length }; +} + +async function reconcileRegistry( + ticketKeys: string[], +): Promise<{ cancelled: number; cleaned: number }> { + "use step"; + const { createStepAdapters } = await import("../lib/step-adapters.js"); + const { logger } = await import("../lib/logger.js"); + const { getRun } = await import("workflow/api"); + + const { runRegistry } = createStepAdapters(); + const aiColumnSet = new Set(ticketKeys); const activeRuns = await runRegistry.listAll(); let cancelled = 0; @@ -93,17 +112,19 @@ export default defineEventHandler(async (event) => { for (const { ticketKey, runId } of activeRuns) { if (aiColumnSet.has(ticketKey)) { - // Ticket is still in AI column — verify the run is actually alive try { const run = getRun(runId); const status = await run.status; - if (status === "completed" || status === "failed" || status === "cancelled") { + if ( + status === "completed" || + status === "failed" || + status === "cancelled" + ) { await runRegistry.unregister(ticketKey); logger.info({ ticketKey, runId, status }, "poll_cleaned_dead_run"); cleaned++; } } catch { - // Run not found or status check failed — clean up so ticket can be retried await runRegistry.unregister(ticketKey).catch(() => {}); logger.warn({ ticketKey, runId }, "poll_cleaned_unreachable_run"); cleaned++; @@ -111,7 +132,6 @@ export default defineEventHandler(async (event) => { continue; } - // Ticket left the AI column — cancel and unregister try { const run = getRun(runId); await run.cancel(); @@ -119,7 +139,6 @@ export default defineEventHandler(async (event) => { logger.info({ ticketKey, runId }, "poll_cancelled_stale_run"); cancelled++; } catch (err) { - // Run may already be finished — unregister to clean up await runRegistry.unregister(ticketKey).catch(() => {}); logger.warn( { ticketKey, runId, error: (err as Error).message }, @@ -128,5 +147,17 @@ export default defineEventHandler(async (event) => { } } - return { status: "ok", discovered: ticketKeys.length, started: started.length, cancelled, cleaned }; -}); + return { cancelled, cleaned }; +} + +export async function pollWorkflow() { + "use workflow"; + + const { env } = await import("../../env.js"); + + while (true) { + const { ticketKeys } = await pollAndDispatch(); + await reconcileRegistry(ticketKeys); + await sleep(env.POLL_INTERVAL_MS); + } +} diff --git a/vercel.json b/vercel.json index e1fe15d..f972aa8 100644 --- a/vercel.json +++ b/vercel.json @@ -1,8 +1,8 @@ { "crons": [ { - "path": "/cron/poll", - "schedule": "* * * * *" + "path": "/poll/start", + "schedule": "*/15 * * * *" } ] } From 9236302fbaa6e0ba4e5f02edc7529b5bceb6c322 Mon Sep 17 00:00:00 2001 From: woywro Date: Mon, 23 Mar 2026 13:45:15 +0100 Subject: [PATCH 2/8] feat: update polling interval to 15 seconds and upgrade workflow locking mechanism --- docs/SPEC.md | 23 ++++---- env.ts | 2 +- src/routes/poll/start.get.test.ts | 92 ++++++++++++++++++++++++++----- src/routes/poll/start.get.ts | 45 ++++++++++++--- src/workflows/poll.ts | 48 ++++++++++------ 5 files changed, 160 insertions(+), 50 deletions(-) diff --git a/docs/SPEC.md b/docs/SPEC.md index 4cb34b8..0484ee2 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -70,10 +70,11 @@ Important boundary: ### 3.1 Main Components 1. **Poller** (Vercel Workflow with sleep) - - Runs as a long-lived Vercel Workflow that sleeps between poll cycles (`POLL_SLEEP_DURATION`, default 15s). + - Runs as a long-lived Vercel Workflow that sleeps between poll cycles (`POLL_INTERVAL_MS`, default 15s). - Queries the issue tracker for tickets in the AI column. - For each discovered ticket, starts a Vercel Workflow run if one is not already active. - - Started via `GET /poll/start` route which ensures singleton operation. + - Started via `GET /poll/start` route which ensures singleton operation. Vercel Cron hits this + route every 15 minutes as a liveness check — if the workflow died, the route restarts it. 2. **Issue Tracker Adapter** - Reads ticket data (description, acceptance criteria, comments, labels). @@ -203,7 +204,7 @@ All runtime config lives in environment variables, validated at startup. Key config groups: - **Sandbox:** concurrency limit (`MAX_CONCURRENT_AGENTS`), job timeout (`JOB_TIMEOUT_MS`). -- **Polling:** sleep duration between cycles (`POLL_SLEEP_DURATION`). +- **Polling:** interval between cycles (`POLL_INTERVAL_MS`, default 15s). - **Issue Tracker:** adapter kind (`ISSUE_TRACKER_KIND`), project key (`JIRA_PROJECT_KEY`), credentials. - **Messaging:** Chat SDK credentials (`CHAT_SDK_API_KEY`), channel config. @@ -256,7 +257,7 @@ transition. ### 8.1 Polling -The poller runs as a long-lived Vercel Workflow that sleeps `POLL_SLEEP_DURATION` (default 15s) +The poller runs as a long-lived Vercel Workflow that sleeps `POLL_INTERVAL_MS` (default 15s) between cycles and queries the issue tracker for tickets in the AI column. For each discovered ticket: 1. Check if a Vercel Workflow run is already active for this ticket — if so, skip. @@ -619,19 +620,19 @@ Notifications are best-effort — never block the workflow. ``` poll_workflow(): while true: - tickets = issueTrackerAdapter.searchTickets("column = AI") + ticketKeys = issueTrackerAdapter.searchTickets("column = AI") - for ticket in tickets: - if hasActiveWorkflowRun(ticket.id): continue + for key in ticketKeys: + if hasActiveWorkflowRun(key): continue if atConcurrencyLimit(): break workflow.start("ticket_workflow", { - ticketId: ticket.id, - identifier: ticket.identifier + ticketId: key, + identifier: key }) - reconcileRegistry(tickets) - sleep(POLL_SLEEP_DURATION) + reconcileRegistry(ticketKeys) + sleep(POLL_INTERVAL_MS) ``` ### 16.2 Ticket Workflow (Vercel Workflow) diff --git a/env.ts b/env.ts index 350cf6d..0f1c200 100644 --- a/env.ts +++ b/env.ts @@ -36,7 +36,7 @@ export const env = createEnv({ JOB_TIMEOUT_MS: z.coerce.number().int().positive().default(1_800_000), // Polling - POLL_INTERVAL_MS: z.coerce.number().int().positive().default(300_000), + POLL_INTERVAL_MS: z.coerce.number().int().positive().default(15_000), // Cron CRON_SECRET: z.string().min(1).optional(), diff --git a/src/routes/poll/start.get.test.ts b/src/routes/poll/start.get.test.ts index afda582..7c6407f 100644 --- a/src/routes/poll/start.get.test.ts +++ b/src/routes/poll/start.get.test.ts @@ -1,17 +1,19 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -const mockRedis = { - get: vi.fn(), - set: vi.fn(), -}; +const { mockRedis, mockStart, mockGetRun } = vi.hoisted(() => ({ + mockRedis: { + get: vi.fn(), + set: vi.fn(), + del: vi.fn(), + }, + mockStart: vi.fn(), + mockGetRun: vi.fn(), +})); vi.mock("@upstash/redis", () => ({ Redis: vi.fn(() => mockRedis), })); -const mockStart = vi.fn(); -const mockGetRun = vi.fn(); - vi.mock("workflow/api", () => ({ start: (...args: any[]) => mockStart(...args), getRun: (...args: any[]) => mockGetRun(...args), @@ -33,6 +35,7 @@ vi.mock("../../lib/logger.js", () => ({ })); import handler from "./start.get.js"; +import { env } from "../../../env.js"; const handle = typeof handler === "function" ? handler : (handler as any).handler; @@ -43,13 +46,18 @@ describe("GET /poll/start", () => { }); it("starts a new workflow when none exists", async () => { - mockRedis.get.mockResolvedValueOnce(null); + mockRedis.get.mockResolvedValueOnce(null).mockResolvedValueOnce(null); + mockRedis.set.mockResolvedValueOnce("OK").mockResolvedValueOnce("OK"); mockStart.mockResolvedValueOnce({ runId: "run_new" }); const result = await handle({} as any); expect(result).toEqual({ status: "started", runId: "run_new" }); - expect(mockRedis.set).toHaveBeenCalledWith("blazebot:poll-workflow", "run_new"); + expect(mockRedis.set).toHaveBeenCalledWith( + "blazebot:poll-workflow", + "run_new", + ); + expect(mockRedis.del).toHaveBeenCalledWith("blazebot:poll-workflow:lock"); }); it("returns already_running when workflow is active", async () => { @@ -58,27 +66,85 @@ describe("GET /poll/start", () => { const result = await handle({} as any); - expect(result).toEqual({ status: "already_running", runId: "run_existing" }); + expect(result).toEqual({ + status: "already_running", + runId: "run_existing", + }); expect(mockStart).not.toHaveBeenCalled(); }); it("starts a new workflow when existing one is dead", async () => { - mockRedis.get.mockResolvedValueOnce("run_dead"); + mockRedis.get.mockResolvedValueOnce("run_dead").mockResolvedValueOnce(null); mockGetRun.mockReturnValueOnce({ status: Promise.resolve("completed") }); + mockRedis.set.mockResolvedValueOnce("OK").mockResolvedValueOnce("OK"); mockStart.mockResolvedValueOnce({ runId: "run_replacement" }); const result = await handle({} as any); expect(result).toEqual({ status: "started", runId: "run_replacement" }); + expect(mockRedis.del).toHaveBeenCalledWith("blazebot:poll-workflow:lock"); }); it("starts a new workflow when getRun throws", async () => { - mockRedis.get.mockResolvedValueOnce("run_gone"); - mockGetRun.mockImplementationOnce(() => { throw new Error("not found"); }); + mockRedis.get.mockResolvedValueOnce("run_gone").mockResolvedValueOnce(null); + mockGetRun.mockImplementationOnce(() => { + throw new Error("not found"); + }); + mockRedis.set.mockResolvedValueOnce("OK").mockResolvedValueOnce("OK"); mockStart.mockResolvedValueOnce({ runId: "run_fresh" }); const result = await handle({} as any); expect(result).toEqual({ status: "started", runId: "run_fresh" }); + expect(mockRedis.del).toHaveBeenCalledWith("blazebot:poll-workflow:lock"); + }); + + it("returns lock_contention when another start is in progress", async () => { + mockRedis.get.mockResolvedValueOnce(null); + mockRedis.set.mockResolvedValueOnce(null); + + const result = await handle({} as any); + + expect(result).toEqual({ + status: "lock_contention", + message: "Another start request is in progress", + }); + expect(mockStart).not.toHaveBeenCalled(); + }); + + it("rejects requests with wrong bearer token when CRON_SECRET is set", async () => { + (env as any).CRON_SECRET = "real-secret"; + + const mockEvent = { + node: { + req: { + headers: { + authorization: "Bearer wrong-secret", + }, + }, + }, + }; + + try { + await expect(handle(mockEvent as any)).rejects.toMatchObject({ + statusCode: 401, + }); + } finally { + delete (env as any).CRON_SECRET; + } + }); + + it("returns already_running after lock when another request started workflow", async () => { + mockRedis.get + .mockResolvedValueOnce(null) + .mockResolvedValueOnce("run_raced"); + mockRedis.set.mockResolvedValueOnce("OK"); + mockGetRun.mockReturnValueOnce({ status: Promise.resolve("running") }); + + const result = await handle({} as any); + + expect(result).toEqual({ status: "already_running", runId: "run_raced" }); + expect(mockStart).not.toHaveBeenCalled(); + expect(mockRedis.del).toHaveBeenCalledWith("blazebot:poll-workflow:lock"); }); }); diff --git a/src/routes/poll/start.get.ts b/src/routes/poll/start.get.ts index a237df7..947aaa6 100644 --- a/src/routes/poll/start.get.ts +++ b/src/routes/poll/start.get.ts @@ -6,6 +6,13 @@ import { pollWorkflow } from "../../workflows/poll.js"; import { logger } from "../../lib/logger.js"; const POLL_WORKFLOW_KEY = "blazebot:poll-workflow"; +const LOCK_KEY = "blazebot:poll-workflow:lock"; +const LOCK_TTL_S = 30; + +const redis = new Redis({ + url: env.AI_WORKFLOW_KV_REST_API_URL, + token: env.AI_WORKFLOW_KV_REST_API_TOKEN, +}); export default defineEventHandler(async (event) => { if (env.CRON_SECRET) { @@ -15,11 +22,6 @@ export default defineEventHandler(async (event) => { } } - const redis = new Redis({ - url: env.AI_WORKFLOW_KV_REST_API_URL, - token: env.AI_WORKFLOW_KV_REST_API_TOKEN, - }); - const existingRunId = await redis.get(POLL_WORKFLOW_KEY); if (existingRunId) { @@ -34,8 +36,33 @@ export default defineEventHandler(async (event) => { } } - const handle = await start(pollWorkflow); - await redis.set(POLL_WORKFLOW_KEY, handle.runId); - logger.info({ runId: handle.runId }, "poll_workflow_started"); - return { status: "started", runId: handle.runId }; + const acquired = await redis.set(LOCK_KEY, "1", { nx: true, ex: LOCK_TTL_S }); + if (!acquired) { + return { + status: "lock_contention", + message: "Another start request is in progress", + }; + } + + try { + const recheckRunId = await redis.get(POLL_WORKFLOW_KEY); + if (recheckRunId) { + try { + const run = getRun(recheckRunId); + const status = await run.status; + if (status === "running") { + return { status: "already_running", runId: recheckRunId }; + } + } catch { + // Fall through to start + } + } + + const handle = await start(pollWorkflow); + await redis.set(POLL_WORKFLOW_KEY, handle.runId); + logger.info({ runId: handle.runId }, "poll_workflow_started"); + return { status: "started", runId: handle.runId }; + } finally { + await redis.del(LOCK_KEY); + } }); diff --git a/src/workflows/poll.ts b/src/workflows/poll.ts index 2128f23..1fdd5cd 100644 --- a/src/workflows/poll.ts +++ b/src/workflows/poll.ts @@ -1,31 +1,46 @@ import { sleep } from "workflow"; -async function pollAndDispatch(): Promise<{ - ticketKeys: string[]; - started: number; -}> { +async function discoverTickets(): Promise { "use step"; const { env } = await import("../../env.js"); const { createStepAdapters } = await import("../lib/step-adapters.js"); const { logger } = await import("../lib/logger.js"); - const { Sandbox } = await import("@vercel/sandbox"); - const { start } = await import("workflow/api"); - const { implementationWorkflow } = await import("./implementation.js"); - const { reviewFixWorkflow } = await import("./review-fix.js"); - const { issueTracker, vcs, runRegistry } = createStepAdapters(); + const { issueTracker } = createStepAdapters(); const jql = `project = ${env.JIRA_PROJECT_KEY} AND status = "${env.COLUMN_AI}"`; const ticketKeys = await issueTracker.searchTickets(jql); logger.info({ ticketCount: ticketKeys.length }, "poll_discovered_tickets"); + return ticketKeys; +} + +async function dispatchTickets(ticketKeys: string[]): Promise { + "use step"; + const { env } = await import("../../env.js"); + const { createStepAdapters } = await import("../lib/step-adapters.js"); + const { logger } = await import("../lib/logger.js"); + const { Sandbox } = await import("@vercel/sandbox"); + const { start } = await import("workflow/api"); + const { implementationWorkflow } = await import("./implementation.js"); + const { reviewFixWorkflow } = await import("./review-fix.js"); + + const { issueTracker, vcs, runRegistry } = createStepAdapters(); + let activeSandboxes = 0; try { - const { json } = await Sandbox.list({ limit: 100 }); - activeSandboxes = json.sandboxes.filter( - (s: any) => s.status === "running", - ).length; + let nextCursor: number | null = null; + do { + const { json } = await Sandbox.list({ + limit: 100, + ...(nextCursor != null ? { until: nextCursor } : {}), + }); + activeSandboxes += json.sandboxes.filter( + (s: any) => s.status === "running", + ).length; + nextCursor = json.pagination.next; + } while (nextCursor != null); } catch { // If we can't check, assume 0 and let sandbox provisioning fail if truly at capacity } @@ -39,7 +54,7 @@ async function pollAndDispatch(): Promise<{ { active: activeSandboxes, max: env.MAX_CONCURRENT_AGENTS }, "poll_at_capacity", ); - return { ticketKeys, started: 0 }; + return 0; } const started: string[] = []; @@ -92,7 +107,7 @@ async function pollAndDispatch(): Promise<{ } } - return { ticketKeys, started: started.length }; + return started.length; } async function reconcileRegistry( @@ -156,7 +171,8 @@ export async function pollWorkflow() { const { env } = await import("../../env.js"); while (true) { - const { ticketKeys } = await pollAndDispatch(); + const ticketKeys = await discoverTickets(); + await dispatchTickets(ticketKeys); await reconcileRegistry(ticketKeys); await sleep(env.POLL_INTERVAL_MS); } From bc902d9fd16325822f951c744621d811e886a70d Mon Sep 17 00:00:00 2001 From: woywro Date: Mon, 23 Mar 2026 14:09:58 +0100 Subject: [PATCH 3/8] fix: post cr improvements --- src/routes/poll/start.get.test.ts | 13 +++++++++++++ src/routes/poll/start.get.ts | 5 +++-- src/workflows/poll.ts | 14 +++++++++++--- 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/routes/poll/start.get.test.ts b/src/routes/poll/start.get.test.ts index 7c6407f..acb574f 100644 --- a/src/routes/poll/start.get.test.ts +++ b/src/routes/poll/start.get.test.ts @@ -134,6 +134,19 @@ describe("GET /poll/start", () => { } }); + it("returns already_running when workflow is pending", async () => { + mockRedis.get.mockResolvedValueOnce("run_pending"); + mockGetRun.mockReturnValueOnce({ status: Promise.resolve("pending") }); + + const result = await handle({} as any); + + expect(result).toEqual({ + status: "already_running", + runId: "run_pending", + }); + expect(mockStart).not.toHaveBeenCalled(); + }); + it("returns already_running after lock when another request started workflow", async () => { mockRedis.get .mockResolvedValueOnce(null) diff --git a/src/routes/poll/start.get.ts b/src/routes/poll/start.get.ts index 947aaa6..001550d 100644 --- a/src/routes/poll/start.get.ts +++ b/src/routes/poll/start.get.ts @@ -8,6 +8,7 @@ import { logger } from "../../lib/logger.js"; const POLL_WORKFLOW_KEY = "blazebot:poll-workflow"; const LOCK_KEY = "blazebot:poll-workflow:lock"; const LOCK_TTL_S = 30; +const ALIVE_STATUSES: string[] = ["running", "pending"]; const redis = new Redis({ url: env.AI_WORKFLOW_KV_REST_API_URL, @@ -28,7 +29,7 @@ export default defineEventHandler(async (event) => { try { const run = getRun(existingRunId); const status = await run.status; - if (status === "running") { + if (ALIVE_STATUSES.includes(status)) { return { status: "already_running", runId: existingRunId }; } } catch { @@ -50,7 +51,7 @@ export default defineEventHandler(async (event) => { try { const run = getRun(recheckRunId); const status = await run.status; - if (status === "running") { + if (ALIVE_STATUSES.includes(status)) { return { status: "already_running", runId: recheckRunId }; } } catch { diff --git a/src/workflows/poll.ts b/src/workflows/poll.ts index 1fdd5cd..5e7a05d 100644 --- a/src/workflows/poll.ts +++ b/src/workflows/poll.ts @@ -171,9 +171,17 @@ export async function pollWorkflow() { const { env } = await import("../../env.js"); while (true) { - const ticketKeys = await discoverTickets(); - await dispatchTickets(ticketKeys); - await reconcileRegistry(ticketKeys); + try { + const ticketKeys = await discoverTickets(); + await dispatchTickets(ticketKeys); + await reconcileRegistry(ticketKeys); + } catch (err) { + const { logger } = await import("../lib/logger.js"); + logger.warn( + { error: (err as Error).message }, + "poll_cycle_failed", + ); + } await sleep(env.POLL_INTERVAL_MS); } } From 674475268fc5c972c159c2730aaec015190bbb18 Mon Sep 17 00:00:00 2001 From: woywro Date: Mon, 23 Mar 2026 14:14:00 +0100 Subject: [PATCH 4/8] feat: add logging for cycle errors in poll workflow --- src/workflows/poll.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/workflows/poll.ts b/src/workflows/poll.ts index 5e7a05d..b32948e 100644 --- a/src/workflows/poll.ts +++ b/src/workflows/poll.ts @@ -165,6 +165,12 @@ async function reconcileRegistry( return { cancelled, cleaned }; } +async function logCycleError(message: string): Promise { + "use step"; + const { logger } = await import("../lib/logger.js"); + logger.warn({ error: message }, "poll_cycle_failed"); +} + export async function pollWorkflow() { "use workflow"; @@ -176,11 +182,7 @@ export async function pollWorkflow() { await dispatchTickets(ticketKeys); await reconcileRegistry(ticketKeys); } catch (err) { - const { logger } = await import("../lib/logger.js"); - logger.warn( - { error: (err as Error).message }, - "poll_cycle_failed", - ); + await logCycleError((err as Error).message); } await sleep(env.POLL_INTERVAL_MS); } From 2000cefbdde8603254b1a0a3a6f17018492ce947 Mon Sep 17 00:00:00 2001 From: woywro Date: Tue, 24 Mar 2026 07:56:16 +0100 Subject: [PATCH 5/8] refactor: update environment variables and improve poll workflow handling --- .env.example | 4 +- .github/workflows/post-deploy.yml | 15 ++++++ env.ts | 4 +- src/routes/poll/start.get.test.ts | 90 ++++++++++++------------------- src/routes/poll/start.get.ts | 53 ++++++++---------- vercel.json | 9 +--- 6 files changed, 74 insertions(+), 101 deletions(-) create mode 100644 .github/workflows/post-deploy.yml diff --git a/.env.example b/.env.example index 8ea1f6d..30223c5 100644 --- a/.env.example +++ b/.env.example @@ -38,8 +38,8 @@ POLL_INTERVAL_MS=300000 # VERCEL_TEAM_ID= # VERCEL_PROJECT_ID= -# Cron auth -CRON_SECRET= +# Deploy hook auth +DEPLOY_HOOK_SECRET= # Workflow (local dev only) WORKFLOW_POSTGRES_URL=postgresql://localhost:5432/ai_workflow diff --git a/.github/workflows/post-deploy.yml b/.github/workflows/post-deploy.yml new file mode 100644 index 0000000..1a58611 --- /dev/null +++ b/.github/workflows/post-deploy.yml @@ -0,0 +1,15 @@ +name: Start poll workflow after deploy + +on: + deployment_status: + +jobs: + start-poll: + if: github.event.deployment_status.state == 'success' + runs-on: ubuntu-latest + steps: + - name: Trigger poll workflow + run: | + curl -sf -X GET \ + -H "Authorization: Bearer ${{ secrets.DEPLOY_HOOK_SECRET }}" \ + "${{ github.event.deployment_status.target_url }}/poll/start" diff --git a/env.ts b/env.ts index 0f1c200..2c5e3de 100644 --- a/env.ts +++ b/env.ts @@ -38,8 +38,8 @@ export const env = createEnv({ // Polling POLL_INTERVAL_MS: z.coerce.number().int().positive().default(15_000), - // Cron - CRON_SECRET: z.string().min(1).optional(), + // Deploy hook auth + DEPLOY_HOOK_SECRET: z.string().min(1).optional(), // Vercel (optional — auto via OIDC on Vercel) VERCEL_TOKEN: z.string().min(1).optional(), diff --git a/src/routes/poll/start.get.test.ts b/src/routes/poll/start.get.test.ts index acb574f..bfccfc7 100644 --- a/src/routes/poll/start.get.test.ts +++ b/src/routes/poll/start.get.test.ts @@ -46,13 +46,18 @@ describe("GET /poll/start", () => { }); it("starts a new workflow when none exists", async () => { - mockRedis.get.mockResolvedValueOnce(null).mockResolvedValueOnce(null); - mockRedis.set.mockResolvedValueOnce("OK").mockResolvedValueOnce("OK"); + mockRedis.set.mockResolvedValueOnce("OK"); + mockRedis.get.mockResolvedValueOnce(null); mockStart.mockResolvedValueOnce({ runId: "run_new" }); + mockRedis.set.mockResolvedValueOnce("OK"); const result = await handle({} as any); - expect(result).toEqual({ status: "started", runId: "run_new" }); + expect(result).toEqual({ + status: "restarted", + runId: "run_new", + cancelledRunId: null, + }); expect(mockRedis.set).toHaveBeenCalledWith( "blazebot:poll-workflow", "run_new", @@ -60,47 +65,45 @@ describe("GET /poll/start", () => { expect(mockRedis.del).toHaveBeenCalledWith("blazebot:poll-workflow:lock"); }); - it("returns already_running when workflow is active", async () => { - mockRedis.get.mockResolvedValueOnce("run_existing"); - mockGetRun.mockReturnValueOnce({ status: Promise.resolve("running") }); + it("cancels existing workflow and starts a new one", async () => { + mockRedis.set.mockResolvedValueOnce("OK"); + mockRedis.get.mockResolvedValueOnce("run_old"); + const mockCancel = vi.fn().mockResolvedValueOnce(undefined); + mockGetRun.mockReturnValueOnce({ cancel: mockCancel }); + mockRedis.del.mockResolvedValueOnce(1); + mockStart.mockResolvedValueOnce({ runId: "run_new" }); + mockRedis.set.mockResolvedValueOnce("OK"); const result = await handle({} as any); expect(result).toEqual({ - status: "already_running", - runId: "run_existing", + status: "restarted", + runId: "run_new", + cancelledRunId: "run_old", }); - expect(mockStart).not.toHaveBeenCalled(); + expect(mockCancel).toHaveBeenCalled(); }); - it("starts a new workflow when existing one is dead", async () => { - mockRedis.get.mockResolvedValueOnce("run_dead").mockResolvedValueOnce(null); - mockGetRun.mockReturnValueOnce({ status: Promise.resolve("completed") }); - mockRedis.set.mockResolvedValueOnce("OK").mockResolvedValueOnce("OK"); - mockStart.mockResolvedValueOnce({ runId: "run_replacement" }); - - const result = await handle({} as any); - - expect(result).toEqual({ status: "started", runId: "run_replacement" }); - expect(mockRedis.del).toHaveBeenCalledWith("blazebot:poll-workflow:lock"); - }); - - it("starts a new workflow when getRun throws", async () => { - mockRedis.get.mockResolvedValueOnce("run_gone").mockResolvedValueOnce(null); + it("starts new workflow even when cancel of existing throws", async () => { + mockRedis.set.mockResolvedValueOnce("OK"); + mockRedis.get.mockResolvedValueOnce("run_gone"); mockGetRun.mockImplementationOnce(() => { throw new Error("not found"); }); - mockRedis.set.mockResolvedValueOnce("OK").mockResolvedValueOnce("OK"); + mockRedis.del.mockResolvedValueOnce(1); mockStart.mockResolvedValueOnce({ runId: "run_fresh" }); + mockRedis.set.mockResolvedValueOnce("OK"); const result = await handle({} as any); - expect(result).toEqual({ status: "started", runId: "run_fresh" }); - expect(mockRedis.del).toHaveBeenCalledWith("blazebot:poll-workflow:lock"); + expect(result).toEqual({ + status: "restarted", + runId: "run_fresh", + cancelledRunId: "run_gone", + }); }); it("returns lock_contention when another start is in progress", async () => { - mockRedis.get.mockResolvedValueOnce(null); mockRedis.set.mockResolvedValueOnce(null); const result = await handle({} as any); @@ -112,8 +115,8 @@ describe("GET /poll/start", () => { expect(mockStart).not.toHaveBeenCalled(); }); - it("rejects requests with wrong bearer token when CRON_SECRET is set", async () => { - (env as any).CRON_SECRET = "real-secret"; + it("rejects requests with wrong bearer token when DEPLOY_HOOK_SECRET is set", async () => { + (env as any).DEPLOY_HOOK_SECRET = "real-secret"; const mockEvent = { node: { @@ -130,34 +133,7 @@ describe("GET /poll/start", () => { statusCode: 401, }); } finally { - delete (env as any).CRON_SECRET; + delete (env as any).DEPLOY_HOOK_SECRET; } }); - - it("returns already_running when workflow is pending", async () => { - mockRedis.get.mockResolvedValueOnce("run_pending"); - mockGetRun.mockReturnValueOnce({ status: Promise.resolve("pending") }); - - const result = await handle({} as any); - - expect(result).toEqual({ - status: "already_running", - runId: "run_pending", - }); - expect(mockStart).not.toHaveBeenCalled(); - }); - - it("returns already_running after lock when another request started workflow", async () => { - mockRedis.get - .mockResolvedValueOnce(null) - .mockResolvedValueOnce("run_raced"); - mockRedis.set.mockResolvedValueOnce("OK"); - mockGetRun.mockReturnValueOnce({ status: Promise.resolve("running") }); - - const result = await handle({} as any); - - expect(result).toEqual({ status: "already_running", runId: "run_raced" }); - expect(mockStart).not.toHaveBeenCalled(); - expect(mockRedis.del).toHaveBeenCalledWith("blazebot:poll-workflow:lock"); - }); }); diff --git a/src/routes/poll/start.get.ts b/src/routes/poll/start.get.ts index 001550d..1d9b06d 100644 --- a/src/routes/poll/start.get.ts +++ b/src/routes/poll/start.get.ts @@ -8,32 +8,33 @@ import { logger } from "../../lib/logger.js"; const POLL_WORKFLOW_KEY = "blazebot:poll-workflow"; const LOCK_KEY = "blazebot:poll-workflow:lock"; const LOCK_TTL_S = 30; -const ALIVE_STATUSES: string[] = ["running", "pending"]; const redis = new Redis({ url: env.AI_WORKFLOW_KV_REST_API_URL, token: env.AI_WORKFLOW_KV_REST_API_TOKEN, }); -export default defineEventHandler(async (event) => { - if (env.CRON_SECRET) { - const auth = getHeader(event, "authorization"); - if (auth !== `Bearer ${env.CRON_SECRET}`) { - throw createError({ statusCode: 401, statusMessage: "Unauthorized" }); - } +async function cancelExisting(): Promise { + const runId = await redis.get(POLL_WORKFLOW_KEY); + if (!runId) return null; + + try { + const run = getRun(runId); + await run.cancel(); + logger.info({ runId }, "poll_workflow_cancelled"); + } catch { + // already dead or not found } - const existingRunId = await redis.get(POLL_WORKFLOW_KEY); + await redis.del(POLL_WORKFLOW_KEY); + return runId; +} - if (existingRunId) { - try { - const run = getRun(existingRunId); - const status = await run.status; - if (ALIVE_STATUSES.includes(status)) { - return { status: "already_running", runId: existingRunId }; - } - } catch { - // Run not found — fall through to start a new one +export default defineEventHandler(async (event) => { + if (env.DEPLOY_HOOK_SECRET) { + const auth = getHeader(event, "authorization"); + if (auth !== `Bearer ${env.DEPLOY_HOOK_SECRET}`) { + throw createError({ statusCode: 401, statusMessage: "Unauthorized" }); } } @@ -46,23 +47,11 @@ export default defineEventHandler(async (event) => { } try { - const recheckRunId = await redis.get(POLL_WORKFLOW_KEY); - if (recheckRunId) { - try { - const run = getRun(recheckRunId); - const status = await run.status; - if (ALIVE_STATUSES.includes(status)) { - return { status: "already_running", runId: recheckRunId }; - } - } catch { - // Fall through to start - } - } - + const cancelledRunId = await cancelExisting(); const handle = await start(pollWorkflow); await redis.set(POLL_WORKFLOW_KEY, handle.runId); - logger.info({ runId: handle.runId }, "poll_workflow_started"); - return { status: "started", runId: handle.runId }; + logger.info({ runId: handle.runId, cancelledRunId }, "poll_workflow_started"); + return { status: "restarted", runId: handle.runId, cancelledRunId }; } finally { await redis.del(LOCK_KEY); } diff --git a/vercel.json b/vercel.json index f972aa8..0967ef4 100644 --- a/vercel.json +++ b/vercel.json @@ -1,8 +1 @@ -{ - "crons": [ - { - "path": "/poll/start", - "schedule": "*/15 * * * *" - } - ] -} +{} From ea75677825de07984fd731b684346b47911cdad7 Mon Sep 17 00:00:00 2001 From: woywro Date: Tue, 24 Mar 2026 08:17:00 +0100 Subject: [PATCH 6/8] docs: update spec --- docs/SPEC.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/SPEC.md b/docs/SPEC.md index 0484ee2..0f5d2f7 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -73,8 +73,9 @@ Important boundary: - Runs as a long-lived Vercel Workflow that sleeps between poll cycles (`POLL_INTERVAL_MS`, default 15s). - Queries the issue tracker for tickets in the AI column. - For each discovered ticket, starts a Vercel Workflow run if one is not already active. - - Started via `GET /poll/start` route which ensures singleton operation. Vercel Cron hits this - route every 15 minutes as a liveness check — if the workflow died, the route restarts it. + - Started via `GET /poll/start` route which cancels any existing poll run and starts a fresh one. + A GitHub Action triggers this route on every successful deployment — no Vercel Cron needed. + Protected by `DEPLOY_HOOK_SECRET` bearer token. 2. **Issue Tracker Adapter** - Reads ticket data (description, acceptance criteria, comments, labels). @@ -771,7 +772,7 @@ run_fixing_feedback(ticketId, existingPR): ### 18.1 Required for MVP -- [ ] Poller — Vercel Cron that queries issue tracker and dispatches workflow runs. +- [ ] Poller — GitHub Action deploy hook that starts the poll workflow on each deployment. - [ ] Issue Tracker adapter (Jira first). - [ ] VCS adapter (GitHub). - [ ] Messaging adapter (Chat SDK — chat-sdk.dev — for Slack/Teams). From 1e4ada6a1eecb9686a56087ce64c3af9d5b16ee3 Mon Sep 17 00:00:00 2001 From: woywro Date: Tue, 24 Mar 2026 08:38:45 +0100 Subject: [PATCH 7/8] fix: improve logging for poll workflow requests --- .github/workflows/post-deploy.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/post-deploy.yml b/.github/workflows/post-deploy.yml index 1a58611..cf990e6 100644 --- a/.github/workflows/post-deploy.yml +++ b/.github/workflows/post-deploy.yml @@ -10,6 +10,8 @@ jobs: steps: - name: Trigger poll workflow run: | - curl -sf -X GET \ + URL="${{ github.event.deployment_status.target_url }}/poll/start" + echo "Calling: $URL" + curl -v -X GET \ -H "Authorization: Bearer ${{ secrets.DEPLOY_HOOK_SECRET }}" \ - "${{ github.event.deployment_status.target_url }}/poll/start" + "$URL" From 5b13f6ed774b63a3deb346ac9b139f620a2a620c Mon Sep 17 00:00:00 2001 From: woywro Date: Tue, 24 Mar 2026 08:41:35 +0100 Subject: [PATCH 8/8] fix: add VERCEL_AUTOMATION_BYPASS --- .github/workflows/post-deploy.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/post-deploy.yml b/.github/workflows/post-deploy.yml index cf990e6..5245e80 100644 --- a/.github/workflows/post-deploy.yml +++ b/.github/workflows/post-deploy.yml @@ -12,6 +12,7 @@ jobs: run: | URL="${{ github.event.deployment_status.target_url }}/poll/start" echo "Calling: $URL" - curl -v -X GET \ + curl -sf -X GET \ -H "Authorization: Bearer ${{ secrets.DEPLOY_HOOK_SECRET }}" \ + -H "x-vercel-protection-bypass: ${{ secrets.VERCEL_AUTOMATION_BYPASS }}" \ "$URL"