Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit a527d3690a854b3292068c57786534f5c4488f71
parent a62cf6a859873f94dc5bc1d4c7e3998f4b8adf55
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue,  7 Jul 2026 12:59:12 -0400

Docker export build: parallel per-site container builds + serial deploy

Make buildPipeline.mode="docker" real. The export build has two natures the
single-process pipeline conflated: shared corpus-scale work (single-writer LMDB
index, staging, archive zips) and per-site work (compose + next build) that
collides on the fixed export/{public,out,.next}. Split them:

- Phase A (host, once): build:data, then a new build:archives step that warms
  the shared archive cache for the union of all sites' channels — hoisting
  archive generation out of per-site compose so parallel containers can't race
  the shared cache. archive{Transcripts,LiveChat} gain a readOnly mode; compose
  honors ARCHIVES_READONLY to materialize-only from the warmed cache.
- Phase B (parallel containers, capped by maxParallelBuilds): each site builds
  in Dockerfile.build, read-only over the corpus/index/staging/archive cache,
  writing an isolated out/ under export/.export-builds/<siteId>/ as the host uid.
- Phase C (host, serial): deploy each built site (R2 upload + wrangler),
  tolerant of a single site failing.

UI: a "Build all sites" control on the Deploy page (build-only or build+deploy);
falls back to a serial host build+deploy when no container engine is available.
settings.json and the entrypoint are mounted fresh (not baked stale); outputs
are host-owned (-u). See DEPLOY_DOCKER.md.

Verified on the real corpus incl. a two-site concurrent fan-out: isolated
per-site outputs, shared cache unchanged (no write races), host-owned out/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Diffstat:
M.dockerignore | 10++++++++++
M.gitignore | 2++
ADEPLOY_DOCKER.md | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ADockerfile.build | 44++++++++++++++++++++++++++++++++++++++++++++
Acommon/bin/build-archives.ts | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/bin/compose-site.ts | 13+++++++++++--
Mcommon/controller/archiveLiveChat.ts | 32+++++++++++++++++++++++++++-----
Mcommon/controller/archiveTranscripts.ts | 38+++++++++++++++++++++++++++++++++-----
Adocker/build-site.sh | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/CHANGELOG.md | 3+++
Meditor/app/build/buildAction.ts | 212++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Meditor/app/deploy/buildDeployCore.ts | 325+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Aeditor/app/deploy/components/BuildAllSitesButton.tsx | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/deploy/components/BuildSitesPanel.tsx | 2+-
Meditor/app/deploy/page.tsx | 16+++++++++++++---
Mexport/CHANGELOG.md | 1+
Mexport/package.json | 1+
17 files changed, 951 insertions(+), 42 deletions(-)

diff --git a/.dockerignore b/.dockerignore @@ -8,6 +8,9 @@ editor/test-transcripts/ editor/test-settings.json +# The real settings.json is mounted fresh into build containers (SETTINGS_FILE) — +# baking it would freeze build config (archive storage, size caps) at image time. +settings.json transcripts/ export/public/summaries/ @@ -17,6 +20,12 @@ export/public/code.tar.xz export/public/transcripts.tar.gz export/public/transcripts.tar.xz +# Build staging / caches / per-site outputs — bind-mounted at run time, never baked. +export/.export-index/ +export/.export-builds/ +export/.compose-cache/ +export/.r2-staging/ + .git .github .claude @@ -26,4 +35,5 @@ export/public/transcripts.tar.xz .DS_Store Dockerfile.test +Dockerfile.build .dockerignore diff --git a/.gitignore b/.gitignore @@ -72,6 +72,8 @@ yarn-error.log* /export/.export-index/ # per-site incremental-compose signatures (skip-unchanged cache) /export/.compose-cache/ +# per-site docker build outputs (out/, .next, public/, caches) — one dir per site +/export/.export-builds/ /transcripts/index.mdb/ # homepage (hub) generated public data (regenerate with `pnpm build:homepage`) diff --git a/DEPLOY_DOCKER.md b/DEPLOY_DOCKER.md @@ -0,0 +1,76 @@ +# Docker export build pipeline + +Docker build mode builds **every site in parallel** in isolated containers, then +deploys them serially — a large speedup when you host several sites, and stronger +isolation than the basic single-process build. This is opt-in: set **Build +pipeline → Docker** in Settings (or the toggle on the Deploy page). Basic mode is +unchanged and remains the default. + +## Prerequisites + +- A container engine: **Docker**, or **podman** (set `DOCKER_BIN=podman`). Rootless + podman is a good fit — it maps container files to your host user automatically. +- The editor host still needs Node + pnpm (Phase A and deploy run on the host) and + your Cloudflare/R2 credentials in the environment (see + [DEPLOY_CLOUDFLARE.md](./DEPLOY_CLOUDFLARE.md)). Credentials are **never** passed + into a container — deploy runs on the host. + +The build image is built (and cached) automatically from `Dockerfile.build` the +first time you run; edit **Build image** / **Dockerfile** in Settings to override +the tag/path. + +## How it works + +Trigger it with **Build all sites** on the Deploy page. One managed job runs three +ordered phases: + +1. **Phase A — shared, on the host, once.** `build:data` (search index + + `.export-index` staging) then `build:archives` (warm the shared archive-zip + cache for the union of all sites' channels). Only the host writes this shared + state, so containers never race it. This phase is serial and is the long pole on + a cold build; on a warm rebuild it's near-instant (unchanged channels are + skipped). +2. **Phase B — per-site, in parallel containers.** Each site's `compose:site + + next build` runs in its own container, capped by **Max parallel builds**. Each + writes an isolated `out/` under `export/.export-builds/<siteId>/`. Containers + mount the corpus/index/staging/archive cache **read-only**. (Network is left on: + `next build` fetches the site's fonts from Google via `next/font/google`; + isolation comes from the read-only mounts, per-site output dir, and non-root + user.) +3. **Phase C — deploy, on the host, serially.** After every build finishes, each + built site is deployed in turn (oversize-archive R2 upload, then `wrangler pages + deploy`). A single site failing to build or deploy is reported and skipped; the + rest still ship. + +If no container engine is available, the action logs a notice and falls back to a +serial host build+deploy (one site at a time). + +## Mounts (per Phase-B container) + +| Host | Container | Mode | +|---|---|---| +| `transcripts/` (corpus + `index.mdb` + archive cache) | `/data/transcripts` | ro | +| `export/.export-index` (shared + per-site staging) | `/data/export/.export-index` | ro | +| `export/.export-builds/<siteId>` (public/out/.next/caches) | `/site` | rw | +| `settings.json` (build config, mounted fresh — not baked) | `/data/settings.json` | ro | + +The per-site `/site` mount is persistent, so incremental `next build` (`.next`) and +incremental compose (`.compose-cache`) stay warm across builds. + +## Tuning & environment + +- **Max parallel builds** (setting) — how many site containers run at once. Each + `next build` can use up to ~8 GB; a safe starting point is `floor(RAM_GB / 9)`. +- `DOCKER_BIN` — container binary (default `docker`; e.g. `podman`). +- `DOCKER_BUILD_MEMORY`, `DOCKER_BUILD_CPUS` — optional per-container `--memory` / + `--cpus` caps so a fan-out can't OOM/peg the host. +- `BUILD_ARCHIVES=0` (or the **Skip archive zips** checkbox) — skip the archive + warm + per-site archive materialize for a faster build with no download bundles. + +## Notes + +- Containers run as your host uid/gid (`-u`), so files under `.export-builds/` are + host-owned, not root-owned. +- The image bakes the repo source + deps; a code change rebuilds it, but Docker + layer caching keeps that cheap (deps re-install only when the lockfile moves). +- `.export-builds/` is gitignored and excluded from the image build context. diff --git a/Dockerfile.build b/Dockerfile.build @@ -0,0 +1,44 @@ +# Build image for the docker export pipeline (buildPipeline.mode = "docker"). +# +# Bakes the repo source + installed deps so each per-site build container is +# hermetic and reproducible. The corpus, the shared LMDB index, the .export-index +# staging, and the archive cache are bind-mounted READ-ONLY at run time — never +# baked (they're hundreds of GB and change constantly). Each container writes only +# its per-site output mount (/site). Deploy never runs here; it stays on the host. +# +# Entry: docker/build-site.sh runs `compose:site + next build` for one SITE_ID. +FROM node:20-bookworm-slim + +# Archive compressors the export build shells out to. `zip` is the default format; +# `tar`/`xz`/`gzip` cover the other configurable archive formats. +RUN apt-get update \ + && apt-get install -y --no-install-recommends zip tar xz-utils gzip \ + && rm -rf /var/lib/apt/lists/* + +# Install pnpm as a plain global binary (NOT corepack): the fan-out containers run +# fully offline (--network=none) as an arbitrary host uid with HOME=/tmp, where +# corepack — lacking a packageManager pin — would try to fetch pnpm from the +# registry and fail. A global install needs no network at run time. +RUN npm install -g pnpm@9.15.4 + +WORKDIR /repo + +# Install deps first for layer caching — rebuilds only when a manifest or the +# lockfile moves. `onlyBuiltDependencies` in pnpm-workspace.yaml rebuilds the +# native modules (lmdb, msgpackr-extract, esbuild). +COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ +COPY common/package.json common/package.json +COPY export/package.json export/package.json +RUN pnpm install --frozen-lockfile + +# Bake source last so a code change only re-runs from here. +COPY . . + +# Containers run with `-u <host-uid>` (so /site outputs are host-owned, not root). +# Next writes a couple of fixed-location files into the export package dir +# (next-env.d.ts, tsconfig.tsbuildinfo) and the entrypoint symlinks .next/out from +# there — so that one dir must be writable by an arbitrary runtime uid. The image +# is ephemeral and isolated (--network=none), so widening it here is harmless. +RUN chmod -R a+rwX /repo/export + +ENTRYPOINT ["bash", "docker/build-site.sh"] diff --git a/common/bin/build-archives.ts b/common/bin/build-archives.ts @@ -0,0 +1,91 @@ +#!/usr/bin/env tsx +// Warm the shared, persistent archive cache (transcripts/export/archives) for the +// UNION of every enabled site's member channels, in a single pass, on the host. +// +// This hoists archive-zip *generation* out of the per-site compose. Under the +// docker build pipeline the per-site compose runs in parallel containers over a +// read-only mount of this cache (ARCHIVES_READONLY=1) — so generation must happen +// once, up front, or two sites sharing a cold channel would race to write the same +// <slug>.zip. Running it here (Phase A, serial, before the fan-out) makes every +// container's compose a pure materialize. +// +// It's signature-gated (see channelSignature.ts) exactly like the compose path, +// so an unchanged channel is reused, not re-zipped — this is a no-op on a warm +// cache and also speeds the basic (non-docker) build by collapsing N per-site +// passes into one union pass. +import { getPaths } from "../lib/paths"; +import { getSettings } from "../lib/settings"; +import { listSites } from "../lib/site"; +import { openChannelSigner } from "../lib/channelSignature"; +import { + archiveCacheDir, + archiveTranscripts, +} from "../controller/archiveTranscripts"; +import { archiveLiveChat } from "../controller/archiveLiveChat"; + +async function main(): Promise<void> { + // Honor the same global/per-build opt-outs the per-site compose respects + // (archivesEnabled in compose-site.ts). The per-site `archives` flag is applied + // below when building the union. + if (getSettings().buildArchives === false) { + console.log("[archives] disabled globally (buildArchives=false) — nothing to warm."); + return; + } + if (process.env.BUILD_ARCHIVES === "0") { + console.log("[archives] BUILD_ARCHIVES=0 — skipping archive cache warm."); + return; + } + + const paths = getPaths(); + const sites = listSites(paths); + + // Union of member slugs across sites that ship archives. A channel shared by + // several sites is warmed once; the readonly per-site compose then materializes + // its subset. + const union = new Set<string>(); + for (const site of sites) { + if (site.archives === false) continue; + for (const c of site.channels) union.add(c.slug); + } + const channelSlugs = [...union].sort(); + if (channelSlugs.length === 0) { + console.log("[archives] no archive-enabled member channels — nothing to warm."); + return; + } + + console.log( + `[archives] warming shared cache for ${channelSlugs.length} channel(s) across ${sites.length} site(s)…`, + ); + + // Match the per-site compose's build options EXACTLY so the sidecar signatures + // line up and the readonly compose sees cache hits (compose-site.ts uses + // `{ format: "zip" }`, i.e. the resolved defaults). + const build = { format: "zip" as const }; + const channelConcurrency = Number(process.env.ARCHIVE_CHANNEL_CONCURRENCY) || 4; + const log = (m: string) => { + if (m) console.log(`[archives] ${m}`); + }; + + const signer = openChannelSigner(paths); + try { + const common = { + paths, + channelSlugs, + outDir: archiveCacheDir(paths), + build, + channelConcurrency, + onLog: log, + signer, + }; + await archiveTranscripts(common); + await archiveLiveChat(common); + } finally { + await signer.close(); + } + console.log("[archives] cache warm complete."); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/common/bin/compose-site.ts b/common/bin/compose-site.ts @@ -344,8 +344,16 @@ async function composeArchives( // another site this run) is not re-zipped. One signer view is shared across the // transcripts + live-chat passes. compose then copies this site's member subset // out of the cache into public/archives below. + // + // ARCHIVES_READONLY (set by the docker per-site build container): the cache was + // already warmed on the host by `build:archives`, and here it's a read-only + // mount — so never generate or open the index, just materialize what's cached. + // This is what keeps parallel per-site containers from racing writes to the one + // shared cache. const cacheDir = archiveCacheDir(paths); - const signer = openChannelSigner(paths); + const readOnly = process.env.ARCHIVES_READONLY === "1"; + if (readOnly) log("read-only cache mode — materializing pre-built archives"); + const signer = readOnly ? undefined : openChannelSigner(paths); let transcripts: Awaited<ReturnType<typeof archiveTranscripts>>; let liveChat: Awaited<ReturnType<typeof archiveLiveChat>>; try { @@ -355,6 +363,7 @@ async function composeArchives( outDir: cacheDir, build, onLog: log, + readOnly, signer, }; // Per-channel archives only — the combined "whole-site" bundles were dropped @@ -363,7 +372,7 @@ async function composeArchives( transcripts = await archiveTranscripts(common); liveChat = await archiveLiveChat(common); } finally { - await signer.close(); + if (signer) await signer.close(); } // Materialize each cached archive into this site's served tree. Hard-link when diff --git a/common/controller/archiveLiveChat.ts b/common/controller/archiveLiveChat.ts @@ -70,6 +70,9 @@ export type ArchiveLiveChatOptions = { // Reuse an already-open per-channel signer across passes (see // ArchiveTranscriptsOptions.signer). signer?: ChannelSigner; + // Materialize-only mode for the docker per-site compose (see + // ArchiveTranscriptsOptions.readOnly). + readOnly?: boolean; }; export type ArchiveLiveChatResult = { @@ -210,15 +213,19 @@ export async function archiveLiveChat( ); const outDir = opts.outDir ?? archivesDir(opts.paths); - await mkdir(outDir, { recursive: true }); - await mkdir(stagingDir(opts.paths), { recursive: true }); + if (!opts.readOnly) { + await mkdir(outDir, { recursive: true }); + await mkdir(stagingDir(opts.paths), { recursive: true }); + } const allChannels = await listChannels(opts.paths); const target = opts.channelSlugs ? allChannels.filter((c) => opts.channelSlugs!.includes(c.slug)) : allChannels; - const signer = opts.signer ?? openChannelSigner(opts.paths); + const signer = opts.readOnly + ? null + : (opts.signer ?? openChannelSigner(opts.paths)); const channelLimit = pLimit(opts.channelConcurrency ?? 4); let reused = 0; @@ -234,10 +241,25 @@ export async function archiveLiveChat( outDir, `${ch.slug}.${ARCHIVE_INFIX}.${archiveExtension(build.format)}`, ); + // Materialize-only: consume the pre-warmed cache, never generate. + if (opts.readOnly) { + if (await fileExists(archivePath)) { + const prev = await readArchiveSidecar(archivePath); + return { + archive: { + slug: ch.slug, + archivePath, + liveChatCount: prev?.count ?? 0, + }, + reused: true, + }; + } + return { skipped: { slug: ch.slug, reason: "no cached live-chat archive" } }; + } // `live-chat:` marks this signature as the live-chat variant so it never // collides with the transcripts archive's sidecar (different files anyway, // but keeps the two kinds' signatures independent). - const sig = signer.signature( + const sig = signer!.signature( ch.slug, `live-chat:${archiveSignatureExtra(build, ch)}`, ); @@ -306,7 +328,7 @@ export async function archiveLiveChat( } } } finally { - if (!opts.signer) await signer.close(); + if (!opts.signer && signer) await signer.close(); } if (reused > 0) log(`Reused ${reused} unchanged cached live-chat archive(s).`); diff --git a/common/controller/archiveTranscripts.ts b/common/controller/archiveTranscripts.ts @@ -75,6 +75,13 @@ export type ArchiveTranscriptsOptions = { // caller can share one LMDB view across the transcripts + live-chat passes. // When omitted, one is opened for the duration of this call. signer?: ChannelSigner; + // Materialize-only mode for the docker per-site compose: never stage, compress, + // or write under the (read-only-mounted) cache. Report the already-cached + // archive for each member channel — the host's build:archives pass warmed the + // cache first. A member with no cached zip is a channel that legitimately had + // nothing to archive (build:archives ran over a superset of these slugs), so it + // is reported as skipped, exactly as a fresh generation would omit it. + readOnly?: boolean; }; export type ArchiveTranscriptsResult = { @@ -379,15 +386,21 @@ export async function archiveTranscripts( ); const outDir = opts.outDir ?? archivesDir(opts.paths); - await mkdir(outDir, { recursive: true }); - await mkdir(stagingDir(opts.paths), { recursive: true }); + // In readOnly mode outDir is the shared cache on a read-only mount — never + // create dirs or touch staging there. + if (!opts.readOnly) { + await mkdir(outDir, { recursive: true }); + await mkdir(stagingDir(opts.paths), { recursive: true }); + } const allChannels = await listChannels(opts.paths); const target = opts.channelSlugs ? allChannels.filter((c) => opts.channelSlugs!.includes(c.slug)) : allChannels; - const signer = opts.signer ?? openChannelSigner(opts.paths); + const signer = opts.readOnly + ? null + : (opts.signer ?? openChannelSigner(opts.paths)); const channelLimit = pLimit(opts.channelConcurrency ?? 4); let reused = 0; @@ -405,7 +418,22 @@ export async function archiveTranscripts( outDir, `${ch.slug}.${archiveExtension(build.format)}`, ); - const sig = signer.signature(ch.slug, archiveSignatureExtra(build, ch)); + // Materialize-only: consume the pre-warmed cache, never generate. + if (opts.readOnly) { + if (await fileExists(archivePath)) { + const prev = await readArchiveSidecar(archivePath); + return { + archive: { + slug: ch.slug, + archivePath, + transcriptCount: prev?.count ?? 0, + }, + reused: true, + }; + } + return { skipped: { slug: ch.slug, reason: "no cached archive" } }; + } + const sig = signer!.signature(ch.slug, archiveSignatureExtra(build, ch)); if (sig) { const prev = await readArchiveSidecar(archivePath); if (prev && prev.signature === sig && (await fileExists(archivePath))) { @@ -473,7 +501,7 @@ export async function archiveTranscripts( } } } finally { - if (!opts.signer) await signer.close(); + if (!opts.signer && signer) await signer.close(); } if (reused > 0) log(`Reused ${reused} unchanged cached archive(s).`); diff --git a/docker/build-site.sh b/docker/build-site.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Per-site build entrypoint for the docker export pipeline. Runs inside the +# Dockerfile.build image, once per SITE_ID, in a parallel fan-out on the host. +# +# The host has already run Phase A (build:data + build:archives), so the shared +# LMDB index, the .export-index staging, and the archive cache are all warm and +# mounted READ-ONLY. This container only composes ITS site's public/ and runs +# `next build`, writing everything into the per-site mount at /site. +# +# Expected run-time mounts (see runDockerBuildAllPhase): +# <transcriptsDir> -> /data/transcripts (ro) +# <export/.export-index> -> /data/export/.export-index (ro) +# <exportBuildsDir>/<siteId> -> /site (rw) +set -euo pipefail + +: "${SITE_ID:?SITE_ID is required}" + +export HOME="${HOME:-/tmp}" +export NODE_ENV=production +export TRANSCRIPTS_DIR=/data/transcripts +export EXPORT_INDEX_DIR=/data/export/.export-index +export EXPORT_PUBLIC_DIR=/site/public +# Materialize-only: consume the host-warmed archive cache (read-only mount), +# never re-generate — see compose-site.ts / archiveTranscripts.ts. +export ARCHIVES_READONLY=1 + +# Persist Next's build cache (.next) into the per-site mount so incremental +# next builds stay warm across runs. A symlink is safe for .next because Next +# reuses — never wholesale-replaces — its cache dir. The export dir was made +# writable in the image so this symlink (and Next's next-env.d.ts) can be created +# as an arbitrary runtime uid. +mkdir -p /site/.next /site/out /site/public +rm -rf export/.next +ln -s /site/.next export/.next + +echo "[build-site] building site '${SITE_ID}'" +# build:nodata = compose:site + next build, WITHOUT the prebuild data phase +# (already done on the host). SITE_ID selects the site in compose-site.ts. +# compose writes straight into the mounted /site/public (EXPORT_PUBLIC_DIR). +pnpm --filter export run build:nodata + +# next build writes export/out as a FRESH real dir (it removes+recreates out, so +# a symlink there wouldn't survive) — publish it into the per-site mount. Reached +# only when the build succeeded (set -e aborts otherwise). +echo "[build-site] publishing out/ -> /site/out" +rm -rf /site/out +mkdir -p /site/out +cp -a export/out/. /site/out/ +echo "[build-site] done -> /site/out ($(find /site/out -type f | wc -l) files)" diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## [Unreleased] +- **Docker build mode is now real: build every site in parallel, then deploy them serially.** The `Docker` build mode (Settings → Build pipeline) was previously a stub that fell back to the basic build. It now runs a proper pipeline, driven by a new **Build all sites** control on the Deploy page (one job, one log, one Cancel). The shared, corpus-scale work — the search index, the per-site staging, and the downloadable archive zips — runs **once on the host**; then each site's `compose + next build` runs in its **own container in parallel** (capped by the **Max parallel builds** setting), each writing an isolated per-site `out/` under `export/.export-builds/<siteId>/`; then the built sites **deploy serially** on the host (R2 upload + `wrangler pages deploy`), tolerant of a single site failing. Containers are read-only over the shared corpus/index/archive cache and run as your host user so outputs aren't root-owned. The image (`Dockerfile.build`, tag from **Build image**) is built/reused via Docker layer caching; when no container engine is available the action falls back to a serial host build+deploy. New env knobs: `DOCKER_BIN` (e.g. `podman`), `DOCKER_BUILD_MEMORY`/`DOCKER_BUILD_CPUS` (per-container caps). See `editor/app/deploy/buildDeployCore.ts` (`runDockerBuildAllPhase`/`runDockerDeployAllPhase`), `editor/app/build/buildAction.ts` (`buildAndDeployAllSitesAction`/`buildAllSitesAction`), `editor/app/deploy/components/BuildAllSitesButton.tsx`, `Dockerfile.build`, `docker/build-site.sh`, `common/bin/build-archives.ts`, and **[DEPLOY_DOCKER.md](../DEPLOY_DOCKER.md)**. + ## [0.7.3] - 2026-07-07 - **Deploys no longer re-upload unchanged oversize archives to R2.** The deploy step used to stream every over-cap archive to R2 on every deploy, even ones byte-identical to what was already there. Because the export build now reuses an unchanged channel's cached zip verbatim, the upload step first does a cheap `HeadObject` and skips any archive whose R2 object already has the same size — so a redeploy after changing one channel only re-uploads that channel's oversize bundle. See `editor/app/deploy/buildDeployCore.ts`. - **New "Search aliases" page for authoring known-term suggestions.** A concept is often spelled many ways — transcription in particular mangles them (AI expands `loli` to `lolly`/`loly`) — so searching one spelling silently misses the rest. This page authors a curated dictionary where one entry groups all the trigger spellings of a concept with a single robust replacement (e.g. `\blol(i|ly)`). A **Global** section applies to every site and ships a small seeded default set you can edit or remove; a **per-site** section (shown when a site is selected in the sidebar) adds to or overrides the global list by id — reuse a global id and mark it disabled to hide that alias for just that site. Nothing is forced: the entries only surface a *suggestion* in the viewer's search box, which the searcher can apply or ignore. Stored as `search-aliases.json` (global under the data dir, per-site under `sites/<id>/`) and merged into each site's bundle at build time. See `editor/app/aliases/{page.tsx,actions.ts,EditorAliasesClient.tsx}`, `common/lib/{searchAliases,aliasesStore}.ts`, `editor/app/lib/nav.ts`, and `editor/e2e/aliases.spec.ts`. diff --git a/editor/app/build/buildAction.ts b/editor/app/build/buildAction.ts @@ -15,16 +15,22 @@ import { archiveCombinedLiveChat, } from "yt-dlp-transcript-common/controller/archiveLiveChat"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; -import { getSite } from "yt-dlp-transcript-common/lib/site"; +import { getSite, listSites, type Site } from "yt-dlp-transcript-common/lib/site"; import { runManagedFunction, type StreamActionResult, } from "yt-dlp-transcript-common/jobs/streamCommand"; import { + dockerAvailable, + dockerSiteOutDir, resolveOutDir, runArchiveUploadIntoLog, runBuildPhase, runDeployIntoLog, + runDockerBuildAllPhase, + runDockerDeployAllPhase, + type SiteBuildOutcome, + type SiteDeployOutcome, } from "../deploy/buildDeployCore"; const DEFAULT_BUILD_QUEUE = "build"; @@ -237,3 +243,207 @@ export async function archiveCombinedLiveChatAction( }, }); } + +// Build EVERY configured site, then deploy each — the docker-pipeline entry point. +// With a container engine available: run the shared data phase + archive warm +// once, fan the per-site builds out in parallel (capped by maxParallelBuilds), +// then deploy the built sites SERIALLY on the host. Without one: fall back to a +// serial host build+deploy per site. One managed job (single streamed log + +// Cancel), serialized on the deploy queue so it never overlaps another deploy. +export async function buildAndDeployAllSitesAction( + skipArchives?: boolean, +): Promise<StreamActionResult> { + const paths = getPaths(); + const sites = listSites(paths); + if (sites.length === 0) return { ok: false, error: "No sites configured." }; + return runManagedFunction({ + kind: "build-deploy-all", + queueKey: DEPLOY_QUEUE, + paths, + fn: async (onLog, signal) => { + const useDocker = await dockerAvailable(signal); + if (signal.aborted) return; + + if (!useDocker) { + onLog( + "[notice] No container engine available (start the daemon, or set " + + "DOCKER_BIN) — falling back to serial host build+deploy.\n", + ); + const deploys = await basicBuildAndDeployAll( + onLog, + signal, + sites, + paths, + skipArchives, + ); + if (signal.aborted) return; + summarize(onLog, deploys); + return; + } + + // Docker: build all (Phase A + B), barrier, then deploy all serially (C). + const outcomes = await runDockerBuildAllPhase(onLog, signal, sites, paths, { + skipArchives, + }); + if (signal.aborted) return; + const builtOk = new Set( + outcomes.filter((o) => o.code === 0).map((o) => o.siteId), + ); + onLog(`\n=== Phase C: deploy (${builtOk.size}/${sites.length} built) ===`); + const deploys = await runDockerDeployAllPhase( + onLog, + signal, + sites, + builtOk, + paths, + (id) => dockerSiteOutDir(paths, id), + ); + if (signal.aborted) return; + summarize(onLog, deploys, outcomes); + }, + }); +} + +// Build EVERY configured site WITHOUT deploying — e.g. to warm caches or validate +// that each site composes and next-builds. Docker mode fans the builds out in +// parallel; otherwise it's a serial host loop (whose shared export/out is +// overwritten per site, so only the last survives — build-all is chiefly a docker +// feature). Queued on the build queue. +export async function buildAllSitesAction( + skipArchives?: boolean, +): Promise<StreamActionResult> { + const paths = getPaths(); + const sites = listSites(paths); + if (sites.length === 0) return { ok: false, error: "No sites configured." }; + return runManagedFunction({ + kind: "build-all", + queueKey: DEFAULT_BUILD_QUEUE, + paths, + fn: async (onLog, signal) => { + const useDocker = await dockerAvailable(signal); + if (signal.aborted) return; + let outcomes: SiteBuildOutcome[]; + if (useDocker) { + outcomes = await runDockerBuildAllPhase(onLog, signal, sites, paths, { + skipArchives, + }); + } else { + onLog( + "[notice] No container engine available — building sites serially on the host.\n", + ); + outcomes = []; + for (let i = 0; i < sites.length; i++) { + if (signal.aborted) return; + const site = sites[i]; + onLog(`\n=== Build ${site.siteId} (${i + 1}/${sites.length}) ===`); + const code = await runBuildPhase(onLog, signal, site.siteId, paths, { + skipData: i > 0, + skipArchives, + }); + outcomes.push({ siteId: site.siteId, code }); + } + } + if (signal.aborted) return; + const ok = outcomes.filter((o) => o.code === 0).length; + const failed = outcomes + .filter((o) => o.code !== 0) + .map((o) => `${o.siteId} (exit ${o.code})`); + onLog(`\n=== Summary: ${ok}/${sites.length} built ===`); + if (failed.length) throw new Error(`Build failures: ${failed.join(", ")}`); + }, + }); +} + +// Serial host build+deploy fallback for buildAndDeployAllSitesAction when no +// container engine is available. Interleaved (build A → deploy A → build B …) +// because the basic build shares export/out — building all first would leave only +// the last site's bundle. Rebuilds the pool-wide data once (first site). +async function basicBuildAndDeployAll( + onLog: (line: string) => void, + signal: AbortSignal, + sites: Site[], + paths: ReturnType<typeof getPaths>, + skipArchives?: boolean, +): Promise<SiteDeployOutcome[]> { + const basicOut = resolveOutDir("", paths); + const deploys: SiteDeployOutcome[] = []; + for (let i = 0; i < sites.length; i++) { + if (signal.aborted) break; + const site = sites[i]; + onLog(`\n=== Build ${site.siteId} (${i + 1}/${sites.length}) ===`); + const code = await runBuildPhase(onLog, signal, site.siteId, paths, { + skipData: i > 0, + skipArchives, + }); + if (signal.aborted) break; + if (code !== 0) { + onLog(`[${site.siteId}] build FAILED (exit ${code}) — skipping deploy`); + deploys.push({ + siteId: site.siteId, + status: "skipped", + reason: `build exit ${code}`, + }); + continue; + } + if (!site.cloudflareProject) { + onLog(`[${site.siteId}] deploy skipped — no Cloudflare project configured`); + deploys.push({ + siteId: site.siteId, + status: "skipped", + reason: "no cloudflareProject", + }); + continue; + } + onLog(`=== Deploy ${site.siteId} ===`); + const up = await runArchiveUploadIntoLog(onLog, signal, site, paths); + if (signal.aborted) break; + if (up !== 0) { + onLog(`[${site.siteId}] deploy FAILED — R2 upload exit ${up}`); + deploys.push({ + siteId: site.siteId, + status: "failed", + reason: `R2 upload exit ${up}`, + }); + continue; + } + const dep = await runDeployIntoLog(onLog, signal, site, basicOut, paths); + if (signal.aborted) break; + if (dep !== 0) { + onLog(`[${site.siteId}] deploy FAILED — exit ${dep}`); + deploys.push({ + siteId: site.siteId, + status: "failed", + reason: `deploy exit ${dep}`, + }); + continue; + } + onLog(`[${site.siteId}] deployed.`); + deploys.push({ siteId: site.siteId, status: "deployed" }); + } + return deploys; +} + +// Log the final tally and THROW if anything failed, so the managed job ends in a +// failed state the UI surfaces. Skips are reported but not failures. +function summarize( + onLog: (line: string) => void, + deploys: SiteDeployOutcome[], + builds?: SiteBuildOutcome[], +): void { + const deployed = deploys + .filter((d) => d.status === "deployed") + .map((d) => d.siteId); + const skipped = deploys.filter((d) => d.status === "skipped"); + const failed = [ + ...(builds ?? []) + .filter((b) => b.code !== 0) + .map((b) => `${b.siteId} (build exit ${b.code})`), + ...deploys + .filter((d) => d.status === "failed") + .map((d) => `${d.siteId} (deploy: ${d.reason})`), + ]; + onLog(`\n=== Summary: ${deployed.length} deployed ===`); + if (deployed.length) onLog(` deployed: ${deployed.join(", ")}`); + for (const s of skipped) onLog(` skipped ${s.siteId}: ${s.reason}`); + if (failed.length) throw new Error(`Failures: ${failed.join(", ")}`); +} diff --git a/editor/app/deploy/buildDeployCore.ts b/editor/app/deploy/buildDeployCore.ts @@ -5,9 +5,8 @@ // here as plain helpers and import them into the thin action wrappers. import path from "node:path"; -import { readdir } from "node:fs/promises"; -import { createReadStream } from "node:fs"; -import { stat } from "node:fs/promises"; +import { mkdir, readdir, stat } from "node:fs/promises"; +import { createReadStream, existsSync } from "node:fs"; import { S3Client, HeadObjectCommand } from "@aws-sdk/client-s3"; import { Upload } from "@aws-sdk/lib-storage"; import { runChildIntoLog } from "yt-dlp-transcript-common/jobs/runChild"; @@ -15,21 +14,32 @@ import type { Paths } from "yt-dlp-transcript-common/lib/paths"; import { getSettings } from "yt-dlp-transcript-common/lib/settings"; import type { Site } from "yt-dlp-transcript-common/lib/site"; -// Where the static output that should be deployed for a site lives. Today the -// build always writes export/out (basic build), so deploy reads it in both -// modes. The Docker follow-up will write each site's `out/` to -// paths.exportBuildsDir/<siteId> and switch this to return that per-site dir in -// docker mode; until then docker mode falls back to a basic build (see -// runBuildPhase), so export/out is correct everywhere. +// Where the basic (host) build writes the static bundle to deploy: the fixed +// export/out, composed one site at a time. The docker fan-out writes per-site +// out/ dirs instead — see dockerSiteOutDir. export function resolveOutDir(_siteId: string, paths: Paths): string { return path.join(paths.exportDir, "out"); } -// Run the build phase for one site, streaming into `onLog`, returning the exit -// code. Routes on the persisted build mode: "basic" runs `pnpm run build` in -// export/ (serialized upstream on the build queue, since the export/ tree is -// shared); "docker" is a follow-up — until the container pipeline lands it logs -// a notice and falls back to the basic build so builds always succeed. +// Where a docker per-site container writes its built bundle (mounted as /site/out +// inside the container). Isolated per site so parallel builds never collide. +export function dockerSiteOutDir(paths: Paths, siteId: string): string { + return path.join(paths.exportBuildsDir, siteId, "out"); +} + +// Where a docker per-site container stages oversize archives for R2 upload. The +// container's EXPORT_PUBLIC_DIR is /site/public (host exportBuildsDir/<siteId>/ +// public), so compose writes .r2-staging as its sibling under exportBuildsDir. +export function dockerSiteStagingDir(paths: Paths, siteId: string): string { + return path.join(paths.exportBuildsDir, siteId, ".r2-staging", siteId, "archives"); +} + +// Run the basic (host) build phase for one site, streaming into `onLog`, +// returning the exit code. Runs `pnpm run build` in export/ (serialized upstream +// on the build queue, since the export/ tree is shared). This is the single-site +// build for basic mode, and the fallback the all-sites docker action drops to +// when no container engine is available. The parallel per-site container path +// lives in runDockerBuildAllPhase. export async function runBuildPhase( onLog: (line: string) => void, signal: AbortSignal, @@ -37,13 +47,6 @@ export async function runBuildPhase( paths: Paths, opts?: { skipData?: boolean; skipArchives?: boolean }, ): Promise<number> { - const { mode } = getSettings().buildPipeline; - if (mode === "docker") { - onLog( - "[notice] Docker build mode isn't wired up yet — running the basic build " + - "for now. Parallel container builds arrive in a follow-up.\n", - ); - } // When skipping the data rebuild, run the `build:nodata` script instead of // `build`. `build:nodata` has the same body (compose:site + next build) but a // different name, so npm's `prebuild` lifecycle hook (which runs build:data = @@ -77,9 +80,10 @@ export async function runBuildPhase( }); } -// Where compose-site staged this site's oversize archives for R2 upload. Kept -// outside export/public so they never ship as Pages assets. Mirrors the path -// composeArchives writes to in common/bin/compose-site.ts. +// Where the basic (host) compose staged this site's oversize archives for R2 +// upload. Kept outside export/public so they never ship as Pages assets. Mirrors +// the path composeArchives writes to in common/bin/compose-site.ts. The docker +// fan-out uses dockerSiteStagingDir instead, passed explicitly. function archiveStagingDir(siteId: string, paths: Paths): string { return path.join( path.dirname(paths.exportPublicDir), @@ -119,11 +123,15 @@ export async function runArchiveUploadIntoLog( signal: AbortSignal, site: Site, paths: Paths, + // Where the oversize archives were staged. Defaults to the basic host location; + // the docker deploy phase passes dockerSiteStagingDir since its container wrote + // .r2-staging under exportBuildsDir/<siteId> instead. + stagingDirOverride?: string, ): Promise<number> { const bucket = getSettings().archiveStorage?.bucket?.trim(); if (!bucket) return 0; - const stagingDir = archiveStagingDir(site.siteId, paths); + const stagingDir = stagingDirOverride ?? archiveStagingDir(site.siteId, paths); let files: string[]; try { files = (await readdir(stagingDir)).filter((f) => !f.startsWith(".")); @@ -250,3 +258,270 @@ export async function runDeployIntoLog( }, }); } + +// --------------------------------------------------------------------------- +// Docker export pipeline (buildPipeline.mode = "docker") +// +// Three ordered phases (see DEPLOY_DOCKER.md): +// A) HOST, serial: build:data (shared LMDB + .export-index) then build:archives +// (warm the shared archive cache). One writer of the shared state. +// B) CONTAINERS, parallel (cap maxParallelBuilds): each site's compose + next +// build in its own container, read-only over the shared caches, writing only +// its per-site out/ under exportBuildsDir/<siteId>. +// C) HOST, serial: deploy each built site (handled by runDockerDeployAllPhase). +// --------------------------------------------------------------------------- + +// The container engine binary. Defaults to `docker`; podman is a CLI drop-in +// (and rootless podman yields host-owned outputs without needing `-u`). +function dockerBin(): string { + return process.env.DOCKER_BIN?.trim() || "docker"; +} + +export type SiteBuildOutcome = { siteId: string; code: number }; +export type SiteDeployOutcome = { + siteId: string; + status: "deployed" | "skipped" | "failed"; + reason?: string; +}; + +// Cheap probe: is the container engine installed and its daemon reachable? Used +// to fall back to serial host builds when docker isn't available. +export async function dockerAvailable(signal: AbortSignal): Promise<boolean> { + const code = await runChildIntoLog(() => {}, signal, { + command: dockerBin(), + args: ["version"], + cwd: process.cwd(), + }); + return code === 0; +} + +// Run a host-side export pnpm script (build:data / build:archives) for Phase A. +// These are pool-wide: no SITE_ID, and no EXPORT_PUBLIC_DIR override so the +// shared index/staging land at their canonical export/.export-index location +// (exactly what the fan-out containers mount read-only). +async function runHostScript( + onLog: (line: string) => void, + signal: AbortSignal, + paths: Paths, + script: string, + extraEnv?: Record<string, string>, +): Promise<number> { + return runChildIntoLog(onLog, signal, { + command: "pnpm", + args: ["run", script], + cwd: paths.exportDir, + env: { + ...process.env, + NODE_ENV: "production", + TRANSCRIPTS_DIR: paths.transcriptsDir, + ...extraEnv, + }, + }); +} + +// Build (or reuse cached layers of) the per-site build image. +async function ensureBuildImage( + onLog: (line: string) => void, + signal: AbortSignal, + paths: Paths, +): Promise<number> { + const { dockerImage, dockerfile } = getSettings().buildPipeline; + onLog(`[docker] building image "${dockerImage}" from ${dockerfile} (cached layers reused)`); + return runChildIntoLog(onLog, signal, { + command: dockerBin(), + args: ["build", "-f", dockerfile, "-t", dockerImage, "."], + cwd: paths.monorepoRoot, + }); +} + +// Run ONE site's build in a container. Mounts the corpus, the shared LMDB index, +// and the .export-index staging read-only; mounts the per-site output dir rw. +// Streams with a [siteId] prefix. Network is left ENABLED — `next build` uses +// next/font/google, which fetches the site's fonts from Google at build time; +// `--network=none` would fail the build. Isolation still comes from the per-site +// output dir, the read-only shared mounts, and the non-root `-u` user. +async function runDockerBuildOne( + onLog: (line: string) => void, + signal: AbortSignal, + siteId: string, + paths: Paths, + opts?: { skipArchives?: boolean }, +): Promise<number> { + const { dockerImage } = getSettings().buildPipeline; + const siteDir = path.join(paths.exportBuildsDir, siteId); + // Pre-create the mount target as the host user so container-written files are + // host-owned (paired with `-u` below), not created root-owned by the daemon. + await mkdir(siteDir, { recursive: true }); + + const args = ["run", "--rm", "--init"]; + const uid = typeof process.getuid === "function" ? process.getuid() : null; + const gid = typeof process.getgid === "function" ? process.getgid() : null; + if (uid !== null && gid !== null) args.push("-u", `${uid}:${gid}`); + // Optional resource caps so N parallel builds (each next build can use ~8 GB) + // don't OOM the host. Sized by the operator; see DEPLOY_DOCKER.md. + const mem = process.env.DOCKER_BUILD_MEMORY?.trim(); + const cpus = process.env.DOCKER_BUILD_CPUS?.trim(); + if (mem) args.push("--memory", mem); + if (cpus) args.push("--cpus", cpus); + args.push( + "-v", `${paths.transcriptsDir}:/data/transcripts:ro`, + "-v", `${paths.exportIndexDir}:/data/export/.export-index:ro`, + "-v", `${siteDir}:/site`, + "-e", `SITE_ID=${siteId}`, + ); + // Mount the host settings.json fresh (build config: archive storage, size caps) + // rather than relying on a possibly-stale copy — it is NOT baked into the image. + if (existsSync(paths.settingsFile)) { + args.push( + "-v", `${paths.settingsFile}:/data/settings.json:ro`, + "-e", "SETTINGS_FILE=/data/settings.json", + ); + } + // Mount the entrypoint fresh over the baked copy so a script tweak takes effect + // without an image rebuild (the image still bakes it as a fallback). + const entrypoint = path.join(paths.monorepoRoot, "docker", "build-site.sh"); + if (existsSync(entrypoint)) { + args.push("-v", `${entrypoint}:/repo/docker/build-site.sh:ro`); + } + if (opts?.skipArchives) args.push("-e", "BUILD_ARCHIVES=0"); + args.push(dockerImage); + + return runChildIntoLog(onLog, signal, { + command: dockerBin(), + args, + cwd: paths.monorepoRoot, + label: `[${siteId}] `, + }); +} + +// Phases A + B. Returns each site's build exit code (0 = ok). Throws only on an +// infrastructure failure (data phase / archive warm / image build) that aborts +// the whole run before any site could build; per-site build failures are +// returned, not thrown, so one bad site never blocks the rest. +export async function runDockerBuildAllPhase( + onLog: (line: string) => void, + signal: AbortSignal, + sites: Site[], + paths: Paths, + opts?: { skipArchives?: boolean }, +): Promise<SiteBuildOutcome[]> { + const { maxParallelBuilds } = getSettings().buildPipeline; + + // --- Phase A: shared data + archive cache (host, serial) --- + onLog("=== Phase A: shared data + archive cache (host) ==="); + const dataCode = await runHostScript(onLog, signal, paths, "build:data"); + if (signal.aborted) return []; + if (dataCode !== 0) throw new Error(`Data phase failed (exit ${dataCode}).`); + if (!opts?.skipArchives) { + const archCode = await runHostScript(onLog, signal, paths, "build:archives"); + if (signal.aborted) return []; + if (archCode !== 0) throw new Error(`Archive cache warm failed (exit ${archCode}).`); + } + + const imgCode = await ensureBuildImage(onLog, signal, paths); + if (signal.aborted) return []; + if (imgCode !== 0) throw new Error(`Docker image build failed (exit ${imgCode}).`); + + // --- Phase B: per-site fan-out (containers, parallel) --- + onLog( + `=== Phase B: building ${sites.length} site(s), up to ${maxParallelBuilds} in parallel ===`, + ); + return runWithConcurrency(sites, maxParallelBuilds, async (site) => { + if (signal.aborted) return { siteId: site.siteId, code: 1 }; + const code = await runDockerBuildOne(onLog, signal, site.siteId, paths, opts); + onLog(`[${site.siteId}] build ${code === 0 ? "ok" : `FAILED (exit ${code})`}`); + return { siteId: site.siteId, code }; + }); +} + +// Phase C: deploy each built site SERIALLY on the host, after the build barrier. +// Partial-failure tolerant — a site that fails to upload/deploy is recorded and +// the loop continues. Sites that failed to build, or have no Cloudflare project, +// are skipped. `outDirFor` resolves each site's built bundle (docker: per-site; +// basic fallback: export/out). +export async function runDockerDeployAllPhase( + onLog: (line: string) => void, + signal: AbortSignal, + sites: Site[], + builtOk: Set<string>, + paths: Paths, + outDirFor: (siteId: string) => string, +): Promise<SiteDeployOutcome[]> { + const outcomes: SiteDeployOutcome[] = []; + for (const site of sites) { + if (signal.aborted) break; + if (!builtOk.has(site.siteId)) { + onLog(`[${site.siteId}] deploy skipped — build failed`); + outcomes.push({ siteId: site.siteId, status: "skipped", reason: "build failed" }); + continue; + } + if (!site.cloudflareProject) { + onLog(`[${site.siteId}] deploy skipped — no Cloudflare project configured`); + outcomes.push({ + siteId: site.siteId, + status: "skipped", + reason: "no cloudflareProject", + }); + continue; + } + onLog(`=== Deploy ${site.siteId} ===`); + const uploadCode = await runArchiveUploadIntoLog( + onLog, + signal, + site, + paths, + dockerSiteStagingDir(paths, site.siteId), + ); + if (signal.aborted) break; + if (uploadCode !== 0) { + onLog(`[${site.siteId}] deploy FAILED — R2 upload exit ${uploadCode}`); + outcomes.push({ + siteId: site.siteId, + status: "failed", + reason: `R2 upload exit ${uploadCode}`, + }); + continue; + } + const deployCode = await runDeployIntoLog( + onLog, + signal, + site, + outDirFor(site.siteId), + paths, + ); + if (signal.aborted) break; + if (deployCode !== 0) { + onLog(`[${site.siteId}] deploy FAILED — exit ${deployCode}`); + outcomes.push({ + siteId: site.siteId, + status: "failed", + reason: `deploy exit ${deployCode}`, + }); + continue; + } + onLog(`[${site.siteId}] deployed.`); + outcomes.push({ siteId: site.siteId, status: "deployed" }); + } + return outcomes; +} + +// Bounded-concurrency map over a fixed work set, preserving input order in the +// results. No external dep; a fresh worker pulls the next index until exhausted. +async function runWithConcurrency<T, R>( + items: T[], + limit: number, + worker: (item: T) => Promise<R>, +): Promise<R[]> { + const results: R[] = new Array(items.length); + let next = 0; + const width = Math.max(1, Math.min(limit, items.length)); + const runners = Array.from({ length: width }, async () => { + while (true) { + const i = next++; + if (i >= items.length) break; + results[i] = await worker(items[i]); + } + }); + await Promise.all(runners); + return results; +} diff --git a/editor/app/deploy/components/BuildAllSitesButton.tsx b/editor/app/deploy/components/BuildAllSitesButton.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { useState } from "react"; +import { + buildAllSitesAction, + buildAndDeployAllSitesAction, +} from "../../build/buildAction"; +import { JobLane } from "./JobLane"; + +type Lane = { deploy: boolean; skipArchives: boolean; key: number }; + +// One-click orchestrated pipeline over EVERY site as a SINGLE managed job (one +// log, one Cancel): in Docker mode it runs the shared data phase + archive warm +// once, fans the per-site builds out in parallel (capped by maxParallelBuilds), +// then deploys the built sites serially. Without a container engine it falls back +// to a serial host build+deploy. Distinct from the per-site panel below, which +// launches one separate job per selected site. +export function BuildAllSitesButton({ dockerMode }: { dockerMode: boolean }) { + const [deploy, setDeploy] = useState(true); + const [skipArchives, setSkipArchives] = useState(false); + const [lane, setLane] = useState<Lane | null>(null); + const [run, setRun] = useState(0); + + function launch() { + const next = run + 1; + setRun(next); + setLane({ deploy, skipArchives, key: next }); + } + + return ( + <div className="flex flex-col gap-3"> + <div className="flex flex-wrap items-center gap-3"> + <button + type="button" + onClick={launch} + className="px-3 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90" + > + {deploy ? "Build & deploy all sites" : "Build all sites"} + </button> + <label className="flex items-center gap-2 text-sm text-muted-foreground"> + <input + type="checkbox" + checked={deploy} + onChange={(e) => setDeploy(e.target.checked)} + /> + Deploy after build + </label> + <label className="flex items-center gap-2 text-sm text-muted-foreground"> + <input + type="checkbox" + checked={skipArchives} + onChange={(e) => setSkipArchives(e.target.checked)} + /> + Skip archive zips + </label> + </div> + <p className="text-xs text-muted-foreground"> + {dockerMode + ? "Docker pipeline: shared data phase runs once, per-site builds run in parallel, then deploys run serially." + : "Docker mode is off — this runs a serial host build+deploy fallback (one site at a time)."} + </p> + {lane && ( + <JobLane + key={lane.key} + title={lane.deploy ? "Build & deploy all sites" : "Build all sites"} + subtitle={ + dockerMode ? "Docker pipeline · parallel builds" : "Serial host fallback" + } + trigger={() => + lane.deploy + ? buildAndDeployAllSitesAction(lane.skipArchives) + : buildAllSitesAction(lane.skipArchives) + } + /> + )} + </div> + ); +} diff --git a/editor/app/deploy/components/BuildSitesPanel.tsx b/editor/app/deploy/components/BuildSitesPanel.tsx @@ -120,7 +120,7 @@ export function BuildSitesPanel({ {serial && ( <p className="text-xs text-muted-foreground"> Basic mode runs these one at a time (the build output tree is shared). - Switch to Docker mode for true parallel builds — a follow-up. + Use “Build all sites” above in Docker mode for true parallel builds. </p> )} diff --git a/editor/app/deploy/page.tsx b/editor/app/deploy/page.tsx @@ -14,6 +14,7 @@ import { getRegistry } from "yt-dlp-transcript-common/jobs/registry"; import { BuildExportButton } from "./components/BuildExportButton"; import { BuildDeployButton } from "./components/BuildDeployButton"; import { BuildModeToggle } from "./components/BuildModeToggle"; +import { BuildAllSitesButton } from "./components/BuildAllSitesButton"; import { BuildSitesPanel } from "./components/BuildSitesPanel"; import { CutReleaseForm } from "./components/CutReleaseForm"; import { DeployButton } from "./components/DeployButton"; @@ -169,10 +170,19 @@ export default async function DeployPage({ {/* 4 — Batch: build (and optionally deploy) several sites at once. */} <section className="flex flex-col gap-3 border-t border-border pt-6"> <div> - <h2 className="text-lg font-semibold">Build multiple sites</h2> + <h2 className="text-lg font-semibold">Build all sites</h2> <p className="text-sm text-muted-foreground"> - Pick sites to build (and optionally deploy) together. Each gets its - own live log lane. + One job over every site: the shared data phase runs once, per-site + builds run in parallel (Docker mode), then deploys run serially. + </p> + </div> + <BuildAllSitesButton dockerMode={buildMode === "docker"} /> + + <div className="mt-4"> + <h3 className="font-semibold">Or pick specific sites</h3> + <p className="text-sm text-muted-foreground"> + Build (and optionally deploy) selected sites, each in its own live log + lane. </p> </div> <BuildSitesPanel diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- **The export build can now fan out across sites in parallel (Docker mode).** Archive-zip generation is hoisted into a new shared host step (`build:archives`) that warms the persistent cache once for the union of all sites' channels, so the per-site `compose:site` can run read-only over that cache (`ARCHIVES_READONLY=1`) without re-zipping or racing — which is what lets several sites build at once. No change to a site's output; this is a build-pipeline change. See `common/bin/build-archives.ts`, `common/controller/{archiveTranscripts,archiveLiveChat}.ts` (`readOnly`), and `common/bin/compose-site.ts`. (Orchestration + UI live in the editor changelog.) - **The "Ask AI" chat now searches like an assistant — and follow-ups work.** Instead of one keyword search per message, the chat now runs an *agentic* loop: it decides what to look up, reads the results, and can refine and search again before answering — you see each search as it happens. Crucially, a follow-up that builds on the last answer (e.g. "put that on a timeline", "summarise it") no longer throws away the earlier context and re-searches your wording — earlier excerpts stay in play, so it just reformats what it already found. Answers render as **Markdown** (with a *Format answers* toggle to fall back to plain text if anything looks off), a live status shows the search→answer pipeline, and the model is told the archive's known transcription-misspelling aliases so it searches for the right variants. A **Search mode** control (Auto / Native tools / Scripted) chooses between your model's native function-calling and a provider-agnostic protocol, with automatic fallback. See `export/app/ask/*`, `export/app/lib/{searchAgent,askConversation,nativeTools/*}.ts`, `common/components/Markdown.tsx`, and `export/e2e/ask-chat.spec.ts`. ## [0.7.3] - 2026-07-07 diff --git a/export/package.json b/export/package.json @@ -9,6 +9,7 @@ "build:stats": "NODE_OPTIONS=--max-old-space-size=8192 tsx ../common/bin/build-stats.ts", "build:templates": "tsx ../common/bin/build-chart-templates.ts", "build:data": "pnpm run build:index && pnpm run build:stats && pnpm run build:templates", + "build:archives": "tsx ../common/bin/build-archives.ts", "detect:duplicates": "NODE_OPTIONS=--max-old-space-size=8192 tsx ../common/bin/duplicate-shorts.ts", "compose:site": "tsx ../common/bin/compose-site.ts", "compose:hub": "tsx ../common/bin/compose-hub.ts",