diff --git a/cli/src/build/prescan/overrides.ts b/cli/src/build/prescan/overrides.ts index ac1b584ff2..d989c45276 100644 --- a/cli/src/build/prescan/overrides.ts +++ b/cli/src/build/prescan/overrides.ts @@ -4,8 +4,6 @@ // to warning so the rest of the scan still runs and reports. import type { Finding, PrescanCheck, Severity } from './types' -export type PrescanOverrideMode = 'skip' | 'warn' - export interface PrescanOverrides { /** Check ids that must not run. */ skip: Set diff --git a/src/components/admin/AdminMultiLineChart.vue b/src/components/admin/AdminMultiLineChart.vue index 4506c2f101..7fbaf8cbac 100644 --- a/src/components/admin/AdminMultiLineChart.vue +++ b/src/components/admin/AdminMultiLineChart.vue @@ -15,7 +15,7 @@ import { import { computed } from 'vue' import { Line } from 'vue-chartjs' import { createChartColorWithOpacity, resolveAccessibleChartColor } from '~/services/chartConfig' -import { formatLocalDate, formatLocalMonthYear } from '~/services/date' +import { formatLocalDate, formatLocalMonthYear, formatLocalTime } from '~/services/date' import { formatNumberValue } from '~/services/formatLocale' interface DataSeries { @@ -34,7 +34,7 @@ const props = defineProps({ default: false, }, dateGranularity: { - type: String as () => 'day' | 'month', + type: String as () => 'day' | 'month' | 'hour', default: 'day', }, valuePrefix: { @@ -67,6 +67,11 @@ function formatChartDate(date: string) { if (formattedMonth) return formattedMonth } + if (props.dateGranularity === 'hour') { + const formattedHour = formatLocalTime(date) + if (formattedHour) + return formattedHour + } return formatLocalDate(date) || date } diff --git a/src/pages/admin/dashboard/builder.vue b/src/pages/admin/dashboard/builder.vue index fae2164702..bff9aba42d 100644 --- a/src/pages/admin/dashboard/builder.vue +++ b/src/pages/admin/dashboard/builder.vue @@ -4,7 +4,7 @@ meta: @@ -388,6 +478,70 @@ displayStore.defaultBack = '/dashboard'
+ +
+ + + + + +
+ +
+ + + + +
+
{ result = await getAdminBuilderAnalytics(c, start_date, end_date) break + case 'builder_capacity': + result = await getAdminBuilderCapacity(c, start_date, end_date) + break + default: throw simpleError('invalid_metric_category', 'Invalid metric category', { metric_category }) } diff --git a/supabase/functions/_backend/public/build/capacity.ts b/supabase/functions/_backend/public/build/capacity.ts new file mode 100644 index 0000000000..8ae3c977c1 --- /dev/null +++ b/supabase/functions/_backend/public/build/capacity.ts @@ -0,0 +1,45 @@ +import type { Context } from 'hono' +import type { MiddlewareKeyVariables } from '../../utils/hono.ts' +import { timingSafeEqual } from 'hono/utils/buffer' +import { z } from 'zod' +import { recordBuilderCapacityIfChanged } from '../../utils/builder_capacity.ts' +import { BRES, parseBody, quickError, simpleError } from '../../utils/hono.ts' +import { cloudlog } from '../../utils/logging.ts' +import { getEnv } from '../../utils/utils.ts' + +const bodySchema = z.object({ + workers_total: z.number().int().min(0).max(10_000), + source: z.string().min(1).max(64).optional(), +}) + +async function assertBuilderApiKey(c: Context) { + const provided = c.req.header('x-api-key') || c.req.header('apikey') || '' + const expected = getEnv(c, 'BUILDER_API_KEY') + if (!expected || !provided || !(await timingSafeEqual(provided, expected))) + throw quickError(401, 'invalid_builder_api_key', 'Invalid builder API key') +} + +export async function reportBuilderCapacity(c: Context) { + await assertBuilderApiKey(c) + const body = await parseBody>(c) + const parsed = bodySchema.safeParse(body) + if (!parsed.success) { + throw simpleError('invalid_body', 'Invalid capacity body', { + issues: parsed.error.issues.map(i => i.message), + }) + } + + const source = parsed.data.source ?? 'builder' + const event = await recordBuilderCapacityIfChanged(c, parsed.data.workers_total, source) + cloudlog({ + requestId: c.get('requestId'), + message: 'builder capacity report', + workers_total: parsed.data.workers_total, + source, + recorded: !!event, + delta: event?.delta ?? 0, + }) + return c.json(event + ? { status: 'ok', recorded: true, workers_total: event.workers_total, delta: event.delta } + : { ...BRES, recorded: false, workers_total: parsed.data.workers_total }) +} diff --git a/supabase/functions/_backend/public/build/index.ts b/supabase/functions/_backend/public/build/index.ts index a37e66b1c3..b943ac0dd0 100644 --- a/supabase/functions/_backend/public/build/index.ts +++ b/supabase/functions/_backend/public/build/index.ts @@ -15,6 +15,7 @@ import { middlewareKey } from '../../utils/hono_middleware.ts' import { aiAnalyzeDeprecated } from './ai_analyze.ts' import { aiAnalyzeStreamBuild } from './ai_analyze_stream.ts' import { cancelBuild } from './cancel.ts' +import { reportBuilderCapacity } from './capacity.ts' import { streamBuildLogs } from './logs.ts' import { requestBuild } from './request.ts' import { startBuild } from './start.ts' @@ -23,6 +24,12 @@ import { uploadSupportLogs } from './support_logs.ts' import { tusProxy } from './upload.ts' export const app = honoFactory.createApp() + +// POST /build/capacity - Builder reports worker pool size (+/− events). Auth: BUILDER_API_KEY. +app.post('/capacity', async (c) => { + return reportBuilderCapacity(c) +}) + const uploadWriteMiddleware = middlewareKey(['all', 'write']) // POST /build/request - Request a new native build diff --git a/supabase/functions/_backend/public/build/start.ts b/supabase/functions/_backend/public/build/start.ts index 5e6bdf0dda..dfc4c1dd85 100644 --- a/supabase/functions/_backend/public/build/start.ts +++ b/supabase/functions/_backend/public/build/start.ts @@ -321,6 +321,7 @@ export async function startBuild( .from('build_requests') .update({ status: startedStatus, + // started_at is set later from the builder-reported run start (status/reconcile). updated_at: new Date().toISOString(), }) .eq('builder_job_id', jobId) diff --git a/supabase/functions/_backend/public/build/status.ts b/supabase/functions/_backend/public/build/status.ts index 1452b3088a..a650f59805 100644 --- a/supabase/functions/_backend/public/build/status.ts +++ b/supabase/functions/_backend/public/build/status.ts @@ -11,6 +11,7 @@ import { shouldApplyBuildTimeout, } from '../../utils/build_timeout.ts' import { emitBuildTransitionEvent } from '../../utils/build_tracking.ts' +import { isoFromBuilderTimestamp } from '../../utils/builder_capacity.ts' import { simpleError } from '../../utils/hono.ts' import { cloudlog, cloudlogErr } from '../../utils/logging.ts' import { checkPermission } from '../../utils/rbac.ts' @@ -223,6 +224,8 @@ export async function getBuildStatus( status: effectiveStatus, last_error: effectiveError, runner_wait_seconds: runnerWaitSeconds, + started_at: isoFromBuilderTimestamp(builderJob.job.started_at) ?? undefined, + completed_at: isoFromBuilderTimestamp(effectiveCompletedAt) ?? undefined, updated_at: new Date().toISOString(), }) .eq('builder_job_id', job_id) diff --git a/supabase/functions/_backend/triggers/cron_reconcile_build_status.ts b/supabase/functions/_backend/triggers/cron_reconcile_build_status.ts index 462abd9561..fe17127095 100644 --- a/supabase/functions/_backend/triggers/cron_reconcile_build_status.ts +++ b/supabase/functions/_backend/triggers/cron_reconcile_build_status.ts @@ -13,6 +13,7 @@ import { TERMINAL_BUILD_STATUSES, } from '../utils/build_timeout.ts' import { emitBuildTransitionEvent } from '../utils/build_tracking.ts' +import { isoFromBuilderTimestamp } from '../utils/builder_capacity.ts' import { BRES, middlewareAPISecret } from '../utils/hono.ts' import { cloudlog, cloudlogErr } from '../utils/logging.ts' import { recordBuildTime, supabaseAdmin } from '../utils/supabase.ts' @@ -225,6 +226,8 @@ app.post('/', middlewareAPISecret, async (c) => { status: effectiveStatus, last_error: effectiveError, runner_wait_seconds: runnerWaitSeconds, + started_at: isoFromBuilderTimestamp(builderJob.job.started_at) ?? undefined, + completed_at: isoFromBuilderTimestamp(effectiveCompletedAt) ?? undefined, updated_at: new Date().toISOString(), }) .eq('id', build.id) diff --git a/supabase/functions/_backend/utils/builder_capacity.ts b/supabase/functions/_backend/utils/builder_capacity.ts new file mode 100644 index 0000000000..53406ef880 --- /dev/null +++ b/supabase/functions/_backend/utils/builder_capacity.ts @@ -0,0 +1,509 @@ +import type { Context } from 'hono' +import { cloudlog, cloudlogErr } from './logging.ts' +import { closeClient, getPgClient } from './pg.ts' +import { supabaseAdmin } from './supabase.ts' +import { getEnv } from './utils.ts' + +export interface BuilderCapacityEvent { + created_at: number + workers_total: number + delta: number +} + +export interface BuilderRunInterval { + started_at: number + completed_at: number | null +} + +export interface BuilderCapacityHourPoint { + date: string + workers: number + used: number + free: number + waiting: number +} + +export interface BuilderCapacityLive { + workers_total: number + workers_online: number + used: number + free: number + waiting: number + offline: number + builder_reachable: boolean +} + +export interface BuilderCapacityResult { + live: BuilderCapacityLive + hourly: BuilderCapacityHourPoint[] + capacity_events: number + runs_sampled: number +} + +interface BuilderRunner { + id?: number | string + online?: boolean + currentJobId?: string | null +} + +interface BuilderRunnersResponse { + runners?: BuilderRunner[] + pressure?: { + waitingJobs?: number + onlineRunners?: number + registeredRunners?: number + } +} + +interface BuilderOkResponse { + machines_set?: number + machines_answering?: number + status?: string +} + +/** Statuses that occupy a runner machine (not queue wait). */ +const OCCUPYING_BUILD_STATUSES = ['running'] as const +const HOUR_MS = 60 * 60 * 1000 +const CAPACITY_ADVISORY_LOCK_KEY = 874_201_903 + +export function msFromBuilderTimestamp(value: number | null | undefined): number | null { + if (typeof value !== 'number' || !Number.isFinite(value)) + return null + return Math.trunc(value) +} + +export function isoFromBuilderTimestamp(value: number | null | undefined): string | null { + const ms = msFromBuilderTimestamp(value) + if (ms === null) + return null + return new Date(ms).toISOString() +} + +/** Requires `events` sorted ascending by `created_at` (then id). */ +export function workersAt(events: BuilderCapacityEvent[], atMs: number): number { + let workers = 0 + for (const event of events) { + if (event.created_at > atMs) + break + workers = event.workers_total + } + return workers +} + +export function maxConcurrentUsed( + intervals: BuilderRunInterval[], + rangeStartMs: number, + rangeEndMs: number, +): number { + const events: Array<{ t: number, d: number }> = [] + for (const interval of intervals) { + if (interval.started_at >= rangeEndMs) + continue + const end = interval.completed_at ?? rangeEndMs + if (end <= rangeStartMs) + continue + const start = Math.max(interval.started_at, rangeStartMs) + const stop = Math.min(end, rangeEndMs) + if (stop <= start) + continue + events.push({ t: start, d: 1 }) + events.push({ t: stop, d: -1 }) + } + events.sort((a, b) => a.t - b.t || a.d - b.d) + + let current = 0 + let max = 0 + for (const event of events) { + current += event.d + if (current > max) + max = current + } + return max +} + +/** + * Single sweep over run intervals + capacity events. + * First/last hour bins are clipped to [startMs, endMs]. + */ +export function reconstructHourlyCapacity( + events: BuilderCapacityEvent[], + intervals: BuilderRunInterval[], + startIso: string, + endIso: string, + waitingByHour: Map = new Map(), +): BuilderCapacityHourPoint[] { + const startMs = Date.parse(startIso) + const endMs = Date.parse(endIso) + if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) + return [] + + const sortedEvents = [...events].sort((a, b) => a.created_at - b.created_at) + + const runEvents: Array<{ t: number, d: number }> = [] + for (const interval of intervals) { + const start = Math.max(interval.started_at, startMs) + const end = Math.min(interval.completed_at ?? endMs, endMs) + if (end <= start) + continue + runEvents.push({ t: start, d: 1 }, { t: end, d: -1 }) + } + runEvents.sort((a, b) => a.t - b.t || a.d - b.d) + + const firstHour = Math.floor(startMs / HOUR_MS) * HOUR_MS + const points: BuilderCapacityHourPoint[] = [] + let ei = 0 + let current = 0 + + for (let hour = firstHour; hour < endMs; hour += HOUR_MS) { + const binStart = Math.max(hour, startMs) + const binEnd = Math.min(hour + HOUR_MS, endMs) + if (binEnd <= binStart) + continue + + let maxUsed = current + while (ei < runEvents.length && runEvents[ei].t < binEnd) { + current += runEvents[ei].d + if (runEvents[ei].t >= binStart) + maxUsed = Math.max(maxUsed, current) + ei += 1 + } + + const workers = workersAt(sortedEvents, binEnd - 1) + const date = new Date(hour).toISOString() + points.push({ + date, + workers, + used: maxUsed, + free: Math.max(0, workers - maxUsed), + waiting: waitingByHour.get(date) ?? 0, + }) + } + + return points +} + +async function fetchBuilderLive(c: Context): Promise<{ + live: BuilderCapacityLive + source: string +}> { + const empty: BuilderCapacityLive = { + workers_total: 0, + workers_online: 0, + used: 0, + free: 0, + waiting: 0, + offline: 0, + builder_reachable: false, + } + + const builderUrl = getEnv(c, 'BUILDER_URL') + const builderApiKey = getEnv(c, 'BUILDER_API_KEY') + if (!builderUrl) { + return { live: empty, source: 'missing_builder_url' } + } + + try { + if (builderApiKey) { + const response = await fetch(`${builderUrl}/gitlab-emulator/runners`, { + method: 'GET', + headers: { 'x-api-key': builderApiKey }, + signal: AbortSignal.timeout(5_000), + }) + if (response.ok) { + const body = await response.json() as BuilderRunnersResponse + const runners = body.runners ?? [] + const online = runners.filter(r => r.online) + const used = online.filter(r => !!r.currentJobId).length + const free = Math.max(0, online.length - used) + const offline = Math.max(0, runners.length - online.length) + const waiting = body.pressure?.waitingJobs ?? 0 + return { + live: { + workers_total: runners.length, + workers_online: online.length, + used, + free, + waiting, + offline, + builder_reachable: true, + }, + source: 'runners', + } + } + cloudlogErr({ + requestId: c.get('requestId'), + message: 'builder capacity runners fetch failed', + status: response.status, + }) + } + + const okResponse = await fetch(`${builderUrl}/ok`, { + method: 'GET', + signal: AbortSignal.timeout(5_000), + }) + if (!okResponse.ok) { + cloudlogErr({ + requestId: c.get('requestId'), + message: 'builder capacity /ok fetch failed', + status: okResponse.status, + }) + return { live: empty, source: 'unreachable' } + } + const ok = await okResponse.json() as BuilderOkResponse + const online = Math.max(0, Math.trunc(ok.machines_answering ?? 0)) + const total = Math.max(online, Math.trunc(ok.machines_set ?? online)) + return { + live: { + workers_total: total, + workers_online: online, + used: 0, + free: online, + waiting: 0, + offline: Math.max(0, total - online), + builder_reachable: true, + }, + source: 'ok', + } + } + catch (error) { + cloudlogErr({ + requestId: c.get('requestId'), + message: 'builder capacity live fetch error', + error: String(error), + }) + return { live: empty, source: 'error' } + } +} + +async function countOccupyingBuilds(c: Context): Promise { + const { count, error } = await supabaseAdmin(c) + .from('build_requests') + .select('id', { count: 'exact', head: true }) + .in('status', [...OCCUPYING_BUILD_STATUSES]) + + if (error) { + cloudlogErr({ + requestId: c.get('requestId'), + message: 'Failed counting occupying builds for capacity', + error: error.message, + }) + return 0 + } + return count ?? 0 +} + +export async function recordBuilderCapacityIfChanged( + c: Context, + workersTotal: number, + source = 'sync', +): Promise { + const total = Math.max(0, Math.trunc(workersTotal)) + const client = getPgClient(c) + try { + await client.query('BEGIN') + await client.query('SELECT pg_advisory_xact_lock($1)', [CAPACITY_ADVISORY_LOCK_KEY]) + + const { rows: latestRows } = await client.query<{ workers_total: number }>( + `SELECT workers_total + FROM public.builder_capacity_events + ORDER BY created_at DESC, id DESC + LIMIT 1`, + ) + const previous = latestRows[0]?.workers_total ?? null + if (previous === total) { + await client.query('COMMIT') + return null + } + + const delta = previous === null ? total : total - previous + const { rows } = await client.query<{ + created_at: string + workers_total: number + delta: number + }>( + `INSERT INTO public.builder_capacity_events (workers_total, delta, source) + VALUES ($1, $2, $3) + RETURNING created_at, workers_total, delta`, + [total, delta, source], + ) + await client.query('COMMIT') + + const inserted = rows[0] + if (!inserted) + return null + + cloudlog({ + requestId: c.get('requestId'), + message: 'builder capacity event recorded', + workers_total: total, + delta, + source, + }) + + return { + created_at: Date.parse(inserted.created_at), + workers_total: inserted.workers_total, + delta: inserted.delta, + } + } + catch (error) { + try { + await client.query('ROLLBACK') + } + catch { + // ignore rollback errors + } + cloudlogErr({ + requestId: c.get('requestId'), + message: 'Failed recording builder capacity event', + error: String(error), + }) + return null + } + finally { + await closeClient(c, client) + } +} + +async function loadCapacityEvents( + c: Context, + startIso: string, + endIso: string, +): Promise { + const client = getPgClient(c) + try { + const { rows } = await client.query<{ + created_at: string + workers_total: number + delta: number + }>( + `WITH baseline AS ( + SELECT id, created_at, workers_total, delta + FROM public.builder_capacity_events + WHERE created_at < $1::timestamptz + ORDER BY created_at DESC, id DESC + LIMIT 1 + ), + window_events AS ( + SELECT id, created_at, workers_total, delta + FROM public.builder_capacity_events + WHERE created_at >= $1::timestamptz + AND created_at <= $2::timestamptz + ) + SELECT id, created_at, workers_total, delta FROM baseline + UNION ALL + SELECT id, created_at, workers_total, delta FROM window_events + ORDER BY created_at ASC, id ASC`, + [startIso, endIso], + ) + return rows.map(row => ({ + created_at: Date.parse(row.created_at), + workers_total: Number(row.workers_total) || 0, + delta: Number(row.delta) || 0, + })) + } + finally { + await closeClient(c, client) + } +} + +async function loadRunIntervals( + c: Context, + startIso: string, + endIso: string, +): Promise { + const client = getPgClient(c) + try { + // Only builder-reported run intervals (started_at set when the runner + // actually starts). Exclude waiting_runner / queue time from "used". + const { rows } = await client.query<{ + started_at: string | null + completed_at: string | null + }>( + `WITH request_runs AS ( + SELECT + br.started_at, + br.completed_at + FROM public.build_requests br + WHERE br.started_at IS NOT NULL + AND ( + br.completed_at IS NOT NULL + OR br.status = 'running' + ) + AND br.started_at < $2::timestamptz + AND (br.completed_at IS NULL OR br.completed_at > $1::timestamptz) + AND (br.completed_at IS NULL OR br.completed_at >= br.started_at) + ), + log_runs AS ( + SELECT + (bl.created_at - make_interval(secs => GREATEST(bl.build_time_unit, 0))) AS started_at, + bl.created_at AS completed_at + FROM public.build_logs bl + WHERE bl.created_at > $1::timestamptz + AND bl.created_at - make_interval(secs => GREATEST(bl.build_time_unit, 0)) < $2::timestamptz + AND bl.build_time_unit > 0 + AND NOT EXISTS ( + SELECT 1 + FROM public.build_requests br + WHERE br.builder_job_id = bl.build_id + AND br.started_at IS NOT NULL + ) + ) + SELECT started_at, completed_at FROM request_runs + UNION ALL + SELECT started_at, completed_at FROM log_runs`, + [startIso, endIso], + ) + + return rows + .map((row) => { + const started = row.started_at ? Date.parse(row.started_at) : NaN + if (!Number.isFinite(started)) + return null + const completed = row.completed_at ? Date.parse(row.completed_at) : null + const completedMs = completed !== null && Number.isFinite(completed) ? completed : null + if (completedMs !== null && completedMs < started) + return null + return { + started_at: started, + completed_at: completedMs, + } satisfies BuilderRunInterval + }) + .filter((row): row is BuilderRunInterval => row !== null) + } + finally { + await closeClient(c, client) + } +} + +export async function getAdminBuilderCapacity( + c: Context, + startIso: string, + endIso: string, +): Promise { + const [{ live, source }, occupyingBuilds] = await Promise.all([ + fetchBuilderLive(c), + countOccupyingBuilds(c), + ]) + + // Prefer builder machine occupancy; fall back to Capgo running jobs when /ok + // path cannot see currentJobId. waiting_runner is demand, not used capacity. + if (source === 'ok' || (!live.builder_reachable && occupyingBuilds > 0)) { + live.used = Math.min(live.workers_online || occupyingBuilds, occupyingBuilds) + live.free = Math.max(0, (live.workers_online || occupyingBuilds) - live.used) + } + + const [events, intervals] = await Promise.all([ + loadCapacityEvents(c, startIso, endIso), + loadRunIntervals(c, startIso, endIso), + ]) + + const hourly = reconstructHourlyCapacity(events, intervals, startIso, endIso) + + return { + live, + hourly, + capacity_events: events.length, + runs_sampled: intervals.length, + } +} diff --git a/supabase/functions/_backend/utils/supabase.types.ts b/supabase/functions/_backend/utils/supabase.types.ts index 4dc9f1b31b..c89b03384f 100644 --- a/supabase/functions/_backend/utils/supabase.types.ts +++ b/supabase/functions/_backend/utils/supabase.types.ts @@ -584,6 +584,30 @@ export type Database = { }, ] } + builder_capacity_events: { + Row: { + created_at: string + delta: number + id: number + source: string + workers_total: number + } + Insert: { + created_at?: string + delta: number + id?: number + source?: string + workers_total: number + } + Update: { + created_at?: string + delta?: number + id?: number + source?: string + workers_total?: number + } + Relationships: [] + } build_requests: { Row: { ai_analyzed: boolean @@ -591,6 +615,7 @@ export type Database = { build_config: Json | null build_mode: string builder_job_id: string | null + completed_at: string | null created_at: string id: string last_error: string | null @@ -598,6 +623,7 @@ export type Database = { platform: string requested_by: string runner_wait_seconds: number + started_at: string | null status: string updated_at: string upload_expires_at: string @@ -611,6 +637,7 @@ export type Database = { build_config?: Json | null build_mode?: string builder_job_id?: string | null + completed_at?: string | null created_at?: string id?: string last_error?: string | null @@ -618,6 +645,7 @@ export type Database = { platform: string requested_by: string runner_wait_seconds?: number + started_at?: string | null status?: string updated_at?: string upload_expires_at: string @@ -631,6 +659,7 @@ export type Database = { build_config?: Json | null build_mode?: string builder_job_id?: string | null + completed_at?: string | null created_at?: string id?: string last_error?: string | null @@ -638,6 +667,7 @@ export type Database = { platform?: string requested_by?: string runner_wait_seconds?: number + started_at?: string | null status?: string updated_at?: string upload_expires_at?: string diff --git a/supabase/migrations/20260807121500_builder_capacity_events.sql b/supabase/migrations/20260807121500_builder_capacity_events.sql new file mode 100644 index 0000000000..e84ca8c417 --- /dev/null +++ b/supabase/migrations/20260807121500_builder_capacity_events.sql @@ -0,0 +1,79 @@ +-- Builder pool capacity as an event log (+/− workers) plus build run intervals +-- on build_requests. Hourly free/used is reconstructed from these events — +-- no polling cron snapshots. + +ALTER TABLE public.build_requests + ADD COLUMN IF NOT EXISTS started_at timestamp with time zone, + ADD COLUMN IF NOT EXISTS completed_at timestamp with time zone; + +COMMENT ON COLUMN public.build_requests.started_at IS + 'Builder-reported job start (UTC). Used for capacity reconstruction.'; +COMMENT ON COLUMN public.build_requests.completed_at IS + 'Builder-reported job completion (UTC). Used for capacity reconstruction.'; + +CREATE INDEX IF NOT EXISTS idx_build_requests_run_interval + ON public.build_requests USING btree (started_at, completed_at) + WHERE started_at IS NOT NULL; + +ALTER TABLE public.build_requests + DROP CONSTRAINT IF EXISTS build_requests_run_interval_check; + +ALTER TABLE public.build_requests + ADD CONSTRAINT build_requests_run_interval_check + CHECK ( + started_at IS NULL + OR completed_at IS NULL + OR completed_at >= started_at + ); + +CREATE TABLE IF NOT EXISTS public.builder_capacity_events ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + created_at timestamp with time zone DEFAULT now() NOT NULL, + workers_total integer NOT NULL, + delta integer NOT NULL, + source text NOT NULL DEFAULT 'sync', + CONSTRAINT builder_capacity_events_workers_total_check CHECK (workers_total >= 0), + CONSTRAINT builder_capacity_events_source_check CHECK ( + char_length(source) > 0 + AND char_length(source) <= 64 + ) +); + +ALTER TABLE public.builder_capacity_events OWNER TO postgres; + +COMMENT ON TABLE public.builder_capacity_events IS + 'Absolute worker pool size over time. delta is workers_total - previous workers_total.'; + +CREATE INDEX IF NOT EXISTS idx_builder_capacity_events_created_at + ON public.builder_capacity_events USING btree (created_at DESC); + +ALTER TABLE public.builder_capacity_events ENABLE ROW LEVEL SECURITY; + +-- System-managed: only service_role may read/write. Platform admin stats use +-- the service-role path in admin_stats, never PostgREST user context. +CREATE POLICY "Deny all authenticated on builder_capacity_events" + ON public.builder_capacity_events + AS RESTRICTIVE + FOR ALL + TO authenticated, anon + USING (false) + WITH CHECK (false); + +CREATE POLICY "Service role manages builder_capacity_events" + ON public.builder_capacity_events + TO service_role + USING (true) + WITH CHECK (true); + +GRANT ALL ON TABLE public.builder_capacity_events TO service_role; +GRANT USAGE, SELECT ON SEQUENCE public.builder_capacity_events_id_seq TO service_role; +REVOKE ALL ON TABLE public.builder_capacity_events FROM PUBLIC; +REVOKE ALL ON TABLE public.builder_capacity_events FROM anon; +REVOKE ALL ON TABLE public.builder_capacity_events FROM authenticated; + +-- Current production pool size (3 MacInCloud GitLab emulator runners). +INSERT INTO public.builder_capacity_events (workers_total, delta, source) +SELECT 3, 3, 'seed' +WHERE NOT EXISTS ( + SELECT 1 FROM public.builder_capacity_events +); diff --git a/tests/admin-stats.unit.test.ts b/tests/admin-stats.unit.test.ts index 3b102176d7..71a0f752dc 100644 --- a/tests/admin-stats.unit.test.ts +++ b/tests/admin-stats.unit.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest' import { adminStatsBodySchema, MAX_ADMIN_STATS_LIMIT, MAX_ADMIN_STATS_OFFSET } from '../supabase/functions/_backend/private/admin_stats.ts' -import { safeParseSchema } from '../supabase/functions/_backend/utils/schema_validation.ts' import { buildPluginBreakdownResult, normalizeAnalyticsLimit } from '../supabase/functions/_backend/utils/cloudflare.ts' +import { normalizeAdminStatsDate } from '../supabase/functions/_backend/utils/pg.ts' + +import { safeParseSchema } from '../supabase/functions/_backend/utils/schema_validation.ts' describe('admin stats validation', () => { const baseBody = { @@ -49,6 +51,15 @@ describe('admin stats validation', () => { expect(parsed.success).toBe(true) }) + it('accepts the builder capacity metric', () => { + const parsed = safeParseSchema(adminStatsBodySchema, { + ...baseBody, + metric_category: 'builder_capacity', + }) + + expect(parsed.success).toBe(true) + }) + it.concurrent('accepts the trial plan breakdown metric', () => { const parsed = safeParseSchema(adminStatsBodySchema, { ...baseBody, @@ -147,8 +158,6 @@ describe('buildPluginBreakdownResult', () => { }) }) -import { normalizeAdminStatsDate } from '../supabase/functions/_backend/utils/pg.ts' - describe('normalizeAdminStatsDate', () => { it.concurrent('normalizes Date objects and ISO timestamps to YYYY-MM-DD', () => { expect(normalizeAdminStatsDate(new Date('2026-06-20T15:30:00.000Z'))).toBe('2026-06-20') diff --git a/tests/audit-logs.test.ts b/tests/audit-logs.test.ts index 7a577400e6..818148f05f 100644 --- a/tests/audit-logs.test.ts +++ b/tests/audit-logs.test.ts @@ -361,7 +361,7 @@ describe('audit log triggers', () => { } }) - it('organization and app bookkeeping UPDATEs do not create audit logs', async () => { + it('organization and app bookkeeping update rows do not create audit logs', async () => { const bookkeepingOrgId = randomUUID() const bookkeepingAppId = `com.audit.bookkeeping.${bookkeepingOrgId.replace(/-/g, '')}` const bookkeepingCustomerId = `cus_audit_bookkeeping_${bookkeepingOrgId}` diff --git a/tests/builder-capacity.unit.test.ts b/tests/builder-capacity.unit.test.ts new file mode 100644 index 0000000000..3556b9bea7 --- /dev/null +++ b/tests/builder-capacity.unit.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest' +import { + isoFromBuilderTimestamp, + maxConcurrentUsed, + msFromBuilderTimestamp, + reconstructHourlyCapacity, + workersAt, +} from '../supabase/functions/_backend/utils/builder_capacity.ts' + +describe('builder capacity reconstruction', () => { + it.concurrent('workersAt uses the latest event at or before the timestamp', () => { + const events = [ + { created_at: Date.parse('2026-08-06T10:00:00.000Z'), workers_total: 2, delta: 2 }, + { created_at: Date.parse('2026-08-06T12:00:00.000Z'), workers_total: 3, delta: 1 }, + { created_at: Date.parse('2026-08-06T14:00:00.000Z'), workers_total: 1, delta: -2 }, + ] + expect(workersAt(events, Date.parse('2026-08-06T09:59:59.000Z'))).toBe(0) + expect(workersAt(events, Date.parse('2026-08-06T10:00:00.000Z'))).toBe(2) + expect(workersAt(events, Date.parse('2026-08-06T12:30:00.000Z'))).toBe(3) + expect(workersAt(events, Date.parse('2026-08-06T15:00:00.000Z'))).toBe(1) + }) + + it.concurrent('maxConcurrentUsed counts overlapping runs inside a window', () => { + const hourStart = Date.parse('2026-08-06T10:00:00.000Z') + const hourEnd = Date.parse('2026-08-06T11:00:00.000Z') + const used = maxConcurrentUsed([ + { started_at: Date.parse('2026-08-06T09:50:00.000Z'), completed_at: Date.parse('2026-08-06T10:20:00.000Z') }, + { started_at: Date.parse('2026-08-06T10:10:00.000Z'), completed_at: Date.parse('2026-08-06T10:40:00.000Z') }, + { started_at: Date.parse('2026-08-06T10:30:00.000Z'), completed_at: Date.parse('2026-08-06T11:10:00.000Z') }, + ], hourStart, hourEnd) + expect(used).toBe(2) + }) + + it.concurrent('maxConcurrentUsed treats null completed_at as still running through the window end', () => { + const hourStart = Date.parse('2026-08-06T10:00:00.000Z') + const hourEnd = Date.parse('2026-08-06T11:00:00.000Z') + const used = maxConcurrentUsed([ + { started_at: Date.parse('2026-08-06T10:15:00.000Z'), completed_at: null }, + ], hourStart, hourEnd) + expect(used).toBe(1) + }) + + it.concurrent('reconstructHourlyCapacity builds free = workers - used', () => { + const hourly = reconstructHourlyCapacity( + [ + { created_at: Date.parse('2026-08-06T09:00:00.000Z'), workers_total: 3, delta: 3 }, + ], + [ + { started_at: Date.parse('2026-08-06T10:05:00.000Z'), completed_at: Date.parse('2026-08-06T10:50:00.000Z') }, + { started_at: Date.parse('2026-08-06T10:20:00.000Z'), completed_at: Date.parse('2026-08-06T10:45:00.000Z') }, + ], + '2026-08-06T10:00:00.000Z', + '2026-08-06T12:00:00.000Z', + ) + expect(hourly).toHaveLength(2) + expect(hourly[0]).toMatchObject({ + date: '2026-08-06T10:00:00.000Z', + workers: 3, + used: 2, + free: 1, + }) + expect(hourly[1]).toMatchObject({ + date: '2026-08-06T11:00:00.000Z', + workers: 3, + used: 0, + free: 3, + }) + }) + + it.concurrent('reconstructHourlyCapacity clips first/last bins to the selected range', () => { + const hourly = reconstructHourlyCapacity( + [ + { created_at: Date.parse('2026-08-06T00:00:00.000Z'), workers_total: 2, delta: 2 }, + ], + [ + { started_at: Date.parse('2026-08-06T00:00:00.000Z'), completed_at: Date.parse('2026-08-06T23:00:00.000Z') }, + { started_at: Date.parse('2026-08-06T10:45:00.000Z'), completed_at: Date.parse('2026-08-06T11:15:00.000Z') }, + ], + '2026-08-06T10:30:00.000Z', + '2026-08-06T11:30:00.000Z', + ) + expect(hourly).toHaveLength(2) + expect(hourly[0].used).toBe(2) + expect(hourly[0].free).toBe(0) + expect(hourly[1].used).toBe(2) + expect(hourly[1].free).toBe(0) + }) + + it.concurrent('reconstructHourlyCapacity keeps zero-worker hours as data', () => { + const hourly = reconstructHourlyCapacity( + [ + { created_at: Date.parse('2026-08-06T10:00:00.000Z'), workers_total: 0, delta: 0 }, + ], + [], + '2026-08-06T10:00:00.000Z', + '2026-08-06T11:00:00.000Z', + ) + expect(hourly).toHaveLength(1) + expect(hourly[0]).toMatchObject({ workers: 0, used: 0, free: 0 }) + }) + + it.concurrent('timestamp helpers accept builder epoch ms and reject invalid values', () => { + expect(msFromBuilderTimestamp(1_700_000_000_000)).toBe(1_700_000_000_000) + expect(msFromBuilderTimestamp(null)).toBeNull() + expect(msFromBuilderTimestamp(Number.NaN)).toBeNull() + expect(isoFromBuilderTimestamp(0)).toBe('1970-01-01T00:00:00.000Z') + expect(isoFromBuilderTimestamp(undefined)).toBeNull() + }) + + it.concurrent('reconstructHourlyCapacity returns [] for inverted or invalid ranges', () => { + expect(reconstructHourlyCapacity([], [], '2026-08-06T12:00:00.000Z', '2026-08-06T10:00:00.000Z')).toEqual([]) + expect(reconstructHourlyCapacity([], [], 'not-a-date', '2026-08-06T10:00:00.000Z')).toEqual([]) + }) +})