diff --git a/.github/workflows/p2-cloud-bundle.yml b/.github/workflows/p2-cloud-bundle.yml new file mode 100644 index 000000000..1ae4aaf1f --- /dev/null +++ b/.github/workflows/p2-cloud-bundle.yml @@ -0,0 +1,73 @@ +# Builds p2's web UI for Radius Cloud Brain after CI passes on main (apps/p2/scripts/cloud-bundle.ts) and uploads it +# to the R2 bucket pi-ui under the commit's full SHA, which Radius serves under /pi/ui/. Builds are immutable: a SHA +# that has one is skipped. Never runs for PRs: the job holds R2 write credentials. + +name: p2 cloud bundle + +on: + workflow_run: + workflows: [CI] + types: [completed] + workflow_dispatch: + inputs: + sha: + description: Full commit SHA to build and upload + required: true + type: string + +permissions: + contents: read + +jobs: + publish: + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch == 'main' && + github.event.workflow_run.head_repository.full_name == github.repository) + runs-on: ubuntu-latest + environment: pi-model-upload + env: + SHA: ${{ github.event.workflow_run.head_sha || inputs.sha }} + AWS_ACCESS_KEY_ID: ${{ secrets.PI_ARTIFACTS_R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.PI_ARTIFACTS_R2_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: auto + AWS_EC2_METADATA_DISABLED: 'true' + R2: --endpoint-url https://67c0d357268b0fca6e0b465bb9d01b84.r2.cloudflarestorage.com + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ env.SHA }} + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci --ignore-scripts + + # The web UI imports pi-ai, whose generated model data is not committed. + - name: Hydrate model data + run: npm run hydrate:model-data + + - name: Build + working-directory: apps/p2 + run: node --import ./src/source.ts scripts/cloud-bundle.ts "$SHA" "$RUNNER_TEMP/cloud-bundle" + + # The manifest goes last, so a build whose manifest exists is complete. + - name: Upload + run: | + if aws s3api head-object $R2 --bucket pi-ui --key "$SHA/manifest.json" >/dev/null 2>&1; then + echo "pi-ui/$SHA exists: kept" + exit 0 + fi + cache="public, max-age=31536000, immutable" + aws s3 cp $R2 --recursive --exclude manifest.json --cache-control "$cache" \ + "$RUNNER_TEMP/cloud-bundle/$SHA" "s3://pi-ui/$SHA" + aws s3 cp $R2 --cache-control "$cache" \ + "$RUNNER_TEMP/cloud-bundle/$SHA/manifest.json" "s3://pi-ui/$SHA/manifest.json" diff --git a/apps/p2/docs/p2-notes.md b/apps/p2/docs/p2-notes.md index 8b55e73f6..5be843647 100644 --- a/apps/p2/docs/p2-notes.md +++ b/apps/p2/docs/p2-notes.md @@ -47,12 +47,11 @@ P2_FAKE=1 npm start # http://127.0.0.1:8788, data in ~/.pi/p2 immediately. At most two previous generations remain available for up to 60 seconds. This polling is temporary until loader model D provides scoped, per-facet `ClientBundle` pushes (`docs/plugin-loading.md` 5.3 and 12.2 step 4). The TUI launcher separately polls `/__p2_tui`. There is no Vite, source-module server or `index.html`. -- **Bundles for Radius Cloud Brain:** the public repo `earendil-works/bundled-ui` holds prebuilt copies of this web - bundle, built from a pi-private checkout by its `scripts/build.mts` (imports `buildWebBundle`). The script patches - three places in the built script: the Chord socket URL (`` new WebSocket(`${...}://${location.host}/chord`) ``), - the version poll (`fetch("/__p2_web",{cache:"no-store",credentials:"same-origin"})`) and the `/__p2_web/` asset - prefix. It fails when a target is missing, so changing those in `src/hosts/web/main.tsx` or `WEB_BUNDLE_PATH` means - updating that script. +- **Bundles for Radius Cloud Brain:** after CI passes on main, `.github/workflows/p2-cloud-bundle.yml` builds this + web bundle with `scripts/cloud-bundle.ts` and uploads it to the R2 bucket `pi-ui` under the commit's full SHA, which + Radius serves under `/pi/ui/`. The script patches the built script's Chord socket URL, version poll and `/__p2_web/` + asset prefix, and fails when one is missing: changing those in `src/hosts/web/main.tsx` or `WEB_BUNDLE_PATH` means + updating it. - `npm run dev` also watches plugins and supervises runtime restarts; see **Dev mode** below. Under `npm start`, code changes (web included) take effect only after a restart. - **Data report** (`scripts/data-report.ts`, read-only, beside a live server): per session its title, location, diff --git a/apps/p2/scripts/cloud-bundle.ts b/apps/p2/scripts/cloud-bundle.ts new file mode 100644 index 000000000..66f59db67 --- /dev/null +++ b/apps/p2/scripts/cloud-bundle.ts @@ -0,0 +1,61 @@ +// Builds p2's web UI for Radius Cloud Brain into `//`: the plugins' web facets, their stylesheet and a +// manifest.json naming both. Radius serves the `pi-ui` bucket under `/pi/ui/` and puts the Chord URL in the page's +// `#root` `data-pi-bootstrap`; the built script assumes p2 serves it, so its Chord socket and its version poll are +// patched. A missing patch target, or anything else still loaded from p2's `/__p2_web/`, fails the build: +// `src/hosts/web/main.tsx` or `WEB_BUNDLE_PATH` changed, and this script must follow. +// +// node --import ./src/source.ts scripts/cloud-bundle.ts + +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { buildWebBundle } from "../src/hosts/bundles.ts"; + +/** The plugins Cloud Brain serves. `web-shell` registers the "app" slot; without it the page stays on "Loading…". */ +const PLUGINS = ["web-shell", "artifacts", "chat", "drafts", "models", "sessions", "status", "subagents", "workspace"]; + +const [sha, out] = process.argv.slice(2); +if (!sha || !/^[0-9a-f]{40}$/.test(sha) || !out) throw new Error("usage: cloud-bundle.ts "); + +const base = `/pi/ui/${sha}`; +const dir = join(out, sha); +const bundle = await buildWebBundle(PLUGINS); +await mkdir(dir, { recursive: true }); + +// Source maps embed the sources and the bundle is served publicly: they are left out, and so are the comments naming +// them (the last line of the script and of the stylesheet). +for (const [file, { body }] of bundle.files) { + if (file.endsWith(".map")) continue; + let text = new TextDecoder().decode(body); + if (file === bundle.script) text = patchScript(text.replace(/\n\/\/# sourceMappingURL=\S+\n?$/, "\n")); + else if (file === bundle.style) text = text.replace(/\n\/\*# sourceMappingURL=\S+ \*\/\n?$/, "\n"); + await writeFile(join(dir, file), text); +} +const manifest = { + version: `pi-${sha}`, + script: bundle.script, + stylesheets: bundle.style ? [bundle.style] : [], + commit: sha, +}; +await writeFile(join(dir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`); +console.log(`built ${dir}`); + +function patchScript(script: string): string { + script = patch( + script, + // biome-ignore lint/suspicious/noTemplateCurlyInString: the built script's exact text, not a template. + 'new WebSocket(`${location.protocol==="https:"?"wss":"ws"}://${location.host}/chord`)', + 'new WebSocket(JSON.parse(document.getElementById("root").dataset.piBootstrap).chordUrl)', + ); + script = patch( + script, + 'fetch("/__p2_web",{cache:"no-store",credentials:"same-origin"})', + `fetch("${base}/manifest.json",{cache:"no-store"})`, + ); + if (script.includes("__p2_web")) throw new Error("the built script still names /__p2_web"); + return script; +} + +function patch(source: string, from: string, to: string): string { + if (!source.includes(from)) throw new Error(`patch target not found: ${from}`); + return source.replaceAll(from, to); +}