mattrguerra/meridian-ops-boardmainPrivate on GitHub, published here in full

push-worker.ts

Language TypeScript7.3 KB184 lines

scripts/push-worker.ts

  1. #!/usr/bin/env tsx
  2. /**
  3. * Publish this morning's board to the deployed Worker.
  4. *
  5. * pnpm push push the current local artifacts
  6. * pnpm push --dry-run say what would be pushed, touch nothing
  7. *
  8. * Why the laptop pushes at all: the only place a model is called is `analysis/brief.py`, which runs here. The
  9. * Worker cannot author a briefing, only render one, so the written briefing has
  10. * to travel. That is the chosen design: the laptop stays in the daily path, and
  11. * nothing about the deployed Worker needs an ANTHROPIC_API_KEY.
  12. *
  13. * Why it sends the rendered pages and not only the briefings: KV holds pages
  14. * the last cron rendered. Writing new briefings into KV does not change them:
  15. * the board would show them only after the next cron. So the coherent page `pnpm morning` just built is what gets published,
  16. * and the briefings are stored alongside it so the Worker's own later syncs can
  17. * include them too.
  18. *
  19. * Everything here goes through `wrangler`, which uses the OAuth session from
  20. * `wrangler login`. No Cloudflare credential is read, stored, or printed by this
  21. * script.
  22. */
  23. import { readFile, stat } from 'node:fs/promises'
  24. import { spawn } from 'node:child_process'
  25. import { fileURLToPath } from 'node:url'
  26. import { mkdtemp, writeFile, rm } from 'node:fs/promises'
  27. import { tmpdir } from 'node:os'
  28. import { join } from 'node:path'
  29. import { checkPush } from '../apps/dashboard/src/push-guard.js'
  30. import { readSnapshot } from '../apps/dashboard/src/snapshot-file.js'
  31. import { readHistory } from '../apps/dashboard/src/history-file.js'
  32. import { readBriefings } from '../apps/dashboard/src/briefings-file.js'
  33. import { headline } from '../apps/dashboard/src/collect.js'
  34. import { rowFrom } from '../apps/dashboard/src/history.js'
  35. import { renderVariants } from '../apps/dashboard/src/variants.js'
  36. import { errorMessage } from '@meridian/shared'
  37. const ROOT = new URL('../', import.meta.url)
  38. const WORKER_DIR = fileURLToPath(new URL('apps/worker/', ROOT))
  39. const dryRun = process.argv.includes('--dry-run')
  40. /** Must match KEY in apps/worker/src/index.ts. */
  41. const KEYS = {
  42. payload: 'dashboard:payload',
  43. briefings: 'dashboard:briefings',
  44. } as const
  45. const path = (rel: string) => fileURLToPath(new URL(rel, ROOT))
  46. async function readIfPresent(rel: string): Promise<string | null> {
  47. try {
  48. return await readFile(path(rel), 'utf8')
  49. } catch {
  50. return null
  51. }
  52. }
  53. async function bytes(rel: string): Promise<number> {
  54. try {
  55. return (await stat(path(rel))).size
  56. } catch {
  57. return 0
  58. }
  59. }
  60. /**
  61. * Runs wrangler and resolves only on success.
  62. *
  63. * `stdio: inherit` on purpose: wrangler's own errors are better than anything
  64. * this could paraphrase, and a push that half-worked needs to be visible rather
  65. * than summarized.
  66. */
  67. function wrangler(args: string[]): Promise<void> {
  68. return new Promise((resolve, reject) => {
  69. const child = spawn('npx', ['wrangler', ...args], { cwd: WORKER_DIR, stdio: 'inherit' })
  70. child.on('error', reject)
  71. child.on('close', (code) =>
  72. code === 0 ? resolve() : reject(new Error(`wrangler ${args[0]} ${args[1]} exited ${code}`)),
  73. )
  74. })
  75. }
  76. async function main(): Promise<void> {
  77. const [payloadRaw, briefingsRaw] = await Promise.all([
  78. readIfPresent('briefing-payload.json'),
  79. readIfPresent('briefings.json'),
  80. ])
  81. if (!payloadRaw) {
  82. console.error('No briefing-payload.json. Run `pnpm morning` (or `pnpm dashboard`) first.')
  83. process.exit(1)
  84. }
  85. const payload = JSON.parse(payloadRaw) as { mode?: string; generatedAt?: string }
  86. const briefings = briefingsRaw
  87. ? (JSON.parse(briefingsRaw) as {
  88. mode?: string
  89. payloadGeneratedAt?: string
  90. briefings?: Record<string, string>
  91. })
  92. : null
  93. const verdict = checkPush({
  94. payloadMode: payload.mode,
  95. payloadGeneratedAt: payload.generatedAt,
  96. htmlBytes: await bytes('dashboard.html'),
  97. briefingsMode: briefings?.mode ?? null,
  98. briefingsPayloadGeneratedAt: briefings?.payloadGeneratedAt ?? null,
  99. briefingCount: briefings ? Object.keys(briefings.briefings ?? {}).length : 0,
  100. })
  101. for (const w of verdict.warnings) console.error(` warning: ${w}`)
  102. if (!verdict.ok) {
  103. console.error('\nRefusing to push:')
  104. for (const r of verdict.refusals) console.error(` - ${r}`)
  105. process.exit(1)
  106. }
  107. const briefingCount = briefings ? Object.keys(briefings.briefings ?? {}).length : 0
  108. console.log(`Publishing the ${payload.generatedAt} sync`)
  109. console.log(` page dashboard.html`)
  110. console.log(` payload briefing-payload.json`)
  111. console.log(` briefings ${briefingCount || 'none'}`)
  112. if (dryRun) {
  113. console.log('\n--dry-run: nothing was pushed.')
  114. return
  115. }
  116. // --preview false is required, not decorative: with a preview namespace for
  117. // `wrangler dev --remote`, the binding has two ids and wrangler refuses to
  118. // guess which one a write means. Production is the explicit choice.
  119. const put = (key: string, file: string) =>
  120. wrangler(['kv', 'key', 'put', '--binding=DASHBOARD', '--preview', 'false', key, `--path=${path(file)}`])
  121. // The pages go last. Each put is its own request, so a failure part-way leaves
  122. // KV mixed. Writing the briefings first means the worst case is a board still
  123. // showing yesterday's page: stale, correctly labelled, and consistent.
  124. // Writing the page first would leave it referencing data that had not landed.
  125. if (briefingsRaw) await put(KEYS.briefings, 'briefings.json')
  126. await put(KEYS.payload, 'briefing-payload.json')
  127. // One page per viewer, written last. Each contains only what that person may
  128. // read, rendered here from the cached sync so the laptop and the Worker's own
  129. // cron produce the same variants from the same code. `dashboard.html` stays a
  130. // local artifact: it is the company view, useful to open on the laptop, and
  131. // deliberately not uploaded: nothing serves it, and an unserved copy of client
  132. // data in KV is only somewhere else to leak from.
  133. const cached = await readSnapshot()
  134. if (!cached.ok) {
  135. console.error(`\nCannot render per-viewer pages: ${cached.reason}.`)
  136. console.error('Run `pnpm dashboard` to cache the sync, then push again.')
  137. console.error('The briefings and payload above were pushed; the pages were not,')
  138. console.error('so the board still shows the previous sync rather than a partial one.')
  139. process.exit(1)
  140. }
  141. const snap = cached.snap
  142. const h = headline(snap)
  143. const history = await readHistory()
  144. const day = rowFrom(h, new Date(snap.syncedAt)).day
  145. const variants = renderVariants(snap, h, history, day, await readBriefings())
  146. const tmp = await mkdtemp(join(tmpdir(), 'meridian-variants-'))
  147. try {
  148. for (const [key, page] of variants) {
  149. // Via a file, not an argv value: a rendered page is ~100KB and would blow
  150. // past the command-line length limit as an argument.
  151. const file = join(tmp, `${key.replace(/[^\w.-]+/g, '_')}.html`)
  152. await writeFile(file, page, 'utf8')
  153. await wrangler(['kv', 'key', 'put', '--binding=DASHBOARD', '--preview', 'false', `dashboard:html:${key}`, `--path=${file}`])
  154. }
  155. } finally {
  156. await rm(tmp, { recursive: true, force: true })
  157. }
  158. console.log(`\nPushed ${variants.size} per-viewer page(s): ${[...variants.keys()].join(', ')}.`)
  159. console.log('Check /health, then open the board.')
  160. }
  161. main().catch((err) => {
  162. console.error(`\npush failed: ${errorMessage(err)}`)
  163. process.exit(1)
  164. })