push-worker.ts
Language TypeScript7.3 KB184 lines
#!/usr/bin/env tsx/*** Publish this morning's board to the deployed Worker.** pnpm push push the current local artifacts* pnpm push --dry-run say what would be pushed, touch nothing** Why the laptop pushes at all: the only place a model is called is `analysis/brief.py`, which runs here. The* Worker cannot author a briefing, only render one, so the written briefing has* to travel. That is the chosen design: the laptop stays in the daily path, and* nothing about the deployed Worker needs an ANTHROPIC_API_KEY.** Why it sends the rendered pages and not only the briefings: KV holds pages* the last cron rendered. Writing new briefings into KV does not change them:* the board would show them only after the next cron. So the coherent page `pnpm morning` just built is what gets published,* and the briefings are stored alongside it so the Worker's own later syncs can* include them too.** Everything here goes through `wrangler`, which uses the OAuth session from* `wrangler login`. No Cloudflare credential is read, stored, or printed by this* script.*/import { readFile, stat } from 'node:fs/promises'import { spawn } from 'node:child_process'import { fileURLToPath } from 'node:url'import { mkdtemp, writeFile, rm } from 'node:fs/promises'import { tmpdir } from 'node:os'import { join } from 'node:path'import { checkPush } from '../apps/dashboard/src/push-guard.js'import { readSnapshot } from '../apps/dashboard/src/snapshot-file.js'import { readHistory } from '../apps/dashboard/src/history-file.js'import { readBriefings } from '../apps/dashboard/src/briefings-file.js'import { headline } from '../apps/dashboard/src/collect.js'import { rowFrom } from '../apps/dashboard/src/history.js'import { renderVariants } from '../apps/dashboard/src/variants.js'import { errorMessage } from '@meridian/shared'const ROOT = new URL('../', import.meta.url)const WORKER_DIR = fileURLToPath(new URL('apps/worker/', ROOT))const dryRun = process.argv.includes('--dry-run')/** Must match KEY in apps/worker/src/index.ts. */const KEYS = {payload: 'dashboard:payload',briefings: 'dashboard:briefings',} as constconst path = (rel: string) => fileURLToPath(new URL(rel, ROOT))async function readIfPresent(rel: string): Promise<string | null> {try {return await readFile(path(rel), 'utf8')} catch {return null}}async function bytes(rel: string): Promise<number> {try {return (await stat(path(rel))).size} catch {return 0}}/*** Runs wrangler and resolves only on success.** `stdio: inherit` on purpose: wrangler's own errors are better than anything* this could paraphrase, and a push that half-worked needs to be visible rather* than summarized.*/function wrangler(args: string[]): Promise<void> {return new Promise((resolve, reject) => {const child = spawn('npx', ['wrangler', ...args], { cwd: WORKER_DIR, stdio: 'inherit' })child.on('error', reject)child.on('close', (code) =>code === 0 ? resolve() : reject(new Error(`wrangler ${args[0]} ${args[1]} exited ${code}`)),)})}async function main(): Promise<void> {const [payloadRaw, briefingsRaw] = await Promise.all([readIfPresent('briefing-payload.json'),readIfPresent('briefings.json'),])if (!payloadRaw) {console.error('No briefing-payload.json. Run `pnpm morning` (or `pnpm dashboard`) first.')process.exit(1)}const payload = JSON.parse(payloadRaw) as { mode?: string; generatedAt?: string }const briefings = briefingsRaw? (JSON.parse(briefingsRaw) as {mode?: stringpayloadGeneratedAt?: stringbriefings?: Record<string, string>}): nullconst verdict = checkPush({payloadMode: payload.mode,payloadGeneratedAt: payload.generatedAt,htmlBytes: await bytes('dashboard.html'),briefingsMode: briefings?.mode ?? null,briefingsPayloadGeneratedAt: briefings?.payloadGeneratedAt ?? null,briefingCount: briefings ? Object.keys(briefings.briefings ?? {}).length : 0,})for (const w of verdict.warnings) console.error(` warning: ${w}`)if (!verdict.ok) {console.error('\nRefusing to push:')for (const r of verdict.refusals) console.error(` - ${r}`)process.exit(1)}const briefingCount = briefings ? Object.keys(briefings.briefings ?? {}).length : 0console.log(`Publishing the ${payload.generatedAt} sync`)console.log(` page dashboard.html`)console.log(` payload briefing-payload.json`)console.log(` briefings ${briefingCount || 'none'}`)if (dryRun) {console.log('\n--dry-run: nothing was pushed.')return}// --preview false is required, not decorative: with a preview namespace for// `wrangler dev --remote`, the binding has two ids and wrangler refuses to// guess which one a write means. Production is the explicit choice.const put = (key: string, file: string) =>wrangler(['kv', 'key', 'put', '--binding=DASHBOARD', '--preview', 'false', key, `--path=${path(file)}`])// The pages go last. Each put is its own request, so a failure part-way leaves// KV mixed. Writing the briefings first means the worst case is a board still// showing yesterday's page: stale, correctly labelled, and consistent.// Writing the page first would leave it referencing data that had not landed.if (briefingsRaw) await put(KEYS.briefings, 'briefings.json')await put(KEYS.payload, 'briefing-payload.json')// One page per viewer, written last. Each contains only what that person may// read, rendered here from the cached sync so the laptop and the Worker's own// cron produce the same variants from the same code. `dashboard.html` stays a// local artifact: it is the company view, useful to open on the laptop, and// deliberately not uploaded: nothing serves it, and an unserved copy of client// data in KV is only somewhere else to leak from.const cached = await readSnapshot()if (!cached.ok) {console.error(`\nCannot render per-viewer pages: ${cached.reason}.`)console.error('Run `pnpm dashboard` to cache the sync, then push again.')console.error('The briefings and payload above were pushed; the pages were not,')console.error('so the board still shows the previous sync rather than a partial one.')process.exit(1)}const snap = cached.snapconst h = headline(snap)const history = await readHistory()const day = rowFrom(h, new Date(snap.syncedAt)).dayconst variants = renderVariants(snap, h, history, day, await readBriefings())const tmp = await mkdtemp(join(tmpdir(), 'meridian-variants-'))try {for (const [key, page] of variants) {// Via a file, not an argv value: a rendered page is ~100KB and would blow// past the command-line length limit as an argument.const file = join(tmp, `${key.replace(/[^\w.-]+/g, '_')}.html`)await writeFile(file, page, 'utf8')await wrangler(['kv', 'key', 'put', '--binding=DASHBOARD', '--preview', 'false', `dashboard:html:${key}`, `--path=${file}`])}} finally {await rm(tmp, { recursive: true, force: true })}console.log(`\nPushed ${variants.size} per-viewer page(s): ${[...variants.keys()].join(', ')}.`)console.log('Check /health, then open the board.')}main().catch((err) => {console.error(`\npush failed: ${errorMessage(err)}`)process.exit(1)})