diff --git a/.env.example b/.env.example index 4172182..62aa85b 100644 --- a/.env.example +++ b/.env.example @@ -70,6 +70,17 @@ NEXT_PUBLIC_BASE_URL=https://alpha.moss.land # ALERT_WEBHOOK_URL=https://discord.com/api/webhooks/... # HEALTH_ALERT_SUMMARY_HOUR=10 +# ─── DB 백업 (scripts/backup-db.ts, 매일 03:00 KST) ────────────────── +# 스냅샷 위치. 기본값은 deploy.sh 의 배포 전 백업과 같은 ~/backups/alpha. +# BACKUP_DIR=/abs/path/backups +# 보관 개수 (기본 14). 매 실행이 DB 전체 사본 한 개를 추가한다. +# BACKUP_KEEP=14 +# ★ 호스트 밖으로 미는 rsync 목적지. 이게 비어 있으면 원본과 사본이 같은 +# 디스크에 있고, 호스트가 사라지면 둘 다 사라진다 — 백업이 아니라 실행 +# 취소다. 설정해 두면 복사 실패가 /health 의 db_backup 을 빨갛게 만든다. +# BACKUP_REMOTE=user@backup-host:/srv/alpha-backups/ +# BACKUP_RSYNC_BIN=rsync + # ─── (Optional) OAuth for community accounts ───────────────────────── # SESSION_SECRET= # KAKAO_OAUTH_CLIENT_ID= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a98207c..7766390 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,12 +1,15 @@ -# The three checks that gate a deploy, run on every PR and every push to main. +# The checks that gate a deploy, run on every PR and every push to main. # -# Deliberately the SAME checks scripts/deploy.sh runs on the server before it -# swaps a release — typecheck, production build, and the health smoke test — -# so a red PR here is a deploy that would have been refused there, and a green -# one is a build the server will accept. Nothing here needs a secret: the smoke -# test builds its own throwaway SQLite schema, and none of the three call an -# external API. That matters because this repository is public and fork PRs -# run without secrets. +# Typecheck, tests, production build and the health smoke test are the SAME +# ones scripts/deploy.sh runs on the server before it swaps a release, so a +# red PR here is a deploy that would have been refused there, and a green one +# is a build the server will accept. The dependency audit is CI-only: a +# registry outage must not be able to block a deploy. +# +# Nothing here needs a secret — the smoke test builds its own throwaway SQLite +# schema, the tests use a temp file, and none of them call an external API +# (the audit talks to the registry, not to a service of ours). That matters +# because this repository is public and fork PRs run without secrets. # # `deploy.sh` reads this workflow's conclusion for the target SHA (see # ci_conclusion there) and will not deploy a commit whose checks failed or are @@ -60,9 +63,16 @@ jobs: restore-keys: | next-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}- + # Covers scripts/ as well as the app. It did not until lib/script-env.ts + # removed the twenty copies of `process.env.NODE_ENV = …` that forced + # the directory out of tsconfig — so the cron scripts, where every + # production incident in this repo has started, were unchecked. - name: Typecheck run: pnpm exec tsc --noEmit + - name: Test + run: pnpm test + - name: Build run: pnpm build env: @@ -79,3 +89,10 @@ jobs: run: pnpm exec tsx scripts/check-health.ts env: MIC_DATA_PATH: ${{ runner.temp }}/mic-data + + # Fails on a NEW high-severity advisory only; the ones already judged + # are listed, with reasons, in pnpm-workspace.yaml's auditConfig. The + # Next.js backlog this repo was carrying (30 advisories, 17 high, patch + # available) went unnoticed because nothing ever asked. + - name: Audit dependencies + run: pnpm audit --audit-level=high diff --git a/README.md b/README.md index 29a590e..528e86c 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,18 @@ cp .env.example .env.local pnpm dev # http://localhost:6900 ``` +The checks CI runs, and the ones `scripts/deploy.sh` re-runs on the server +before it swaps a release: + +```bash +pnpm typecheck && pnpm test && pnpm build && pnpm tsx scripts/check-health.ts && pnpm audit:deps +``` + +`pnpm typecheck` covers `scripts/` as well as the app. `pnpm audit:deps` fails only +on a *new* high-severity advisory — the ones already assessed are listed with +their reasoning in `pnpm-workspace.yaml`, and that list is the thing to re-open +on every Next.js upgrade. + ### Required env vars | Var | Purpose | Default | @@ -119,10 +131,26 @@ anything; `touch ~/alpha/.git/alpha-deploy-hold` pauses deploys (do this tick); `--force` overrides the hold, the CI gate and the failure backoff. All knobs are documented at the top of the script. -Because the poller deploys whatever reaches `main`, `main` should be -protected — PR required, at least one approval, no self-approval — before it -is switched on. Anyone with push access is otherwise one push away from -production. +Because the poller deploys whatever reaches `main`, what protects `main` +protects production. The `main` ruleset currently enforces: a PR (no direct +pushes), no deletion, no force-push, and the `checks` status check — so +nothing reaches production without CI green. It does **not** require a human +approval: `required_approving_review_count` is 0 and `require_last_push_approval` +is off, which means a merge is one click by whoever opened the PR. + +Whether to close that gap depends on how many people can merge. With more than +one maintainer, require an approval and make it a real second pair of eyes: + +```bash +gh api repos/MosslandOpenDevs/alpha/rulesets/20975561 | jq '{name,target,enforcement,conditions,rules:(.rules|map(if .type=="pull_request" then (.parameters.required_approving_review_count=1 | .parameters.require_last_push_approval=true) else . end))}' | gh api -X PUT repos/MosslandOpenDevs/alpha/rulesets/20975561 --input - +``` + +(Read-modify-write, so the other rules and the branch conditions survive; it +changes only the two approval fields.) + +With a single maintainer that setting blocks every merge, including your own, +and the honest answer is that CI is the gate — say so here rather than +documenting a control nobody turned on. Before restarting PM2 by hand, run the health smoke check: @@ -148,9 +176,45 @@ When Alpha runs behind a reverse proxy or CDN, set `TRUSTED_PROXY_HOPS` to the n The cron apps cover macro fetch, AI synthesis, daily brief, English brief translation, persona ticks, persona replies, trackable call resolution, why-moved article generation, entity connections, dynamic Q&A seeding, -IndexNow weekly ping, a weekly LLM-citation audit, and a health watchdog. -`ecosystem.config.cjs` is the list of record — `scripts/deploy.sh` reads the -app names from it rather than keeping its own copy. +IndexNow weekly ping, a weekly LLM-citation audit, a nightly verified DB +backup, and a health watchdog. `ecosystem.config.cjs` is the list of record — +`scripts/deploy.sh` reads the app names from it rather than keeping its own +copy. + +### Backups and restore + +`scripts/backup-db.ts` runs nightly at 03:00 KST. It snapshots the DB through +SQLite's own backup API (not `cp` — the DB is in WAL mode), opens the copy and +runs `PRAGMA integrity_check` on it, and, when `BACKUP_REMOTE` is set, rsyncs +it off the box. The result lands on `/health` as `db_backup`, so a backup that +stopped running, stopped verifying, or stopped leaving the host is visible +before you need it rather than after. + +**Set `BACKUP_REMOTE`.** Without it the only copies of the DB are on the same +disk as the original, and `scripts/deploy.sh`'s pre-swap snapshot has the same +problem plus a recovery point of "whenever we last deployed". Community posts, +trackable calls and audit history cannot be regenerated from anywhere. + +Run it once by hand after first deploying it — until it has run, `db_backup` +reads `fail` (correctly: no backup exists yet) and `?strict=1` answers 503: + +```bash +pnpm tsx scripts/backup-db.ts +``` + +The drill that proves a snapshot is restorable — it reads the snapshot, never +production, so it is safe to run any time: + +```bash +DB_PATH= pnpm tsx scripts/check-health.ts --live +``` + +The restore itself. The stale `-wal`/`-shm` beside the *original* must go +first, or SQLite replays them over the file you just restored: + +```bash +pm2 stop all && rm -f "$DB_PATH-wal" "$DB_PATH-shm" && cp "$DB_PATH" && pm2 start all +``` ## AI persona disclosure diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index 1fc0b1a..49d13c8 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -161,6 +161,16 @@ module.exports = { cronRestart: "0 4 * * *", note: "매일 13:00 KST — call backfill + pending resolve", }), + cronApp({ + // The DB was only ever snapshotted by scripts/deploy.sh, immediately + // before a swap — so the recovery point was "the last deploy", and both + // copies lived on the same disk. This one runs daily, verifies what it + // wrote, and pushes it off the box when BACKUP_REMOTE is set. + name: "alpha-backup-cron", + script: "scripts/backup-db.ts --scheduled", + cronRestart: "0 18 * * *", + note: "매일 03:00 KST — DB 스냅샷 + 무결성 검사 + off-host 복사", + }), cronApp({ // No --scheduled guard on purpose: this one is supposed to run at every // registration. It only reads /api/health and speaks on a state change, diff --git a/lib/calls.ts b/lib/calls.ts index 0263afa..ba4361d 100644 --- a/lib/calls.ts +++ b/lib/calls.ts @@ -3,7 +3,9 @@ * * 흐름: * 1. 페르소나/사용자가 asset entity에 stance 글 작성 - * 2. lib/calls.ts가 자동으로 call 레코드 생성 + * 2. 글과 **같은 transaction 안에서** call 레코드 생성 (lib/persona-post.ts). + * 글만 남고 call 이 없는 상태는 /agents 가 지키지 못하는 약속이므로, + * reference price 는 글을 쓰기 전에 확보한다. * - direction: agree → up, disagree → down, observe → skip * - reference_price: 글 작성 시점 가격 (lib/prices.ts — 코인이면 * CoinGecko, 지수·원자재면 Yahoo) @@ -15,7 +17,14 @@ * 4. handle별 적중률 누적 → /agents/[handle] 트랙레코드 */ +import { + createPost, + ensureCommunityTables, + type CreatePostArgs, + type Post, +} from "./community"; import { getDb } from "./db"; +import { getAssetOrStub } from "./mic"; import { currentPrice, flatPctFor, isCallableAsset, marketFor, priceOn } from "./prices"; export type Direction = "up" | "down"; @@ -138,11 +147,8 @@ function nano(): string { return crypto.randomUUID().replace(/-/g, "").slice(0, 16); } -/** - * 새 글에 대해 call 레코드 생성 (이미 있으면 skip). - * Returns null if not callable (no asset, no stance, etc.) - */ -export async function createCallFromPost(post: { +/** The subset of a post a call is built from. */ +export type CallSource = { id: string; ref_type: string; ref_id: string | null; @@ -151,39 +157,73 @@ export async function createCallFromPost(post: { author_handle: string; stance: string | null; created_at: string; -}): Promise { +}; + +/** Create the table from outside, so a caller can do it before opening a + * transaction rather than running DDL inside one. */ +export function ensureCallsTable(): void { ensureTable(); +} +/** + * Can this post carry a call, and in which direction? No network, no writes. + * + * Split out from the insert so a caller can ask the question *before* it + * spends money generating the post — lib/persona-post.ts fetches the + * reference price up front on the strength of this answer. + */ +export function callDirectionFor(post: CallSource): Direction | null { // 자산 entity여야 함 if (post.ref_type !== "asset" || !post.ref_id) return null; // 답글은 call 대상이 아니다 — 트랙레코드는 페이지에 대한 최초 판단만 센다. // (백필 쿼리에도 같은 조건이 있지만, 여기서도 막아야 호출 경로가 늘어도 안전.) if (post.parent_id) return null; // stance가 있어야 함 (observe 제외) - if (!post.stance || post.stance === "observe" || post.stance === "neutral") - return null; + if (post.stance !== "agree" && post.stance !== "disagree") return null; + // 가격 출처가 없거나, 있어도 페그 자산이면 방향성 call 이 성립하지 않는다. + // (isCallableAsset 이 두 조건을 모두 본다.) + if (!isCallableAsset(post.ref_id)) return null; + return post.stance === "agree" ? "up" : "down"; +} + +/** + * Write the call for a post whose reference price is already in hand. + * + * Synchronous on purpose. better-sqlite3 transactions cannot await, and the + * only way a stance post and its call are guaranteed to exist together is to + * write them in one — see lib/persona-post.ts. + * + * Throws on an unusable price: by the time we are here the caller has claimed + * to have one, and silently dropping the call is the failure mode this split + * exists to remove. + */ +export function insertCallForPost( + post: CallSource, + referencePrice: number +): TrackableCall | null { + ensureTable(); + const direction = callDirectionFor(post); + if (!direction) return null; + if (!Number.isFinite(referencePrice) || referencePrice <= 0) { + throw new Error( + `unusable reference price for ${post.ref_id}: ${referencePrice}` + ); + } // 이미 있으면 skip const existing = getDb() .prepare(`SELECT id FROM alpha_trackable_calls WHERE post_id = ?`) .get(post.id); if (existing) return null; - // 가격 출처가 없거나, 있어도 페그 자산이면 방향성 call 이 성립하지 않는다. - // (isCallableAsset 이 두 조건을 모두 본다.) - if (!isCallableAsset(post.ref_id)) return null; - - const price = await currentPrice(post.ref_id); - if (price == null || !Number.isFinite(price) || price <= 0) return null; - + const assetId = post.ref_id as string; // getAssetOrStub, not getEntity: getAllEntities() reads the canonical store // only, so an asset that lives as a stub (ethereum was one) resolves to null // and the published call would carry the raw id — "ethereum" where the page // says 이더리움. - const { getAssetOrStub } = await import("./mic"); - const entity = getAssetOrStub(post.ref_id); - const assetLabel = entity?.label || post.ref_id; + const entity = getAssetOrStub(assetId); + const assetLabel = entity?.label || assetId; - const direction: Direction = post.stance === "agree" ? "up" : "down"; + const price = referencePrice; const refDate = new Date(post.created_at); const targetDate = new Date(refDate.getTime() + DEFAULT_HORIZON_DAYS * 86400_000); @@ -192,14 +232,14 @@ export async function createCallFromPost(post: { post_id: post.id, author_kind: post.author_kind === "agent" ? "agent" : "anonymous", author_handle: post.author_handle, - asset_id: post.ref_id, + asset_id: assetId, asset_label: assetLabel, direction, horizon_days: DEFAULT_HORIZON_DAYS, reference_price: price, reference_date: refDate.toISOString(), target_date: targetDate.toISOString(), - flat_pct: flatPctFor(post.ref_id), + flat_pct: flatPctFor(assetId), resolution_status: "pending", resolution_price: null, resolved_at: null, @@ -239,6 +279,52 @@ export async function createCallFromPost(post: { return call; } +/** + * 글과 call 을 한 transaction 으로 쓴다 — 트랙레코드의 유일한 무결성 보장. + * + * A stance on a priceable asset IS a call: /agents publishes the record built + * from them, so a post that exists without its call is a promise the site + * cannot keep. Pass the reference price you already fetched (see + * lib/persona-post.ts, which fetches it before the model call) and both rows + * land together or neither does. + * + * `referencePrice` null means "this was never going to carry a call" — an + * unpriceable page, a dry stance — and the post is written on its own. + * insertCallForPost re-checks stance, asset and duplication, so the caller + * does not repeat those conditions and they cannot drift apart. + */ +export function createPostWithCall( + args: CreatePostArgs, + referencePrice: number | null +): Post { + ensureCommunityTables(); + ensureTable(); + return getDb().transaction(() => { + const post = createPost(args); + if (referencePrice != null) insertCallForPost(post, referencePrice); + return post; + })(); +} + +/** + * 새 글에 대해 call 레코드 생성 (이미 있으면 skip). + * Returns null if not callable (no asset, no stance, no price, …). + * + * The price is fetched here, so post and call cannot be written together. + * That is fine for the backfill path (scripts/track-calls.ts), which is + * recovering posts that already exist. A writer creating the post right now + * should pre-fetch the price and use insertCallForPost() inside its own + * transaction instead. + */ +export async function createCallFromPost( + post: CallSource +): Promise { + if (!callDirectionFor(post)) return null; + const price = await currentPrice(post.ref_id as string); + if (price == null || !Number.isFinite(price) || price <= 0) return null; + return insertCallForPost(post, price); +} + /** target_date 도달한 pending call resolve. */ export async function resolveCall(callId: string): Promise { ensureTable(); diff --git a/lib/health.ts b/lib/health.ts index ab11ea9..00ac5dd 100644 --- a/lib/health.ts +++ b/lib/health.ts @@ -175,11 +175,14 @@ export function getCostBudget(): CostBudget { /** * LLM citation audit — an outcome, not a subsystem. * - * Deliberately kept out of `subsystems`: those measure "is the pipeline - * running", and this one runs perfectly while reporting 0%. Folding a content - * result into a liveness roll-up would either page someone weekly over - * something no restart fixes, or get ignored — and `?strict=1` is wired to - * uptime monitors. It is reported beside them instead. + * The citation RATE is deliberately kept out of `subsystems`: those measure + * "is the pipeline running", and this one runs perfectly while reporting 0%. + * Folding a content result into a liveness roll-up would either page someone + * weekly over something no restart fixes, or get ignored — and `?strict=1` is + * wired to uptime monitors. It is reported beside them instead. + * + * Whether the cron RAN is a different question, and that one does belong in + * the roll-up: it is there as the `audit_cron` subsystem below. */ export type AuditSummary = { runs: AuditRun[]; @@ -332,6 +335,8 @@ export function getSystemHealth(): { const ONE_HOUR = 3600; const ONE_DAY = 24 * ONE_HOUR; + const audit = getAuditSummary(); + const subsystems: SubsystemHealth[] = [ toSubsystem({ key: "signalmap_canonical", @@ -514,6 +519,49 @@ export function getSystemHealth(): { failAfterSec: CONTENT_FAIL_SEC, }); })(), + (() => { + // A backup nobody checks is a backup nobody has. The heartbeat carries + // the verification result and whether the copy left the box, so a + // snapshot that stopped verifying — or stopped being pushed off-host — + // shows up here rather than at restore time. + const hb = getHeartbeat("alpha-backup-cron"); + const sub = toSubsystem({ + key: "db_backup", + label: "DB 백업 (검증 + off-host)", + cadence: "매일 03:00 KST cron", + lastAt: hb?.lastRunAt ?? null, + warnAfterSec: 28 * ONE_HOUR, + failAfterSec: 50 * ONE_HOUR, + note: hb + ? `마지막 실행 ${hb.lastStatus}. ${hb.lastNote ?? ""}`.trim() + : "heartbeat 없음 — cron 첫 실행 대기 중.", + }); + return applyHeartbeatFailure(sub, hb); + })(), + (() => { + // Liveness of the weekly audit cron — deliberately NOT its citation + // rate. AuditSummary explains why the rate stays out of the roll-up: it + // is a content outcome, no restart fixes a 0%, and ?strict=1 is wired to + // uptime monitors. But "did the weekly job run" is the same question + // every other row here asks, and leaving the whole audit out is why a + // 94-day stall (last run 2026-05-18, seen 2026-08-20) never paged + // anyone. The rate keeps its own place beside the subsystems. + const hb = getHeartbeat("alpha-audit-cron"); + const sub = toSubsystem({ + key: "audit_cron", + label: "LLM citation audit cron (실행 여부)", + cadence: "매주 월요일 11:00 KST", + lastAt: hb?.lastRunAt ?? (audit.latest ? `${audit.latest.date}T02:00:00Z` : null), + latestDate: audit.latest?.date ?? null, + // Weekly: one missed Monday is a warn, two is a fail. + warnAfterSec: 9 * ONE_DAY, + failAfterSec: 16 * ONE_DAY, + note: hb + ? `cron 마지막 실행 ${hb.lastStatus}. ${hb.lastNote ?? ""}`.trim() + : "heartbeat 없음 — cron 첫 실행 대기 중.", + }); + return applyHeartbeatFailure(sub, hb); + })(), ]; // Severity order, worst first — the first status any subsystem reports wins. @@ -528,7 +576,7 @@ export function getSystemHealth(): { worstStatus: worst, subsystems, costBudget, - audit: getAuditSummary(), + audit, }; } diff --git a/lib/persona-post.ts b/lib/persona-post.ts index cbc74fd..979bb15 100644 --- a/lib/persona-post.ts +++ b/lib/persona-post.ts @@ -30,7 +30,8 @@ import { } from "./mic"; import { getSynthesis } from "./synthesis"; import { kstClock } from "./kst"; -import { isCallableAsset } from "./prices"; +import { currentPrice, isCallableAsset } from "./prices"; +import { createPostWithCall } from "./calls"; // v2 (2026-08-19): the model now sees the page's recent videos, never a bare // "영상 N편" count, and the persona system prompt is sent once. Bumped so the @@ -510,11 +511,35 @@ export async function generatePersonaPost(args: { stance: string | null; }[]; + const priceable = args.refType === "asset" && isCallableAsset(args.refId); + + // The reference price is fetched HERE — before the model call, before the + // post exists. Two things follow from that order, and both are the point: + // + // - post and call can be written in one transaction below, so a stance on + // a priceable asset either publishes *with* its call or not at all. The + // old order wrote the post, then fetched a price, then inserted the call + // inside a `catch {}` that swallowed everything — a CoinGecko blip left + // a published call that /agents can never show. + // - a price outage costs zero tokens instead of a paid generation that + // ends up half-recorded. + let referencePrice: number | null = null; + if (priceable) { + referencePrice = await currentPrice(args.refId); + if ( + referencePrice == null || + !Number.isFinite(referencePrice) || + referencePrice <= 0 + ) { + return { ok: false, reason: "no_reference_price" }; + } + } + const prompt = buildPrompt({ agent, refLabel, refType: args.refType, - priceable: args.refType === "asset" && isCallableAsset(args.refId), + priceable, pageContext, videoLines, topComments, @@ -571,25 +596,20 @@ export async function generatePersonaPost(args: { }; } - const post = createPost({ - refType: args.refType, - refId: args.refId, - body, - stance, - authorKind: "agent", - authorToken: `agent:${args.handle}`, - authorHandle: `@${args.handle}`, - }); - - // 트랙레코드: asset entity stance 글이면 자동 call 레코드 생성 (실패 무시) - if (post.ref_type === "asset" && stance && stance !== "observe") { - try { - const { createCallFromPost } = await import("./calls"); - await createCallFromPost(post); - } catch { - // call creation 실패는 post 작성과 무관하게 무시 - } - } + // 트랙레코드: 글과 call 을 한 transaction 으로 (lib/calls.ts). 실패하면 + // 글도 남지 않는다 — 그래야 tick 이 다음 후보로 넘어간다. + const post = createPostWithCall( + { + refType: args.refType, + refId: args.refId, + body, + stance, + authorKind: "agent", + authorToken: `agent:${args.handle}`, + authorHandle: `@${args.handle}`, + }, + referencePrice + ); return { ok: true, post, costUsd: result.costUsd }; } diff --git a/lib/script-env.ts b/lib/script-env.ts new file mode 100644 index 0000000..9e91739 --- /dev/null +++ b/lib/script-env.ts @@ -0,0 +1,58 @@ +/** + * What a cron script does before it touches anything else. + * + * Twenty scripts under scripts/ carried their own byte-identical copy of this + * (two of them subtly different, which is how copies go). One copy means one + * place to fix when the rule changes — and, less obviously, it is what let + * `tsc --noEmit` start covering scripts/ at all: every copy assigned to + * `process.env.NODE_ENV`, which Next declares readonly, so the whole + * directory had to stay out of the typecheck. Twenty-four cron scripts — + * where every production incident in this repo has originated — were + * therefore never typechecked by CI. + * + * Call it at module top level, before the dynamic `await import("../lib/…")` + * that every script uses. Static imports are hoisted, so a static import of + * this module still runs before those. + */ + +import fs from "node:fs"; +import path from "node:path"; + +/** Read `KEY=value` lines into process.env. Existing values win, so a real + * environment variable always beats the file. */ +function loadEnvFile(file: string): void { + if (!fs.existsSync(file)) return; + for (const line of fs.readFileSync(file, "utf8").split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq < 0) continue; + const key = trimmed.slice(0, eq).trim(); + if (!process.env[key]) process.env[key] = trimmed.slice(eq + 1).trim(); + } +} + +/** + * Default NODE_ENV, for a script that manages its own environment otherwise + * (scripts/check-health.ts builds a throwaway DB and must not read .env). + * + * The cast is not a shortcut — `next/types/global.d.ts` declares + * ProcessEnv.NODE_ENV readonly for application code, which this is not. It + * lives here so there is exactly one of it to find. + */ +export function defaultNodeEnv(value = "production"): void { + const env = process.env as Record; + env.NODE_ENV = env.NODE_ENV || value; +} + +/** + * `.env.local`, then `.env`, then default NODE_ENV to production. + * + * The NODE_ENV default is for hand-run invocations (`pnpm tsx scripts/…`); + * the pm2 apps already set it (ecosystem.config.cjs). + */ +export function loadScriptEnv(): void { + loadEnvFile(path.join(process.cwd(), ".env.local")); + loadEnvFile(path.join(process.cwd(), ".env")); + defaultNodeEnv(); +} diff --git a/package.json b/package.json index 9507021..ada9289 100644 --- a/package.json +++ b/package.json @@ -6,12 +6,14 @@ "scripts": { "dev": "next dev -p 6900", "build": "next build", - "start": "next start -p 6900" + "start": "next start -p 6900", + "typecheck": "tsc --noEmit", + "test": "node --import tsx --test tests/*.test.ts", + "audit:deps": "pnpm audit --audit-level=high" }, "dependencies": { "better-sqlite3": "^12.9.0", - "nanoid": "^5.1.11", - "next": "16.2.4", + "next": "16.2.12", "react": "19.2.4", "react-dom": "19.2.4" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d09e29a..473229d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,12 +11,9 @@ importers: better-sqlite3: specifier: ^12.9.0 version: 12.9.0 - nanoid: - specifier: ^5.1.11 - version: 5.1.11 next: - specifier: 16.2.4 - version: 16.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: 16.2.12 + version: 16.2.12(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: specifier: 19.2.4 version: 19.2.4 @@ -383,57 +380,57 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@next/env@16.2.4': - resolution: {integrity: sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw==} + '@next/env@16.2.12': + resolution: {integrity: sha512-d0Z5Bc13Fa4nR8pFAKx2jay2yhJM16vlfHbTzYnUQAxlNb6B6lmn4hjt69lYNt4kRtyYP6gEM49lPRHNbIyneg==} - '@next/swc-darwin-arm64@16.2.4': - resolution: {integrity: sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A==} + '@next/swc-darwin-arm64@16.2.12': + resolution: {integrity: sha512-0W1R0teHWJrqKX0FH20IzzIWAOuGtBxPGuObrxy1lE8hQvCFj49KE8a3WUg0D7sq6rn6zkM4c7YGUnhudBS6oA==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.2.4': - resolution: {integrity: sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ==} + '@next/swc-darwin-x64@16.2.12': + resolution: {integrity: sha512-Hy5Ls099+aFUmOLmIgPfLqNi6iCwhL3uQCssz5rWk+5Nkc6TUKCE83DY5BbNylfm3+mfwcSFnLRfrZDJhVxdtw==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.2.4': - resolution: {integrity: sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ==} + '@next/swc-linux-arm64-gnu@16.2.12': + resolution: {integrity: sha512-+YqU2h1cQkHsGfvjAsrSmst8UIFBibBGm5x3Xgel8NLMiDQtNOM4sM2GOEMvG5YiOBNeN/Ykk8cQC2S0Xrqljg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@16.2.4': - resolution: {integrity: sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==} + '@next/swc-linux-arm64-musl@16.2.12': + resolution: {integrity: sha512-0qjhiYBaKAqF63LA1ZWAAnKTzFUguAaZiRa5etMLGGPj/B6uEVjtIZldIzFEp3wHlB0koK6aTzqPtSdplTCjoA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@16.2.4': - resolution: {integrity: sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==} + '@next/swc-linux-x64-gnu@16.2.12': + resolution: {integrity: sha512-7A3q26W+h7gnA15uqBToNuDqBEFZZcqh0mW2mn4AJh/G5pdg2RVE3n4slzLEliASZFG3NmsbEzng/x2Sh09mBg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@16.2.4': - resolution: {integrity: sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==} + '@next/swc-linux-x64-musl@16.2.12': + resolution: {integrity: sha512-qSjL/uppm+cbh21s72Ss8gkiOhQ4dExWHNGOWy6eZV7STj5WsKehgxT61beSsOj+YYQuTplL376lOCdMQU5T8w==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@16.2.4': - resolution: {integrity: sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==} + '@next/swc-win32-arm64-msvc@16.2.12': + resolution: {integrity: sha512-X6hzsOUJac/e7AWSbn9gQ9nzHld1xWP5iyjHpYWvud8pufB679O1xg4JDyKr8Xd69Jvd+kM2Der6uftiZCmjYA==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.2.4': - resolution: {integrity: sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==} + '@next/swc-win32-x64-msvc@16.2.12': + resolution: {integrity: sha512-F6fakeHuFTLOPt0bslQJdf+xtT+WIP9DVn/m4y1w1mRnVPyh3D/cNvzlRkxM444xfm+IvvYNSOrKiA2CDJ0Uxw==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -733,16 +730,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - nanoid@5.1.11: - resolution: {integrity: sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==} - engines: {node: ^18 || >=20} - hasBin: true - napi-build-utils@2.0.0: resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} - next@16.2.4: - resolution: {integrity: sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==} + next@16.2.12: + resolution: {integrity: sha512-iD59eYQWmbFcEbX7v/acG5DRym9iw1DdaPoD0WTA920naWsE25wShzJW4+UvAs8MK9EC2kBfIH6vtto1H1PHGw==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -1096,30 +1088,30 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@next/env@16.2.4': {} + '@next/env@16.2.12': {} - '@next/swc-darwin-arm64@16.2.4': + '@next/swc-darwin-arm64@16.2.12': optional: true - '@next/swc-darwin-x64@16.2.4': + '@next/swc-darwin-x64@16.2.12': optional: true - '@next/swc-linux-arm64-gnu@16.2.4': + '@next/swc-linux-arm64-gnu@16.2.12': optional: true - '@next/swc-linux-arm64-musl@16.2.4': + '@next/swc-linux-arm64-musl@16.2.12': optional: true - '@next/swc-linux-x64-gnu@16.2.4': + '@next/swc-linux-x64-gnu@16.2.12': optional: true - '@next/swc-linux-x64-musl@16.2.4': + '@next/swc-linux-x64-musl@16.2.12': optional: true - '@next/swc-win32-arm64-msvc@16.2.4': + '@next/swc-win32-arm64-msvc@16.2.12': optional: true - '@next/swc-win32-x64-msvc@16.2.4': + '@next/swc-win32-x64-msvc@16.2.12': optional: true '@swc/helpers@0.5.15': @@ -1375,13 +1367,11 @@ snapshots: nanoid@3.3.12: {} - nanoid@5.1.11: {} - napi-build-utils@2.0.0: {} - next@16.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next@16.2.12(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: - '@next/env': 16.2.4 + '@next/env': 16.2.12 '@swc/helpers': 0.5.15 baseline-browser-mapping: 2.10.27 caniuse-lite: 1.0.30001791 @@ -1390,14 +1380,14 @@ snapshots: react-dom: 19.2.4(react@19.2.4) styled-jsx: 5.1.6(react@19.2.4) optionalDependencies: - '@next/swc-darwin-arm64': 16.2.4 - '@next/swc-darwin-x64': 16.2.4 - '@next/swc-linux-arm64-gnu': 16.2.4 - '@next/swc-linux-arm64-musl': 16.2.4 - '@next/swc-linux-x64-gnu': 16.2.4 - '@next/swc-linux-x64-musl': 16.2.4 - '@next/swc-win32-arm64-msvc': 16.2.4 - '@next/swc-win32-x64-msvc': 16.2.4 + '@next/swc-darwin-arm64': 16.2.12 + '@next/swc-darwin-x64': 16.2.12 + '@next/swc-linux-arm64-gnu': 16.2.12 + '@next/swc-linux-arm64-musl': 16.2.12 + '@next/swc-linux-x64-gnu': 16.2.12 + '@next/swc-linux-x64-musl': 16.2.12 + '@next/swc-win32-arm64-msvc': 16.2.12 + '@next/swc-win32-x64-msvc': 16.2.12 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 709deee..2243c05 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -11,3 +11,32 @@ allowBuilds: "better-sqlite3@12.9.0": true # native SQLite binding — required at runtime "esbuild@0.27.7": true # via tsx, used by every cron script "sharp@0.34.5": true # next/image optional dep; prebuilt @img binaries + +# `pnpm audit --audit-level=high` runs in CI (.github/workflows/ci.yml). +# Not `--prod`: devDependencies run in production here — every cron app is +# `./node_modules/.bin/tsx`, so the dev tree is part of the runtime surface. +# The point is to catch a NEW advisory, so the ones we have already judged are +# listed here with the reason — an unexplained ignore is how the Next.js +# backlog that prompted this got missed in the first place. +# +# All four packages below are pinned inside `next` itself and cannot be moved +# without overriding what Next ships and tests against. Re-check on every Next +# upgrade: `pnpm audit` with this list removed. +auditConfig: + ignoreGhsas: + # postcss 8.4.31, pinned exactly by next. Every one of these needs + # attacker-controlled CSS or a sourceMappingURL in it; the only CSS this + # build compiles is our own Tailwind input. + - GHSA-qx2v-qp2m-jg93 # XSS via unescaped when stringifying CSS + - GHSA-6g55-p6wh-862q # file read via attacker-controlled sourceMappingURL + - GHSA-r28c-9q8g-f849 # path traversal in previous-source-map auto-loading + - GHSA-fxqj-rqcc-2cmp # incomplete fix of GHSA-6g55-p6wh-862q + # nanoid 3.3.x, a dependency of that same postcss. Reached only by + # postcss's own id generation, never with a caller-supplied size. + - GHSA-28wg-ghj8-5hjv # non-secure generator loops on a negative size + - GHSA-2v37-7h3g-55p8 # custom generator loops when size is zero + # sharp 0.34.5 — next/image's optional encoder. This app imports + # next/image nowhere and configures no `images.remotePatterns`, so + # /_next/image accepts same-origin paths only and no attacker-supplied + # bytes reach libvips. + - GHSA-f88m-g3jw-g9cj # inherited libvips CVEs diff --git a/scripts/apply-why-moved-kst-repair.ts b/scripts/apply-why-moved-kst-repair.ts index dc0955c..b93a6a2 100644 --- a/scripts/apply-why-moved-kst-repair.ts +++ b/scripts/apply-why-moved-kst-repair.ts @@ -51,23 +51,11 @@ import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; +import { loadScriptEnv } from "../lib/script-env"; import Database from "better-sqlite3"; -function loadEnvFile(file: string): void { - if (!fs.existsSync(file)) return; - for (const line of fs.readFileSync(file, "utf8").split("\n")) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) continue; - const separator = trimmed.indexOf("="); - if (separator < 0) continue; - const key = trimmed.slice(0, separator).trim(); - const value = trimmed.slice(separator + 1).trim(); - if (!process.env[key]) process.env[key] = value; - } -} -loadEnvFile(path.join(process.cwd(), ".env.local")); -loadEnvFile(path.join(process.cwd(), ".env")); +loadScriptEnv(); type WhyMovedRow = { asset: string; diff --git a/scripts/audit-auto.ts b/scripts/audit-auto.ts index ad87483..b25671b 100644 --- a/scripts/audit-auto.ts +++ b/scripts/audit-auto.ts @@ -18,22 +18,9 @@ import fs from "node:fs"; import path from "node:path"; +import { loadScriptEnv } from "../lib/script-env"; -function loadEnvFile(file: string) { - if (!fs.existsSync(file)) return; - const text = fs.readFileSync(file, "utf8"); - for (const line of text.split("\n")) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) continue; - const eq = trimmed.indexOf("="); - if (eq < 0) continue; - const key = trimmed.slice(0, eq).trim(); - const val = trimmed.slice(eq + 1).trim(); - if (!process.env[key]) process.env[key] = val; - } -} -loadEnvFile(path.join(process.cwd(), ".env.local")); -loadEnvFile(path.join(process.cwd(), ".env")); +loadScriptEnv(); type Query = { id: string; @@ -197,10 +184,15 @@ async function main() { existing = JSON.parse(fs.readFileSync(outFile, "utf8")); } + // An errored entry is not an answer. The checkpoint below writes 429s, + // timeouts and 5xx to the same file as real results, and this set used to + // count them as done — so the --scheduled re-run skipped precisely the + // queries that had failed, and a query that errored once was never asked + // again. Resume past the answers we have; retry everything else. const completedQueryIds = scheduled ? new Set( existing - .filter((result) => result.llm === "openai") + .filter((result) => result.llm === "openai" && !result.error) .map((result) => result.query_id) ) : new Set(); @@ -210,6 +202,16 @@ async function main() { (!queryFilter || q.id === queryFilter) && !completedQueryIds.has(q.id) ).slice(0, limit); + // Drop the failed attempts for the queries we are about to re-ask, so the + // day's file does not end up holding both an error and an answer for the + // same query. Only the errors: a successful answer being re-asked (a + // hand-run `--limit=3` over a finished day) is a second sample and the + // summary is built to count it as one — see `answers` below. + const retrying = new Set(queries.map((q) => q.id)); + existing = existing.filter( + (result) => !(retrying.has(result.query_id) && result.error) + ); + if (scheduled && queries.length === 0) { console.log( `Scheduled audit already complete for ${clock.date}; nothing to do.` @@ -337,7 +339,17 @@ async function main() { errors, }); console.log(`Recorded summary for ${clock.date} (alpha_audit_runs): ${summary}`); - recordHeartbeat("alpha-audit-cron", errors > 0 ? "error" : "ok", summary); + // `ok` with some errors, not `error`. lib/health.ts now reads this heartbeat + // as the audit_cron subsystem, and a fail there answers 503 on + // /api/health?strict=1 — one gpt-4o 429 out of thirty must not do that. The + // failed queries are retried on the next run (see `retrying` above), and the + // count stays visible in alpha_audit_runs.errors and on /health. "Every + // answer errored" is still an error; that case returns above. + recordHeartbeat( + "alpha-audit-cron", + "ok", + errors > 0 ? `${summary} (실패 ${errors}건은 다음 실행에서 재시도)` : summary + ); } main().catch((err) => { diff --git a/scripts/audit-why-moved-kst.ts b/scripts/audit-why-moved-kst.ts index a7ddde9..72dfa98 100644 --- a/scripts/audit-why-moved-kst.ts +++ b/scripts/audit-why-moved-kst.ts @@ -12,23 +12,9 @@ import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; +import { loadScriptEnv } from "../lib/script-env"; -function loadEnvFile(file: string) { - if (!fs.existsSync(file)) return; - const text = fs.readFileSync(file, "utf8"); - for (const line of text.split("\n")) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) continue; - const eq = trimmed.indexOf("="); - if (eq < 0) continue; - const key = trimmed.slice(0, eq).trim(); - const val = trimmed.slice(eq + 1).trim(); - if (!process.env[key]) process.env[key] = val; - } -} - -loadEnvFile(path.join(process.cwd(), ".env.local")); -loadEnvFile(path.join(process.cwd(), ".env")); +loadScriptEnv(); type WhyMovedRow = { asset: string; diff --git a/scripts/backfill-audit-runs.ts b/scripts/backfill-audit-runs.ts index b948095..ff32051 100644 --- a/scripts/backfill-audit-runs.ts +++ b/scripts/backfill-audit-runs.ts @@ -17,21 +17,9 @@ import fs from "node:fs"; import path from "node:path"; +import { loadScriptEnv } from "../lib/script-env"; -function loadEnvFile(file: string) { - if (!fs.existsSync(file)) return; - for (const line of fs.readFileSync(file, "utf8").split("\n")) { - const t = line.trim(); - if (!t || t.startsWith("#")) continue; - const eq = t.indexOf("="); - if (eq < 0) continue; - const k = t.slice(0, eq).trim(); - if (!process.env[k]) process.env[k] = t.slice(eq + 1).trim(); - } -} -loadEnvFile(path.join(process.cwd(), ".env.local")); -loadEnvFile(path.join(process.cwd(), ".env")); -process.env.NODE_ENV = process.env.NODE_ENV || "production"; +loadScriptEnv(); type Row = { query_id: string; diff --git a/scripts/backup-db.ts b/scripts/backup-db.ts new file mode 100644 index 0000000..945d76e --- /dev/null +++ b/scripts/backup-db.ts @@ -0,0 +1,213 @@ +/** + * 일일 DB 백업 — 스냅샷 → 무결성 검사 → (선택) 호스트 밖으로 복사. + * + * 왜 별도 cron 인가. 지금까지 DB 백업은 scripts/deploy.sh 의 swap 직전에만 + * 찍혔다. 그러면 두 가지가 따라온다: + * + * - RPO 가 배포 주기다. 하루 배포가 없으면 하루치가, 일주일 없으면 일주일치가 + * 보호되지 않는다. 손실 가능한 데이터는 커뮤니티 글·call·audit 기록처럼 + * 재생성이 불가능한 것들이다. + * - 사본이 원본과 같은 디스크에 있다. `~/backups/alpha` 는 호스트가 + * 사라지면 원본과 함께 사라진다. 그건 백업이 아니라 실행 취소다. + * + * 그래서 이 스크립트는 매일 돌고, 사본을 *검사하고*, `BACKUP_REMOTE` 가 + * 설정돼 있으면 호스트 밖으로 민다. 검사가 중요한 이유: 복원할 수 없는 백업이 + * 있다는 사실은 복원할 때 알게 되며, 그때는 이미 늦다. + * + * 환경변수: + * BACKUP_DIR 스냅샷 위치 (기본 ~/backups/alpha — deploy.sh 와 같은 곳) + * BACKUP_KEEP 보관 개수 (기본 14) + * BACKUP_REMOTE rsync 목적지. 예) user@host:/srv/alpha-backups/ + * 비어 있으면 로컬 스냅샷만 — off-host 아님을 로그가 말한다. + * BACKUP_RSYNC_BIN rsync 실행 파일 (기본 rsync) + * + * 사용법: + * pnpm tsx scripts/backup-db.ts # 지금 한 번 + * pnpm tsx scripts/backup-db.ts --scheduled # 03시 KST 에만 (pm2 cron) + * + * 복원: + * pm2 stop all + * rm -f "$DB_PATH-wal" "$DB_PATH-shm" && cp "$DB_PATH" + * pm2 start all + * 스냅샷 한 파일에 그 시점의 내용이 전부 들어 있다. 원본의 낡은 -wal/-shm 이 + * 남아 있으면 복원한 파일 위에 그게 다시 적용되므로 먼저 지운다. + * + * pm2 cron: 매일 18:00 UTC = 03:00 KST. + */ + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { loadScriptEnv } from "../lib/script-env"; + +loadScriptEnv(); + +const execFileAsync = promisify(execFile); + +/** Intended KST hour of the pm2 cron. */ +const SCHEDULED_KST_HOUR = 3; + +function backupDir(): string { + return path.resolve( + process.env.BACKUP_DIR || path.join(os.homedir(), "backups", "alpha") + ); +} + +/** Snapshot the live DB through better-sqlite3's own backup API. + * + * Not `cp`: the DB runs in WAL mode, so the .sqlite file on its own is a + * torn read — the recent writes live in -wal until a checkpoint. `.backup()` + * produces one finished, self-contained file. deploy.sh does the same thing + * inline; this is the same call with a verification step after it. */ +async function snapshot(src: string, dest: string): Promise { + const Database = (await import("better-sqlite3")).default; + const db = new Database(src, { readonly: true }); + try { + await db.backup(dest); + } finally { + db.close(); + } +} + +/** Would this file actually restore? `PRAGMA integrity_check` walks the whole + * b-tree, and the row count is a second opinion that the pages it walked hold + * the data we meant to keep. */ +async function verify(file: string): Promise<{ ok: boolean; note: string }> { + const Database = (await import("better-sqlite3")).default; + const db = new Database(file, { readonly: true }); + try { + const check = db.pragma("integrity_check", { simple: true }); + if (check !== "ok") return { ok: false, note: `integrity_check=${check}` }; + const posts = ( + db.prepare(`SELECT COUNT(*) AS n FROM alpha_posts`).get() as { n: number } + ).n; + return { ok: true, note: `integrity ok, posts=${posts}` }; + } catch (err) { + return { ok: false, note: `열 수 없음: ${(err as Error).message}` }; + } finally { + db.close(); + } +} + +/** A snapshot is three files, not one: the copy inherits the source's WAL + * mode, so opening it to verify leaves a -wal and a -shm beside it. Remove + * the set, or a discarded snapshot leaves debris that looks like a snapshot. */ +function removeSnapshot(file: string): void { + for (const f of [file, `${file}-wal`, `${file}-shm`]) { + fs.rmSync(f, { force: true }); + } +} + +/** Keep the newest N snapshots. Every run adds a full copy of the DB. */ +function prune(dir: string, keep: number): number { + const files = fs + .readdirSync(dir) + .filter((f) => f.startsWith("alpha-daily-") && f.endsWith(".sqlite")) + .map((f) => ({ f, t: fs.statSync(path.join(dir, f)).mtimeMs })) + .sort((a, b) => b.t - a.t); + let removed = 0; + for (const { f } of files.slice(keep)) { + removeSnapshot(path.join(dir, f)); + removed++; + } + return removed; +} + +async function main() { + const args = process.argv.slice(2); + const { scheduledSkipReason } = await import("../lib/kst"); + const skip = scheduledSkipReason(args, SCHEDULED_KST_HOUR); + if (skip) { + console.log(skip); + return; + } + + const { recordHeartbeat } = await import("../lib/cron-heartbeat"); + const src = process.env.DB_PATH; + if (!src || !fs.existsSync(src)) { + const note = `DB_PATH 없음 또는 파일 없음: ${src ?? "(unset)"}`; + console.error(note); + recordHeartbeat("alpha-backup-cron", "error", note); + process.exitCode = 1; + return; + } + + const dir = backupDir(); + const keep = Number(process.env.BACKUP_KEEP || "14"); + if (!Number.isInteger(keep) || keep < 1) { + throw new Error("BACKUP_KEEP must be a positive integer"); + } + const remote = process.env.BACKUP_REMOTE || ""; + + fs.mkdirSync(dir, { recursive: true }); + const stamp = new Date().toISOString().replace(/[-:]/g, "").slice(0, 15) + "Z"; + const dest = path.join(dir, `alpha-daily-${stamp}.sqlite`); + + try { + await snapshot(src, dest); + } catch (err) { + const note = `스냅샷 실패: ${(err as Error).message}`; + console.error(note); + removeSnapshot(dest); + recordHeartbeat("alpha-backup-cron", "error", note); + process.exitCode = 1; + return; + } + + const check = await verify(dest); + if (!check.ok) { + // A snapshot that does not verify is worse than none: it would be trusted. + const note = `검증 실패 — ${check.note}`; + console.error(note); + removeSnapshot(dest); + recordHeartbeat("alpha-backup-cron", "error", note); + process.exitCode = 1; + return; + } + + // Drop the -wal/-shm that verifying just created, so what is retained (and + // rsynced) is the single self-contained file the restore note describes. + fs.rmSync(`${dest}-wal`, { force: true }); + fs.rmSync(`${dest}-shm`, { force: true }); + + const sizeMb = (fs.statSync(dest).size / 1024 / 1024).toFixed(1); + console.log(`snapshot ${path.basename(dest)} (${sizeMb}MB) — ${check.note}`); + + // The off-host leg. Without it everything above is one disk failure away + // from nothing, so a configured remote that refuses is an error, not a + // warning — the local copy still exists, but the box is a single point of + // failure again and someone has to know. + let offHost = "off-host 미설정 (BACKUP_REMOTE)"; + let status: "ok" | "error" = "ok"; + if (remote) { + const rsync = process.env.BACKUP_RSYNC_BIN || "rsync"; + try { + await execFileAsync(rsync, ["-a", "--", dest, remote], { + timeout: 15 * 60_000, + }); + offHost = `off-host 복사 완료 → ${remote}`; + console.log(offHost); + } catch (err) { + offHost = `off-host 복사 실패 → ${remote}: ${(err as Error).message.slice(0, 200)}`; + console.error(offHost); + status = "error"; + } + } else { + console.warn( + `⚠ ${offHost} — 사본이 원본과 같은 호스트에 있습니다. 호스트 손실 시 둘 다 사라집니다.` + ); + } + + const removed = prune(dir, keep); + const note = `${path.basename(dest)} ${sizeMb}MB · ${check.note} · ${offHost} · 정리 ${removed}건 (보관 ${keep})`; + console.log(`Heartbeat: ${status} — ${note}`); + recordHeartbeat("alpha-backup-cron", status, note); + if (status === "error") process.exitCode = 1; +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/check-health.ts b/scripts/check-health.ts index be4e314..08c110e 100644 --- a/scripts/check-health.ts +++ b/scripts/check-health.ts @@ -19,6 +19,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { defaultNodeEnv } from "../lib/script-env"; async function main() { const live = process.argv.includes("--live"); @@ -33,7 +34,7 @@ async function main() { console.error("DB_PATH 가 필요합니다."); process.exit(2); } - process.env.NODE_ENV = process.env.NODE_ENV || "production"; + defaultNodeEnv(); try { if (!live) { diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 9ac40ec..ef04bea 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -246,7 +246,8 @@ strict_ok() { return 1 } -# Register the 13 alpha apps from a release directory. `pm2 delete` first: +# Register the release directory's alpha apps (the ecosystem is the list of +# record; do not hard-code a count here — it has changed four times). `pm2 delete` first: # `pm2 start ecosystem.config.cjs` on already-registered names would keep the # OLD cwd/script path and only restart. Only the release's own apps are # touched — never the poller, never another project's. @@ -256,6 +257,9 @@ strict_ok() { # the directory discard_release removes seconds later, and persisted by # `pm2 save`. Carrying the forward swap's set into the rollback closes that. SWAP_APPS="" +# Cleared by a `pm2 save` that would not take. While it is 0, the persisted +# process list names a release directory we must not delete. +PM2_SAVE_OK=1 pm2_swap_to() { local dir="$1" app apps @@ -275,11 +279,23 @@ pm2_swap_to() { esac done # pm2 merges the caller's process.env into each app's stored env. Do not - # let the poller's GITHUB_TOKEN / DEPLOY_* leak into the 13 apps. + # let the poller's GITHUB_TOKEN / DEPLOY_* leak into the cron apps. ( cd "${dir}" && env -u GITHUB_TOKEN -u DEPLOY_ALERT_WEBHOOK -u DEPLOY_REQUIRE_CI \ -u DEPLOY_INTERVAL_SEC "${PM2_BIN}" start ecosystem.config.cjs >/dev/null 2>&1 ) \ || { log "ERROR pm2 start from ${dir} failed"; return 1; } - "${PM2_BIN}" save >/dev/null 2>&1 || log "WARN pm2 save failed" + # `pm2 save` rewrites ~/.pm2/dump.pm2, which is what `pm2 resurrect` reads on + # boot. If it fails the dump still names the PREVIOUS release directory, and + # prune_releases is free to delete that directory a few seconds later — so + # the next reboot resurrects nothing and the box comes up serving nothing. + # A failure here does not mean the deploy failed (the new release is up and + # will pass health_ok), so it must not be recorded as one; it means the + # persisted state is stale, which is a different, louder problem. Retry once, + # then say so and let the caller skip the prune. + if ! "${PM2_BIN}" save >/dev/null 2>&1 && ! "${PM2_BIN}" save >/dev/null 2>&1; then + PM2_SAVE_OK=0 + log "CRITICAL pm2 save failed twice -- ~/.pm2/dump.pm2 still points at the old release; a reboot would not come back. Run 'pm2 save' by hand." + alert "alpha CRITICAL: pm2 save failed during the swap to $(basename "${dir}") -- dump.pm2 is stale, a reboot would not restore the apps. Run 'pm2 save' on the box." + fi } backup_before_swap() { @@ -445,7 +461,9 @@ main() { # still listed are the ones whose crons have not been verified re-run-safe # the same way — 06 macro, 07 synthesis/seed-qa/connections, 08 brief/ # translate/why-moved, 13 calls. Each can be dropped once its script carries - # the same guard. + # the same guard. 03 (backup) was never added: a second snapshot is a new + # timestamped file under the same retention cap, so re-running it costs a + # disk copy and nothing else. DEPLOY_QUIET_HOURS_KST=${DEPLOY_QUIET_HOURS_KST-"6 7 8 13"} # The webhook is a credential and this repo is public, so it lives only in # a server-side .env.local (gitignored). The TS scripts read the LIVE @@ -544,7 +562,7 @@ main() { # What PM2 serves is the truth. If it is already the tip — however it got # there, including a hand deploy — adopt it and stop. Rebuilding the same - # SHA would only restart 13 apps for nothing. + # SHA would only restart every app for nothing. if [ "${LIVE_SHA}" = "${TARGET}" ]; then [ "${DEPLOYED}" = "${TARGET}" ] || { log "live already at ${TARGET:0:8}; adopting as deployed state"; record_success "${TARGET}"; } { [ "${DEPLOY_VERBOSE}" = "1" ] || [ "${CHECK_ONLY}" = "1" ]; } && log "up to date at ${TARGET:0:8} (${LIVE_DIR})" @@ -635,6 +653,10 @@ main() { || fail_build "copy .env.local" ( cd "${NEW_DIR}" && "${PNPM_BIN}" install --frozen-lockfile >/dev/null 2>&1 ) || fail_build "pnpm install" + # Cheap (sub-second) and independent of the registry, so it belongs here and + # not only in CI: --force and a missing CI verdict both reach this point + # without anything having run the tests. + ( cd "${NEW_DIR}" && "${PNPM_BIN}" test >/dev/null 2>&1 ) || fail_build "pnpm test" ( cd "${NEW_DIR}" && "${PNPM_BIN}" build >/dev/null 2>&1 ) || fail_build "next build" # The one check tsc and next build cannot make: getSystemHealth()'s raw SQL @@ -643,6 +665,19 @@ main() { || fail_build "check-health smoke test" # --- 4. Swap ------------------------------------------------------------- + # Ask again, now that the build is behind us. The check in step 3 is what + # stops us wasting an install+build during a cron hour, but it is a verdict + # about the time it was taken: install+build+smoke runs for minutes, so a + # tick that started at 05:5x lands its swap inside the 06:00 KST slot — and + # a swap re-registers every cron app, which pm2 then runs immediately. That + # is the double-fire the quiet hours exist to prevent. Nothing is live yet, + # so the release is simply discarded and a later tick rebuilds it. + if in_quiet_hours && [ "${FORCE}" != "1" ]; then + log "build finished but it is now $(kst_hour):xx KST (a cron slot) -- discarding ${NEW_DIR} and deferring the swap" + discard_release "${NEW_DIR}" + exit 0 + fi + backup_before_swap "${NEW_DIR}" || fail_build "pre-swap backup" # From here production is being touched. A failure is recorded as phase @@ -650,7 +685,7 @@ main() { # itself fails cannot skip the bookkeeping under set -e. # # And from here a dropped SSH session (HUP) or a stray Ctrl-C must not stop - # us: pm2_swap_to deletes 13 apps and then starts them, and dying between + # us: pm2_swap_to deletes every alpha app and then starts them, and dying between # the two leaves nothing serving and nothing that will restart it. Ignore # those signals for the few seconds the swap and any rollback take. SIGKILL # cannot be caught — pm2 stop's escalation is still a hazard for a MANUAL @@ -668,7 +703,14 @@ main() { record_success "${TARGET}" log "deployed ${TARGET:0:8} at ${NEW_DIR}" strict_ok || true - prune_releases "${NEW_DIR}" + # Only prune once the persisted process list actually points here. See + # pm2_swap_to: pruning against a stale dump.pm2 is what turns a failed + # save into an empty box after the next reboot. + if [ "${PM2_SAVE_OK}" = "1" ]; then + prune_releases "${NEW_DIR}" + else + log "WARN skipping release prune -- pm2 save did not take, the old release must stay reachable" + fi exit 0 fi diff --git a/scripts/fetch-macro-kr.ts b/scripts/fetch-macro-kr.ts index 0813802..aa4cc7e 100644 --- a/scripts/fetch-macro-kr.ts +++ b/scripts/fetch-macro-kr.ts @@ -5,25 +5,9 @@ * pnpm tsx scripts/fetch-macro-kr.ts */ -import fs from "node:fs"; -import path from "node:path"; +import { loadScriptEnv } from "../lib/script-env"; -function loadEnvFile(file: string) { - if (!fs.existsSync(file)) return; - const text = fs.readFileSync(file, "utf8"); - for (const line of text.split("\n")) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) continue; - const eq = trimmed.indexOf("="); - if (eq < 0) continue; - const key = trimmed.slice(0, eq).trim(); - const val = trimmed.slice(eq + 1).trim(); - if (!process.env[key]) process.env[key] = val; - } -} -loadEnvFile(path.join(process.cwd(), ".env.local")); -loadEnvFile(path.join(process.cwd(), ".env")); -process.env.NODE_ENV = process.env.NODE_ENV || "production"; +loadScriptEnv(); async function main() { const { fetchKrSeries, KR_MACRO_SERIES, ECOS_AVAILABLE } = await import("../lib/ecos"); diff --git a/scripts/fetch-macro.ts b/scripts/fetch-macro.ts index b77a87a..7808c5d 100644 --- a/scripts/fetch-macro.ts +++ b/scripts/fetch-macro.ts @@ -8,25 +8,9 @@ * pm2 cron: 매일 06:00 KST = 21:00 UTC. */ -import fs from "node:fs"; -import path from "node:path"; +import { loadScriptEnv } from "../lib/script-env"; -function loadEnvFile(file: string) { - if (!fs.existsSync(file)) return; - const text = fs.readFileSync(file, "utf8"); - for (const line of text.split("\n")) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) continue; - const eq = trimmed.indexOf("="); - if (eq < 0) continue; - const key = trimmed.slice(0, eq).trim(); - const val = trimmed.slice(eq + 1).trim(); - if (!process.env[key]) process.env[key] = val; - } -} -loadEnvFile(path.join(process.cwd(), ".env.local")); -loadEnvFile(path.join(process.cwd(), ".env")); -process.env.NODE_ENV = process.env.NODE_ENV || "production"; +loadScriptEnv(); async function main() { const { fetchSeriesLatest, MACRO_SERIES, FRED_AVAILABLE } = await import("../lib/fred"); diff --git a/scripts/generate-brief.ts b/scripts/generate-brief.ts index ced8c0c..d44ab5c 100644 --- a/scripts/generate-brief.ts +++ b/scripts/generate-brief.ts @@ -9,25 +9,9 @@ * pm2 cron: 매일 23:00 UTC = 다음날 08:00 KST. */ -import fs from "node:fs"; -import path from "node:path"; +import { loadScriptEnv } from "../lib/script-env"; -function loadEnvFile(file: string) { - if (!fs.existsSync(file)) return; - const text = fs.readFileSync(file, "utf8"); - for (const line of text.split("\n")) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) continue; - const eq = trimmed.indexOf("="); - if (eq < 0) continue; - const key = trimmed.slice(0, eq).trim(); - const val = trimmed.slice(eq + 1).trim(); - if (!process.env[key]) process.env[key] = val; - } -} -loadEnvFile(path.join(process.cwd(), ".env.local")); -loadEnvFile(path.join(process.cwd(), ".env")); -process.env.NODE_ENV = process.env.NODE_ENV || "production"; +loadScriptEnv(); function dateAdd(date: string, days: number): string { const t = Date.parse(date + "T00:00:00Z") + days * 24 * 3600_000; diff --git a/scripts/generate-connections.ts b/scripts/generate-connections.ts index 3c7db06..10f5382 100644 --- a/scripts/generate-connections.ts +++ b/scripts/generate-connections.ts @@ -8,25 +8,9 @@ * 비용: 페어당 ~$0.0001-0.0003. top 80 = ~$0.02. */ -import fs from "node:fs"; -import path from "node:path"; +import { loadScriptEnv } from "../lib/script-env"; -function loadEnvFile(file: string) { - if (!fs.existsSync(file)) return; - const text = fs.readFileSync(file, "utf8"); - for (const line of text.split("\n")) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) continue; - const eq = trimmed.indexOf("="); - if (eq < 0) continue; - const key = trimmed.slice(0, eq).trim(); - const val = trimmed.slice(eq + 1).trim(); - if (!process.env[key]) process.env[key] = val; - } -} -loadEnvFile(path.join(process.cwd(), ".env.local")); -loadEnvFile(path.join(process.cwd(), ".env")); -process.env.NODE_ENV = process.env.NODE_ENV || "production"; +loadScriptEnv(); function parseFlag(args: string[], name: string): string | undefined { const flag = `--${name}=`; diff --git a/scripts/generate-synthesis.ts b/scripts/generate-synthesis.ts index 14bfbfb..329b970 100644 --- a/scripts/generate-synthesis.ts +++ b/scripts/generate-synthesis.ts @@ -9,26 +9,10 @@ * 비용: 평균 영상 10개 합성 1회 ~$0.001. 100개 ~$0.10. */ -import fs from "node:fs"; -import path from "node:path"; +import { loadScriptEnv } from "../lib/script-env"; // .env.local 로드 + production DB 강제 -function loadEnvFile(file: string) { - if (!fs.existsSync(file)) return; - const text = fs.readFileSync(file, "utf8"); - for (const line of text.split("\n")) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) continue; - const eq = trimmed.indexOf("="); - if (eq < 0) continue; - const key = trimmed.slice(0, eq).trim(); - const val = trimmed.slice(eq + 1).trim(); - if (!process.env[key]) process.env[key] = val; - } -} -loadEnvFile(path.join(process.cwd(), ".env.local")); -loadEnvFile(path.join(process.cwd(), ".env")); -process.env.NODE_ENV = process.env.NODE_ENV || "production"; +loadScriptEnv(); function parseFlag(args: string[], name: string): string | undefined { const flag = `--${name}=`; diff --git a/scripts/generate-why-moved.ts b/scripts/generate-why-moved.ts index 372cd8b..3b2ed6a 100644 --- a/scripts/generate-why-moved.ts +++ b/scripts/generate-why-moved.ts @@ -19,8 +19,7 @@ * - relocated: pulse 가 다른 날짜 키 아래 저장돼 있음 → 감사된 KST repair 전용. */ -import fs from "node:fs"; -import path from "node:path"; +import { loadScriptEnv } from "../lib/script-env"; const DEFAULT_AUTOMATIC_LIMIT = 20; /** Stale articles this many KST days old (or newer) are refreshed by the @@ -28,22 +27,7 @@ const DEFAULT_AUTOMATIC_LIMIT = 20; * enough to survive a couple of missed cron runs. */ const AUTO_REFRESH_DAYS = 3; -function loadEnvFile(file: string) { - if (!fs.existsSync(file)) return; - const text = fs.readFileSync(file, "utf8"); - for (const line of text.split("\n")) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) continue; - const eq = trimmed.indexOf("="); - if (eq < 0) continue; - const key = trimmed.slice(0, eq).trim(); - const val = trimmed.slice(eq + 1).trim(); - if (!process.env[key]) process.env[key] = val; - } -} -loadEnvFile(path.join(process.cwd(), ".env.local")); -loadEnvFile(path.join(process.cwd(), ".env")); -process.env.NODE_ENV = process.env.NODE_ENV || "production"; +loadScriptEnv(); type ParsedArgs = { positional: string[]; diff --git a/scripts/health-alert.ts b/scripts/health-alert.ts index 502e49b..990c45c 100644 --- a/scripts/health-alert.ts +++ b/scripts/health-alert.ts @@ -33,21 +33,9 @@ import fs from "node:fs"; import path from "node:path"; +import { loadScriptEnv } from "../lib/script-env"; -function loadEnvFile(file: string) { - if (!fs.existsSync(file)) return; - for (const line of fs.readFileSync(file, "utf8").split("\n")) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) continue; - const eq = trimmed.indexOf("="); - if (eq < 0) continue; - const key = trimmed.slice(0, eq).trim(); - const val = trimmed.slice(eq + 1).trim(); - if (!process.env[key]) process.env[key] = val; - } -} -loadEnvFile(path.join(process.cwd(), ".env.local")); -loadEnvFile(path.join(process.cwd(), ".env")); +loadScriptEnv(); const HEALTH_URL = process.env.HEALTH_ALERT_URL || @@ -74,12 +62,21 @@ const STATE_FILE = process.env.HEALTH_ALERT_STATE_FILE type Level = "ok" | "warn" | "fail" | "down"; type Subsystem = { key: string; status: string; note?: string; latest_date?: string | null }; +/** The citation audit's result, not its liveness — see lib/health.ts. It is + * never a paging condition; it rides along in the daily summary so the number + * the site is actually trying to move gets read by someone once a day. */ +type Audit = { + latest_date?: string | null; + latest_rate?: number | null; + age_days?: number | null; +}; type Probe = { level: Level; httpStatus: number | null; worst: string | null; db: string | null; subsystems: Subsystem[]; + audit?: Audit; error?: string; }; type State = { level: Level; since: string; lastSummaryDate: string }; @@ -123,6 +120,7 @@ async function probeOnce(): Promise { db?: string; worst_status?: string; subsystems?: Subsystem[]; + audit?: Audit; } = {}; let parsed = false; try { @@ -151,6 +149,7 @@ async function probeOnce(): Promise { worst, db, subsystems: body.subsystems ?? [], + audit: body.audit, error: level === "down" ? `HTTP ${res.status} ${text.slice(0, 120).replace(/\s+/g, " ")}` : undefined, }; } catch (err) { @@ -210,6 +209,19 @@ function subsystemLines(p: Probe): string { ); } +/** One line of citation audit, for the daily summary only. */ +function auditLine(p: Probe): string { + // A down probe has no body to read; saying "no runs recorded" there would + // report a content result we never actually got. + if (p.level === "down") return "\n📊 citation audit: 확인 불가 (health 응답 없음)"; + const a = p.audit; + if (!a || !a.latest_date) return "\n📊 citation audit: 아직 기록된 실행이 없습니다"; + const rate = + a.latest_rate == null ? "—" : `${Math.round(a.latest_rate * 1000) / 10}%`; + const age = a.age_days == null ? "" : ` (${a.age_days}일 전)`; + return `\n📊 citation audit ${a.latest_date}${age}: ${rate}`; +} + async function main() { const args = process.argv.slice(2); @@ -257,7 +269,9 @@ async function main() { const wantSummary = args.includes("--summary") || (hour === SUMMARY_KST_HOUR && prev.lastSummaryDate !== date); if (wantSummary) { const icon = p.level === "ok" ? "🟢" : p.level === "warn" ? "🟡" : "🔴"; - await post(`${icon} **alpha 일일 요약** ${date} ${String(hour).padStart(2, "0")}:00 KST\n${detail}`); + await post( + `${icon} **alpha 일일 요약** ${date} ${String(hour).padStart(2, "0")}:00 KST\n${detail}${auditLine(p)}` + ); spoke = true; } diff --git a/scripts/indexnow-cron.ts b/scripts/indexnow-cron.ts index 1213bd2..ea4af65 100644 --- a/scripts/indexnow-cron.ts +++ b/scripts/indexnow-cron.ts @@ -14,25 +14,9 @@ import fs from "node:fs"; import path from "node:path"; +import { loadScriptEnv } from "../lib/script-env"; -// .env.local 수동 로드 (Next.js 밖에서 실행되므로 자동 로드 안 됨) -function loadEnvFile(file: string) { - if (!fs.existsSync(file)) return; - const text = fs.readFileSync(file, "utf8"); - for (const line of text.split("\n")) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) continue; - const eq = trimmed.indexOf("="); - if (eq < 0) continue; - const key = trimmed.slice(0, eq).trim(); - const val = trimmed.slice(eq + 1).trim(); - if (!process.env[key]) process.env[key] = val; - } -} -loadEnvFile(path.join(process.cwd(), ".env.local")); -loadEnvFile(path.join(process.cwd(), ".env")); - -process.env.NODE_ENV = process.env.NODE_ENV || "production"; +loadScriptEnv(); // Keep state beside the persistent SQLite DB by default. A release-local // `cwd/data` file disappears on every worktree deployment and causes the next diff --git a/scripts/persona-replies.ts b/scripts/persona-replies.ts index 998c564..57aac8d 100644 --- a/scripts/persona-replies.ts +++ b/scripts/persona-replies.ts @@ -15,25 +15,9 @@ * pm2 cron: 매일 03:00 UTC = 12:00 KST. */ -import fs from "node:fs"; -import path from "node:path"; - -function loadEnvFile(file: string) { - if (!fs.existsSync(file)) return; - const text = fs.readFileSync(file, "utf8"); - for (const line of text.split("\n")) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) continue; - const eq = trimmed.indexOf("="); - if (eq < 0) continue; - const key = trimmed.slice(0, eq).trim(); - const val = trimmed.slice(eq + 1).trim(); - if (!process.env[key]) process.env[key] = val; - } -} -loadEnvFile(path.join(process.cwd(), ".env.local")); -loadEnvFile(path.join(process.cwd(), ".env")); -process.env.NODE_ENV = process.env.NODE_ENV || "production"; +import { loadScriptEnv } from "../lib/script-env"; + +loadScriptEnv(); function parseFlag(args: string[], name: string): string | undefined { const flag = `--${name}=`; diff --git a/scripts/persona-seed.ts b/scripts/persona-seed.ts index 0399a1f..6e2ec31 100644 --- a/scripts/persona-seed.ts +++ b/scripts/persona-seed.ts @@ -11,25 +11,9 @@ * top 20 entity × 2 페르소나 = 40 posts × $0.0003 = ~$0.012 */ -import fs from "node:fs"; -import path from "node:path"; +import { loadScriptEnv } from "../lib/script-env"; -function loadEnvFile(file: string) { - if (!fs.existsSync(file)) return; - const text = fs.readFileSync(file, "utf8"); - for (const line of text.split("\n")) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) continue; - const eq = trimmed.indexOf("="); - if (eq < 0) continue; - const key = trimmed.slice(0, eq).trim(); - const val = trimmed.slice(eq + 1).trim(); - if (!process.env[key]) process.env[key] = val; - } -} -loadEnvFile(path.join(process.cwd(), ".env.local")); -loadEnvFile(path.join(process.cwd(), ".env")); -process.env.NODE_ENV = process.env.NODE_ENV || "production"; +loadScriptEnv(); function parseFlag(args: string[], name: string): string | undefined { const flag = `--${name}=`; diff --git a/scripts/persona-tick.ts b/scripts/persona-tick.ts index 4a084f3..bd1f035 100644 --- a/scripts/persona-tick.ts +++ b/scripts/persona-tick.ts @@ -16,25 +16,9 @@ * - 사람 댓글 5+ 페이지는 skip (HN decay) */ -import fs from "node:fs"; -import path from "node:path"; - -function loadEnvFile(file: string) { - if (!fs.existsSync(file)) return; - const text = fs.readFileSync(file, "utf8"); - for (const line of text.split("\n")) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) continue; - const eq = trimmed.indexOf("="); - if (eq < 0) continue; - const key = trimmed.slice(0, eq).trim(); - const val = trimmed.slice(eq + 1).trim(); - if (!process.env[key]) process.env[key] = val; - } -} -loadEnvFile(path.join(process.cwd(), ".env.local")); -loadEnvFile(path.join(process.cwd(), ".env")); -process.env.NODE_ENV = process.env.NODE_ENV || "production"; +import { loadScriptEnv } from "../lib/script-env"; + +loadScriptEnv(); function parseFlag(args: string[], name: string): string | undefined { const flag = `--${name}=`; @@ -46,10 +30,11 @@ function parseFlag(args: string[], name: string): string | undefined { const SCHEDULED_KST_HOUR = 9; /** - * How many of each tick's pages are reserved for priceable assets. + * How many priceable-asset posts a KST day may publish. * - * See the pool build below. Bounded by the 30-day (persona, page) cooldown: - * 8 priceable assets × 8 personas ÷ 30 days ≈ 2.1 sustainable draws a day. + * A ceiling on published posts, not on candidates offered — see the pool + * build below. Bounded by the 30-day (persona, page) cooldown: 8 priceable + * assets × 8 personas ÷ 30 days ≈ 2.1 sustainable draws a day. */ const CALLABLE_ASSET_QUOTA = 2; @@ -163,7 +148,7 @@ async function main() { // Shuffle pool.sort(() => Math.random() - 0.5); - // Reserve the first slots for assets that can actually carry a price call. + // Draw priceable assets first, and cap how many of them get published. // // Without this the draw is uniform over the whole pool, and on 2026-08-20 // that pool was 237 pages of which 8 are priceable (비트코인·코스피·S&P500· @@ -172,31 +157,58 @@ async function main() { // publishes on /agents — are produced only there, which is why none had been // created since 2026-05-16 even after the price sources were fixed. // - // The quota is 2, not more: (persona, page) pairs have a 30-day cooldown, so + // The cap is on what gets POSTED, not on how many candidates are offered. + // The first version reserved exactly two candidates, which bought at most + // two attempts: a 30-day cooldown hit or a CoinGecko blip on either of them + // and the day produced no call at all — the quota named a target it had no + // way to reach. Every priceable page goes to the front instead, and the + // loop stops drawing them once `CALLABLE_ASSET_QUOTA` have published, so a + // SKIP costs the next candidate rather than the whole day. + // + // The cap is 2, not more: (persona, page) pairs have a 30-day cooldown, so // 8 assets × 8 personas ÷ 30 days ≈ 2.1 sustainable draws a day. Asking for // more would just produce SKIPs and crowd out the rest of the site. const { isCallableAsset } = await import("../lib/prices"); - const reserved = pool - .filter((c) => c.refType === "asset" && isCallableAsset(c.refId)) - .slice(0, CALLABLE_ASSET_QUOTA); - if (reserved.length) { - const reservedKeys = new Set(reserved.map((c) => `${c.refType}:${c.refId}`)); - const rest = pool.filter((c) => !reservedKeys.has(`${c.refType}:${c.refId}`)); + const key = (c: Candidate) => `${c.refType}:${c.refId}`; + const callable = (c: Candidate) => + c.refType === "asset" && isCallableAsset(c.refId); + const priceable = pool.filter(callable); + if (priceable.length) { + const front = new Set(priceable.map(key)); + const rest = pool.filter((c) => !front.has(key(c))); pool.length = 0; - pool.push(...reserved, ...rest); + pool.push(...priceable, ...rest); } + // Same resumability as `postedToday`: a partial run, or a swap that fires + // the tick twice, must not publish a second day's worth of calls. + const callablePostedToday = ( + getDb() + .prepare( + `SELECT ref_id FROM alpha_posts + WHERE author_kind = 'agent' AND parent_id IS NULL AND is_deleted = 0 + AND ref_type = 'asset' + AND datetime(created_at, '+9 hours') >= date('now', '+9 hours')` + ) + .all() as { ref_id: string | null }[] + ).filter((r) => r.ref_id && isCallableAsset(r.ref_id)).length; + console.log( - `Tick ${today}: pool=${pool.length} (persons skipped ${skippedPersons}, priceable reserved ${reserved.length}), types=${[...selectedTypes].join(",")}, agents=${agents.length}, target=${remaining}${postedToday ? ` (${postedToday} already today, cap ${pages})` : ""} posts` + `Tick ${today}: pool=${pool.length} (persons skipped ${skippedPersons}, priceable ${priceable.length} first, ${callablePostedToday}/${CALLABLE_ASSET_QUOTA} priceable already today), types=${[...selectedTypes].join(",")}, agents=${agents.length}, target=${remaining}${postedToday ? ` (${postedToday} already today, cap ${pages})` : ""} posts` ); let posted = 0; + let callablePosted = callablePostedToday; let totalCost = 0; let attempts = 0; const maxAttempts = remaining * 5; // safety bound for (const c of pool) { if (posted >= remaining || attempts >= maxAttempts) break; + // Skipped before `attempts++`: passing over the priceable tail once the + // cap is met is not an attempt, and must not eat the safety bound. + const isCallable = callable(c); + if (isCallable && callablePosted >= CALLABLE_ASSET_QUOTA) continue; attempts++; // Pick an agent, prefer those that haven't posted recently @@ -211,6 +223,7 @@ async function main() { }); if (r.ok && r.post) { posted++; + if (isCallable) callablePosted++; totalCost += r.costUsd ?? 0; process.stdout.write(`OK [$${(r.costUsd ?? 0).toFixed(4)}]\n`); } else { @@ -223,7 +236,7 @@ async function main() { } console.log( - `\nTick done. Posted: ${posted}/${remaining} · cost: $${totalCost.toFixed(4)} · attempts: ${attempts}` + `\nTick done. Posted: ${posted}/${remaining} (priceable ${callablePosted}/${CALLABLE_ASSET_QUOTA}) · cost: $${totalCost.toFixed(4)} · attempts: ${attempts}` ); } diff --git a/scripts/reindex-why-moved.ts b/scripts/reindex-why-moved.ts index 9370762..ee07a7f 100644 --- a/scripts/reindex-why-moved.ts +++ b/scripts/reindex-why-moved.ts @@ -18,21 +18,9 @@ * pnpm tsx scripts/reindex-why-moved.ts */ -import fs from "node:fs"; -import path from "node:path"; +import { loadScriptEnv } from "../lib/script-env"; -function loadEnvFile(file: string) { - if (!fs.existsSync(file)) return; - for (const line of fs.readFileSync(file, "utf8").split("\n")) { - const t = line.trim(); - if (!t || t.startsWith("#")) continue; - const eq = t.indexOf("="); - if (eq < 0) continue; - const k = t.slice(0, eq).trim(); - if (!process.env[k]) process.env[k] = t.slice(eq + 1).trim(); - } -} -loadEnvFile(path.join(process.cwd(), ".env.local")); +loadScriptEnv(); async function main() { const dry = process.argv.includes("--dry-run"); diff --git a/scripts/seed-qa-dynamic.ts b/scripts/seed-qa-dynamic.ts index 63eafb2..5f37250 100644 --- a/scripts/seed-qa-dynamic.ts +++ b/scripts/seed-qa-dynamic.ts @@ -11,25 +11,9 @@ * pnpm tsx scripts/seed-qa-dynamic.ts --limit=40 # higher cap * pnpm tsx scripts/seed-qa-dynamic.ts --dry-run # preview only */ -import fs from "node:fs"; -import path from "node:path"; +import { loadScriptEnv } from "../lib/script-env"; -function loadEnvFile(file: string) { - if (!fs.existsSync(file)) return; - const text = fs.readFileSync(file, "utf8"); - for (const line of text.split("\n")) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) continue; - const eq = trimmed.indexOf("="); - if (eq < 0) continue; - const key = trimmed.slice(0, eq).trim(); - const val = trimmed.slice(eq + 1).trim(); - if (!process.env[key]) process.env[key] = val; - } -} -loadEnvFile(path.join(process.cwd(), ".env.local")); -loadEnvFile(path.join(process.cwd(), ".env")); -process.env.NODE_ENV = process.env.NODE_ENV || "production"; +loadScriptEnv(); const ARGS = process.argv.slice(2); diff --git a/scripts/seed-qa.ts b/scripts/seed-qa.ts index 12ec718..c406312 100644 --- a/scripts/seed-qa.ts +++ b/scripts/seed-qa.ts @@ -10,25 +10,9 @@ * 주: ask_alpha는 캐시되어 동일 질의 재호출 0원. */ -import fs from "node:fs"; -import path from "node:path"; +import { loadScriptEnv } from "../lib/script-env"; -function loadEnvFile(file: string) { - if (!fs.existsSync(file)) return; - const text = fs.readFileSync(file, "utf8"); - for (const line of text.split("\n")) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) continue; - const eq = trimmed.indexOf("="); - if (eq < 0) continue; - const key = trimmed.slice(0, eq).trim(); - const val = trimmed.slice(eq + 1).trim(); - if (!process.env[key]) process.env[key] = val; - } -} -loadEnvFile(path.join(process.cwd(), ".env.local")); -loadEnvFile(path.join(process.cwd(), ".env")); -process.env.NODE_ENV = process.env.NODE_ENV || "production"; +loadScriptEnv(); const QUESTIONS = [ // 자산 관련 (audit Q1-Q5) diff --git a/scripts/track-calls.ts b/scripts/track-calls.ts index b7c1a62..084e3fe 100644 --- a/scripts/track-calls.ts +++ b/scripts/track-calls.ts @@ -14,25 +14,10 @@ * pm2 cron: 매일 04:00 UTC = 13:00 KST. */ -import fs from "node:fs"; import path from "node:path"; +import { loadScriptEnv } from "../lib/script-env"; -function loadEnvFile(file: string) { - if (!fs.existsSync(file)) return; - const text = fs.readFileSync(file, "utf8"); - for (const line of text.split("\n")) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) continue; - const eq = trimmed.indexOf("="); - if (eq < 0) continue; - const key = trimmed.slice(0, eq).trim(); - const val = trimmed.slice(eq + 1).trim(); - if (!process.env[key]) process.env[key] = val; - } -} -loadEnvFile(path.join(process.cwd(), ".env.local")); -loadEnvFile(path.join(process.cwd(), ".env")); -process.env.NODE_ENV = process.env.NODE_ENV || "production"; +loadScriptEnv(); // Reference prices are spot prices fetched when this script runs, so a post // can only be recovered here while it is minutes old; otherwise it would be diff --git a/scripts/translate-briefs.ts b/scripts/translate-briefs.ts index 871684a..dc3ea0b 100644 --- a/scripts/translate-briefs.ts +++ b/scripts/translate-briefs.ts @@ -11,25 +11,9 @@ * pnpm tsx scripts/translate-briefs.ts --days=30 # custom window */ -import fs from "node:fs"; -import path from "node:path"; +import { loadScriptEnv } from "../lib/script-env"; -function loadEnvFile(file: string) { - if (!fs.existsSync(file)) return; - const text = fs.readFileSync(file, "utf8"); - for (const line of text.split("\n")) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) continue; - const eq = trimmed.indexOf("="); - if (eq < 0) continue; - const key = trimmed.slice(0, eq).trim(); - const val = trimmed.slice(eq + 1).trim(); - if (!process.env[key]) process.env[key] = val; - } -} -loadEnvFile(path.join(process.cwd(), ".env.local")); -loadEnvFile(path.join(process.cwd(), ".env")); -process.env.NODE_ENV = process.env.NODE_ENV || "production"; +loadScriptEnv(); function parseFlag(args: string[], name: string): string | undefined { const flag = `--${name}=`; diff --git a/tests/calls.test.ts b/tests/calls.test.ts new file mode 100644 index 0000000..b63c803 --- /dev/null +++ b/tests/calls.test.ts @@ -0,0 +1,203 @@ +/** + * Trackable calls — eligibility, the write, and the one invariant the site's + * track record rests on: a persona stance post on a priceable asset and its + * call either both exist or neither does. + * + * That invariant used to be "write the post, then fetch a price, then insert + * the call inside a `catch {}`". It held right up until CoinGecko answered + * slowly, and /agents had nothing to show for months. It is cheap to assert + * and expensive to rediscover, so it is asserted here. + * + * Runs against a throwaway SQLite file. No network: the price is passed in, + * which is the whole point of insertCallForPost(). + */ + +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { after, before, describe, it } from "node:test"; + +type Calls = typeof import("../lib/calls"); +type Community = typeof import("../lib/community"); +type Db = typeof import("../lib/db"); + +let calls: Calls; +let community: Community; +let db: Db; +let tmpDir: string; + +/** A post as the writer hands it over, with only the fields a call reads. */ +function post(over: Partial[0]> = {}) { + return { + id: `p-${Math.random().toString(16).slice(2, 10)}`, + ref_type: "asset", + ref_id: "bitcoin", + parent_id: null, + author_kind: "agent", + author_handle: "@테스트", + stance: "agree", + created_at: "2026-08-21T00:00:00.000Z", + ...over, + }; +} + +before(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "alpha-calls-test-")); + process.env.DB_PATH = path.join(tmpDir, "test.sqlite"); + // No canonical corpus in a test run; lib/mic.ts degrades to empty and the + // call falls back to the raw asset id as its label. + process.env.MIC_DATA_PATH = path.join(tmpDir, "mic-data"); + // Imported after the env is set — lib/db.ts reads DB_PATH at module load. + calls = await import("../lib/calls"); + community = await import("../lib/community"); + db = await import("../lib/db"); + calls.ensureCallsTable(); + community.ensureCommunityTables(); +}); + +after(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe("callDirectionFor", () => { + it("maps a stance to a direction", () => { + assert.equal(calls.callDirectionFor(post({ stance: "agree" })), "up"); + assert.equal(calls.callDirectionFor(post({ stance: "disagree" })), "down"); + }); + + it("refuses a stance that is not a direction", () => { + for (const stance of ["observe", "neutral", "", null, "AGREE", "up"]) { + assert.equal(calls.callDirectionFor(post({ stance })), null, `stance=${stance}`); + } + }); + + it("refuses anything that is not a top-level asset post", () => { + assert.equal(calls.callDirectionFor(post({ ref_type: "entity" })), null); + assert.equal(calls.callDirectionFor(post({ ref_id: null })), null); + // Replies are not calls — the track record counts a first judgement on a + // page, and three of the four calls ever published came from replies + // before this was enforced. + assert.equal(calls.callDirectionFor(post({ parent_id: "p-parent" })), null); + }); + + it("refuses an asset with no price source, and a pegged one", () => { + assert.equal(calls.callDirectionFor(post({ ref_id: "gpu" })), null); + // usdt is priceable but not callable: a 7-day direction on a dollar + // stablecoin cannot be right or wrong. + assert.equal(calls.callDirectionFor(post({ ref_id: "usdt" })), null); + }); +}); + +describe("insertCallForPost", () => { + it("records the price, the horizon and the band in force", () => { + const p = post({ ref_id: "bitcoin", stance: "disagree" }); + const call = calls.insertCallForPost(p, 61234.5); + assert.ok(call); + assert.equal(call.direction, "down"); + assert.equal(call.reference_price, 61234.5); + assert.equal(call.horizon_days, 7); + assert.equal(call.resolution_status, "pending"); + assert.equal(call.flat_pct, 1); // crypto band + assert.equal( + call.target_date, + new Date("2026-08-28T00:00:00.000Z").toISOString() + ); + }); + + it("uses the asset class's own flat band, not one number for everything", () => { + // An index moves less than a coin; grading both at ±1% would land four in + // ten S&P calls unscoreable against one in eight for crypto. + const call = calls.insertCallForPost(post({ ref_id: "sp500" }), 5000); + assert.equal(call?.flat_pct, 0.5); + }); + + it("is idempotent per post", () => { + const p = post(); + assert.ok(calls.insertCallForPost(p, 100)); + assert.equal(calls.insertCallForPost(p, 100), null); + }); + + it("throws rather than skip when the price is unusable", () => { + // The caller has already claimed to hold a price. Dropping the call here + // is exactly the silent failure this split exists to remove. + for (const bad of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) { + assert.throws( + () => calls.insertCallForPost(post(), bad), + /unusable reference price/ + ); + } + }); +}); + +describe("createPostWithCall — a stance post and its call are one write", () => { + // The real function lib/persona-post.ts calls, not a transaction rebuilt + // here: a test that re-creates the pattern would pass even if the caller + // stopped using it, which is exactly the regression being guarded. + const write = (body: string, refId: string, price: number | null) => + calls.createPostWithCall( + { + refType: "asset", + refId, + body, + stance: "agree", + authorKind: "agent", + authorToken: "agent:테스트", + authorHandle: "@테스트", + }, + price + ); + + const countPosts = (body: string) => + ( + db + .getDb() + .prepare(`SELECT COUNT(*) AS n FROM alpha_posts WHERE body = ?`) + .get(body) as { n: number } + ).n; + + const callFor = (postId: string) => + db + .getDb() + .prepare(`SELECT id FROM alpha_trackable_calls WHERE post_id = ?`) + .get(postId) as { id: string } | undefined; + + it("publishes both when the price is good", () => { + const p = write("좋은 가격", "ethereum", 3000); + assert.equal(countPosts("좋은 가격"), 1); + assert.ok(callFor(p.id)); + }); + + it("publishes neither when the call cannot be written", () => { + assert.throws(() => write("나쁜 가격", "ethereum", 0)); + // The regression: the post used to survive on its own, and /agents could + // never show the call it implied. + assert.equal(countPosts("나쁜 가격"), 0); + }); + + it("still publishes a post that was never going to carry a call", () => { + // Not every persona post is a call. An unpriceable page has no reference + // price to pass, and the post stands on its own — with no call row. + const p = write("가격 없는 페이지", "gpu", null); + assert.equal(countPosts("가격 없는 페이지"), 1); + assert.equal(callFor(p.id), undefined); + }); + + it("writes no call for a stance that is not a direction", () => { + // observe is a real persona stance and a normal post; it is not a call. + const p = calls.createPostWithCall( + { + refType: "asset", + refId: "bitcoin", + body: "관망", + stance: "observe", + authorKind: "agent", + authorToken: "agent:테스트", + authorHandle: "@테스트", + }, + 70000 + ); + assert.equal(countPosts("관망"), 1); + assert.equal(callFor(p.id), undefined); + }); +}); diff --git a/tests/kst.test.ts b/tests/kst.test.ts new file mode 100644 index 0000000..79de8ea --- /dev/null +++ b/tests/kst.test.ts @@ -0,0 +1,102 @@ +/** + * KST wall-clock helpers — the arithmetic every cron guard depends on. + * + * These are worth a test precisely because they are boring: a nine-hour + * offset applied in the wrong direction is invisible in review and moved + * every scheduled job by nine hours once already (see the history note in + * ecosystem.config.cjs). Nothing here touches the DB or the network. + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { kstClock, kstDayBounds, isScheduledNow, scheduledSkipReason } from "../lib/kst"; + +describe("kstClock", () => { + it("reads an instant as Korean wall-clock time", () => { + // 2026-08-20T15:00:00Z is exactly 2026-08-21 00:00 KST — the first + // instant of the Korean day, and the boundary every daily cap turns on. + const c = kstClock(new Date("2026-08-20T15:00:00Z")); + assert.equal(c.date, "2026-08-21"); + assert.equal(c.hour, 0); + assert.equal(c.weekday, 5); // Friday + }); + + it("is one minute earlier still the previous Korean day", () => { + const c = kstClock(new Date("2026-08-20T14:59:00Z")); + assert.equal(c.date, "2026-08-20"); + assert.equal(c.hour, 23); + }); +}); + +describe("kstDayBounds", () => { + it("spans 15:00Z the day before to 15:00Z the day of", () => { + const { start, end } = kstDayBounds("2026-08-21"); + assert.equal(new Date(start).toISOString(), "2026-08-20T15:00:00.000Z"); + assert.equal(new Date(end).toISOString(), "2026-08-21T15:00:00.000Z"); + assert.equal(end - start, 24 * 3600_000); + }); + + it("rejects a malformed date rather than returning a shifted window", () => { + assert.throws(() => kstDayBounds("2026-8-21"), /Invalid calendar date/); + assert.throws(() => kstDayBounds("not-a-date"), /Invalid calendar date/); + }); + + it("rejects a date that does not exist", () => { + // Date.parse would roll 02-30 forward to 03-02 and silently bound the + // wrong day. + assert.throws(() => kstDayBounds("2026-02-30"), /Invalid calendar date/); + }); +}); + +describe("isScheduledNow", () => { + // 2026-08-17T02:00:00Z = Monday 11:00 KST — the citation audit's slot. + const mondayEleven = new Date("2026-08-17T02:00:00Z"); + + it("accepts anywhere inside the intended hour", () => { + assert.equal(isScheduledNow(11, 1, mondayEleven).ok, true); + assert.equal( + isScheduledNow(11, 1, new Date("2026-08-17T02:59:59Z")).ok, + true + ); + }); + + it("rejects the hour either side of it", () => { + assert.equal(isScheduledNow(11, 1, new Date("2026-08-17T01:59:59Z")).ok, false); + assert.equal(isScheduledNow(11, 1, new Date("2026-08-17T03:00:00Z")).ok, false); + }); + + it("rejects the right hour on the wrong weekday", () => { + // Same KST hour, one day later. + assert.equal(isScheduledNow(11, 1, new Date("2026-08-18T02:00:00Z")).ok, false); + }); + + it("ignores the weekday when the job is daily", () => { + assert.equal(isScheduledNow(11, undefined, new Date("2026-08-18T02:00:00Z")).ok, true); + }); +}); + +describe("scheduledSkipReason", () => { + it("lets an unflagged run through whatever the time", () => { + assert.equal(scheduledSkipReason([], 9), null); + assert.equal(scheduledSkipReason(["--pages=10"], 9), null); + }); + + it("lets a --scheduled run through in exactly one hour of the day", () => { + // This is the guard that makes a deploy-time pm2 registration a no-op. + // It reads the real clock, so assert the shape rather than a verdict: + // whatever hour it is, exactly one of the 24 slots may proceed. + const passing = Array.from({ length: 24 }, (_, h) => + scheduledSkipReason(["--scheduled"], h) + ).filter((r) => r === null); + assert.equal(passing.length, 1); + }); + + it("names the slot it was expecting when it skips", () => { + // Twelve hours from now is never the current hour. + const otherHour = (kstClock().hour + 12) % 24; + const reason = scheduledSkipReason(["--scheduled"], otherHour); + assert.notEqual(reason, null); + assert.match(reason as string, /Scheduled run skipped/); + assert.match(reason as string, new RegExp(`${String(otherHour).padStart(2, "0")}:00-`)); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index e8263c5..3a13f90 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -30,5 +30,5 @@ ".next/dev/types/**/*.ts", "**/*.mts" ], - "exclude": ["node_modules", "scripts"] + "exclude": ["node_modules"] }