// 빌드 큐 폴러. supporter_builds 에서 status=queued 행을 하나 집어 run.mjs 를 돌린다 (v2 §12-3 워커 위치와 무관하게 동작). // 어디서든 돈다: 지금은 haewon 의 Mac(launchd) 또는 Claude 세션에서 수동, 나중에 Trigger.dev 태스크·VPS cron 에 같은 파일을 올린다. // // node workers/supporters-build/poll.mjs 큐를 한 번 비우고 종료 (cron/launchd 용) // node workers/supporters-build/poll.mjs --loop 60 60초마다 반복 (상주 프로세스) // 옵션: --no-deploy --max-cost-usd 10 --work-root ~/supporters-builds // // 필요한 환경: .env 의 VITE_SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, OPENAI_API_KEY, (배포 시) vercel CLI 로그인. // 같은 행을 두 폴러가 집지 않도록 queued → running 갱신을 조건부 PATCH(status=eq.queued)로 하고, 갱신된 행이 없으면 건너뛴다. import { spawnSync } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { homedir } from 'node:os'; const ROOT = resolve(fileURLToPath(new URL('../../', import.meta.url))); const args = process.argv.slice(2); const opt = (k, d) => { const i = args.indexOf(`--${k}`); return i >= 0 && args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : d; }; const flag = (k) => args.includes(`--${k}`); (function loadEnv() { const p = join(ROOT, '.env'); if (!existsSync(p)) return; for (const line of readFileSync(p, 'utf8').split('\n')) { const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/); if (m && !process.env[m[1]]) process.env[m[1]] = m[2].replace(/^["']|["']$/g, ''); } })(); const SB = process.env.SUPABASE_URL ?? process.env.VITE_SUPABASE_URL; const KEY = process.env.SUPABASE_SERVICE_ROLE_KEY; if (!SB || !KEY) { console.error('VITE_SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY 없음'); process.exit(2); } const H = { apikey: KEY, authorization: `Bearer ${KEY}`, 'content-type': 'application/json' }; const WORK_ROOT = resolve(opt('work-root', join(homedir(), 'supporters-builds'))); const loopSec = Number(opt('loop', '0')); async function nextQueued() { const r = await fetch(`${SB}/rest/v1/supporter_builds?status=eq.queued&order=created_at.asc&limit=1&select=id,clinic_id,url,clinic_name`, { headers: H }); const rows = await r.json(); return rows[0] ?? null; } async function claim(id) { const r = await fetch(`${SB}/rest/v1/supporter_builds?id=eq.${id}&status=eq.queued`, { method: 'PATCH', headers: { ...H, prefer: 'return=representation' }, body: JSON.stringify({ status: 'running', phase: 'claimed', updated_at: new Date().toISOString() }) }); const rows = await r.json(); return Array.isArray(rows) && rows.length > 0; } async function runOne(row) { const argv = [join(ROOT, 'workers/supporters-build/run.mjs'), '--clinic', row.clinic_id, '--url', row.url, '--work', join(WORK_ROOT, `${row.clinic_id}-${row.id.slice(0, 8)}`), '--build-id', row.id, '--max-cost-usd', opt('max-cost-usd', '10')]; if (row.clinic_name) argv.push('--name', row.clinic_name); if (!flag('no-deploy')) argv.push('--deploy'); console.log(`▶ ${row.clinic_id} ${row.url} (${row.id})`); const r = spawnSync('node', argv, { cwd: ROOT, stdio: 'inherit', env: process.env }); console.log(`${r.status === 0 ? '✓' : '✗'} ${row.clinic_id} exit ${r.status}`); } async function drain() { let n = 0; for (;;) { const row = await nextQueued(); if (!row) break; if (!(await claim(row.id))) continue; await runOne(row); n++; } return n; } if (loopSec > 0) { console.log(`큐 폴링 ${loopSec}초 간격 · ${SB}`); for (;;) { try { const n = await drain(); if (n) console.log(`처리 ${n}건`); } catch (e) { console.error('폴링 오류', e.message); } await new Promise((r) => setTimeout(r, loopSec * 1000)); } } else { const n = await drain(); console.log(n ? `처리 ${n}건` : '큐 비어 있음'); }