Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 150 additions & 0 deletions supabase/functions/_backend/triggers/cron_reconcile_build_status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import type { MiddlewareKeyVariables } from '../utils/hono.ts'
import { Hono } from 'hono/tiny'
import { BRES, middlewareAPISecret } from '../utils/hono.ts'
import { cloudlog, cloudlogErr } from '../utils/logging.ts'
import { recordBuildTime, supabaseAdmin } from '../utils/supabase.ts'
import { getEnv } from '../utils/utils.ts'

interface BuilderStatusResponse {
job: {
status: string
started_at: number | null
completed_at: number | null
error: string | null
}
machine: Record<string, unknown> | null
uploadUrl?: string
}

const TERMINAL_STATUSES = new Set(['succeeded', 'failed', 'expired', 'released', 'cancelled'])
const STALE_THRESHOLD_MINUTES = 5
const ORPHAN_THRESHOLD_HOURS = 1
const BATCH_LIMIT = 50

export const app = new Hono<MiddlewareKeyVariables>()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Use createHono() instead of new Hono<MiddlewareKeyVariables>().

The coding guidelines require using createHono from utils/hono.ts for all Hono app initialization. This likely wires in shared middleware (e.g., request ID generation) that you rely on via c.get('requestId').

Proposed fix
-import { BRES, middlewareAPISecret } from '../utils/hono.ts'
+import { BRES, createHono, middlewareAPISecret } from '../utils/hono.ts'
 ...
-export const app = new Hono<MiddlewareKeyVariables>()
+export const app = createHono()

As per coding guidelines: "Use createHono from utils/hono.ts for all Hono framework application initialization and routing." Based on learnings: "Use createHono from utils/hono.ts for all Hono framework application initialization and routing."

🤖 Prompt for AI Agents
In `@supabase/functions/_backend/triggers/cron_reconcile_build_status.ts` at line
24, Replace direct construction of the Hono app with the shared initializer:
change the app initialization from "new Hono<MiddlewareKeyVariables>()" to
calling "createHono<MiddlewareKeyVariables>()" and update imports to import
createHono from "utils/hono.ts" (remove or keep Hono import only if still
needed). This ensures middleware wired by createHono (e.g., requestId via
c.get('requestId')) is present for the exported "app".


app.post('/', middlewareAPISecret, async (c) => {
const startTime = Date.now()
let reconciled = 0
let orphaned = 0
let errors = 0

const supabase = supabaseAdmin(c)
const builderUrl = getEnv(c, 'BUILDER_URL')
const builderApiKey = getEnv(c, 'BUILDER_API_KEY')

const { data: staleBuilds, error: queryError } = await supabase
.from('build_requests')
.select('id, builder_job_id, app_id, owner_org, requested_by, platform, status, created_at')
.not('status', 'in', `(${[...TERMINAL_STATUSES].join(',')})`)
.lt('updated_at', new Date(Date.now() - STALE_THRESHOLD_MINUTES * 60 * 1000).toISOString())
.order('updated_at', { ascending: true })
.limit(BATCH_LIMIT)

if (queryError) {
cloudlogErr({ requestId: c.get('requestId'), message: 'Failed to query stale build_requests', error: queryError.message })
return c.json(BRES)
}

if (!staleBuilds || staleBuilds.length === 0) {
cloudlog({ requestId: c.get('requestId'), message: 'No stale builds to reconcile' })
return c.json(BRES)
}

cloudlog({ requestId: c.get('requestId'), message: `Found ${staleBuilds.length} stale builds to reconcile` })

for (const build of staleBuilds) {
if (!build.builder_job_id) {
const createdAt = new Date(build.created_at).getTime()
const orphanCutoff = Date.now() - ORPHAN_THRESHOLD_HOURS * 60 * 60 * 1000
if (createdAt < orphanCutoff) {
const { error: updateError } = await supabase
.from('build_requests')
.update({
status: 'failed',
last_error: 'Build request was never submitted to builder',
updated_at: new Date().toISOString(),
})
.eq('id', build.id)

if (updateError) {
cloudlogErr({ requestId: c.get('requestId'), message: 'Failed to mark orphan build as failed', buildId: build.id, error: updateError.message })
errors++
}
else {
orphaned++
}
}
continue
}

try {
const response = await fetch(`${builderUrl}/jobs/${build.builder_job_id}`, {
method: 'GET',
headers: { 'x-api-key': builderApiKey },
})

if (!response.ok) {
cloudlogErr({ requestId: c.get('requestId'), message: 'Builder status fetch failed', buildId: build.id, jobId: build.builder_job_id, status: response.status })
errors++
continue
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

External fetch call has no timeout — risk of the cron job hanging indefinitely.

If the builder API is slow or unresponsive, this fetch will block without limit. Since this runs inside a loop over up to 50 builds, a single hung request can stall the entire cron invocation (and potentially hit the function execution time limit silently).

Consider adding an AbortSignal.timeout:

Proposed fix
       const response = await fetch(`${builderUrl}/jobs/${build.builder_job_id}`, {
         method: 'GET',
         headers: { 'x-api-key': builderApiKey },
+        signal: AbortSignal.timeout(10_000),
       })
🤖 Prompt for AI Agents
In `@supabase/functions/_backend/triggers/cron_reconcile_build_status.ts` around
lines 86 - 96, The fetch to `${builderUrl}/jobs/${build.builder_job_id}` can
hang; modify the request inside the try where `fetch` is called to use an
AbortSignal timeout (e.g., `AbortSignal.timeout(5000)`) and pass the resulting
`signal` option to `fetch`; ensure the catch block detects an abort/timeout (and
logs via `cloudlogErr` including `c.get('requestId')`, `build.id`,
`build.builder_job_id`) and increments `errors` and `continue`s just like other
failures so a single slow request won't stall the loop.


const builderJob = await response.json() as BuilderStatusResponse
const jobStatus = builderJob.job.status

const { error: updateError } = await supabase
.from('build_requests')
.update({
status: jobStatus,
last_error: builderJob.job.error || null,
updated_at: new Date().toISOString(),
})
.eq('id', build.id)
Comment on lines +101 to +111
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Unbounded external status value written directly to the database.

jobStatus (Line 99) is whatever string the builder API returns, and it's written directly into build_requests.status (Line 104). If the builder returns an unexpected status value (e.g., "queued", "running", or a future new status), it will be stored as-is. If the status column has a CHECK constraint or enum, the update will fail (handled). If not, it could introduce values that other parts of the system don't expect.

Consider validating or mapping jobStatus to your known set of statuses before writing.

🤖 Prompt for AI Agents
In `@supabase/functions/_backend/triggers/cron_reconcile_build_status.ts` around
lines 98 - 108, The code writes an unvalidated builder API status
(builderJob.job.status stored in jobStatus) directly into the
build_requests.status column; instead map or validate jobStatus against your
known set of allowed statuses (e.g., "pending", "in_progress", "completed",
"failed") before calling supabase.from('build_requests').update, and convert
unknown values to a safe fallback (or reject/update to a normalized enum) while
also saving builderJob.job.error to last_error; update the logic around
builderJob/jobStatus to perform this mapping/validation prior to the DB update
so only allowed status values are written.


if (updateError) {
cloudlogErr({ requestId: c.get('requestId'), message: 'Failed to update build_requests status', buildId: build.id, error: updateError.message })
errors++
continue
}

reconciled++

if (
TERMINAL_STATUSES.has(jobStatus)
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The recordBuildTime function should only be called for 'succeeded' or 'failed' statuses, not for all terminal statuses. The existing pattern in supabase/functions/_backend/public/build/status.ts:128 explicitly checks for these two statuses before calling recordBuildTime. Other terminal statuses like 'expired', 'released', or 'cancelled' may not have valid started_at/completed_at times or should not be billed. Add an explicit status check: if ((jobStatus === 'succeeded' || jobStatus === 'failed') && builderJob.job.started_at && builderJob.job.completed_at)

Suggested change
TERMINAL_STATUSES.has(jobStatus)
(jobStatus === 'succeeded' || jobStatus === 'failed')

Copilot uses AI. Check for mistakes.
&& builderJob.job.started_at
&& builderJob.job.completed_at
Comment on lines +117 to +119

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restrict build-time billing to billable terminal statuses

The reconciliation path currently records build time for any status in TERMINAL_STATUSES, so jobs that end as cancelled, expired, or released will be billed whenever they include timestamps. In contrast, the normal /public/build/status.ts flow only calls recordBuildTime for succeeded/failed, so this introduces inconsistent and potentially inflated billing depending on which path updates the row first.

Useful? React with 👍 / 👎.

) {
const buildTimeSeconds = Math.floor((builderJob.job.completed_at - builderJob.job.started_at) / 1000)
const resolvedPlatform = (build.platform === 'ios' || build.platform === 'android')
? build.platform
: 'ios'

await recordBuildTime(
c,
build.owner_org,
build.requested_by,
build.builder_job_id,
resolvedPlatform as 'ios' | 'android',
buildTimeSeconds,
)
}
}
catch (err) {
cloudlogErr({ requestId: c.get('requestId'), message: 'Error reconciling build', buildId: build.id, jobId: build.builder_job_id, error: String(err) })
errors++
}
}

cloudlog({
requestId: c.get('requestId'),
message: 'Build status reconciliation completed',
duration_ms: Date.now() - startTime,
total: staleBuilds.length,
reconciled,
orphaned,
errors,
})

return c.json(BRES)
})
2 changes: 2 additions & 0 deletions supabase/functions/triggers/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { app as credit_usage_alerts } from '../_backend/triggers/credit_usage_alerts.ts'
import { app as cron_clean_orphan_images } from '../_backend/triggers/cron_clean_orphan_images.ts'
import { app as cron_clear_versions } from '../_backend/triggers/cron_clear_versions.ts'
import { app as cron_reconcile_build_status } from '../_backend/triggers/cron_reconcile_build_status.ts'
import { app as cron_email } from '../_backend/triggers/cron_email.ts'
import { app as cron_stat_app } from '../_backend/triggers/cron_stat_app.ts'
import { app as cron_stat_org } from '../_backend/triggers/cron_stat_org.ts'
Expand Down Expand Up @@ -48,6 +49,7 @@ appGlobal.route('/cron_stat_org', cron_stat_org)
appGlobal.route('/cron_sync_sub', cron_sync_sub)
appGlobal.route('/cron_clear_versions', cron_clear_versions)
appGlobal.route('/cron_clean_orphan_images', cron_clean_orphan_images)
appGlobal.route('/cron_reconcile_build_status', cron_reconcile_build_status)
appGlobal.route('/credit_usage_alerts', credit_usage_alerts)
appGlobal.route('/on_organization_delete', on_organization_delete)
appGlobal.route('/on_deploy_history_create', on_deploy_history_create)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
SELECT pgmq.create('cron_reconcile_build_status');

INSERT INTO public.cron_tasks (
name,
description,
task_type,
target,
batch_size,
second_interval,
minute_interval,
hour_interval,
run_at_hour,
run_at_minute,
run_at_second,
run_on_dow,
run_on_day
) VALUES (
'reconcile_build_status',
'Send build status reconciliation job to queue every 15 minutes',
'queue',
'cron_reconcile_build_status',
null,
null,
15,
null,
null,
null,
0,
null,
null
)
ON CONFLICT (name) DO UPDATE SET
description = EXCLUDED.description,
task_type = EXCLUDED.task_type,
target = EXCLUDED.target,
minute_interval = EXCLUDED.minute_interval,
run_at_second = EXCLUDED.run_at_second,
updated_at = NOW();

INSERT INTO public.cron_tasks (
name,
description,
task_type,
target,
batch_size,
second_interval,
minute_interval,
hour_interval,
run_at_hour,
run_at_minute,
run_at_second,
run_on_dow,
run_on_day
) VALUES (
'reconcile_build_status_queue',
'Process build status reconciliation queue',
'function_queue',
'["cron_reconcile_build_status"]',
null,
null,
5,
null,
null,
null,
0,
null,
null
)
ON CONFLICT (name) DO UPDATE SET
description = EXCLUDED.description,
task_type = EXCLUDED.task_type,
target = EXCLUDED.target,
minute_interval = EXCLUDED.minute_interval,
run_at_second = EXCLUDED.run_at_second,
updated_at = NOW();
Loading