WIP: 원진성형외과 회귀는 글 생성 단계에서 네트워크 끊김(fetch failed)으로 중단. 다음 세션에 --from generate 로 재실행. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
63 lines
3.9 KiB
JavaScript
63 lines
3.9 KiB
JavaScript
// 빌드 큐 폴러. 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}건` : '큐 비어 있음');
|
|
}
|