Skip to content

feat(builder): drive builds through the B4P push channel - #6

Draft
ozsay wants to merge 1 commit into
mainfrom
feat/build-session-push-channel
Draft

feat(builder): drive builds through the B4P push channel#6
ozsay wants to merge 1 commit into
mainfrom
feat/build-session-push-channel

Conversation

@ozsay

@ozsay ozsay commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Wires Sunny to Base44's build-session API — the partner-facing push channel from apper#21516 — as the reference implementation a partner copies.

Why

The sidebar drove the builder the only way the platform API allowed, and every part of it was a workaround:

Today Here
Writes sendMessage holds the request open for the whole turn (~28s, hence BUILDER_TIMEOUT_MS = 120s) 202 in milliseconds
Progress getApp + getConversation every 2.5s; the transcript grows all build long, so reads queue behind each other until the proxy times out one SSE stream, one debounced read per actual transcript change
Completion inferred from a polling edge — an app seen processing that stops being processing turn.finished
Cancel not possible a Stop button
Out of credits a generic error status: blocked, reason: quota, rendered as a pause

The switch

NEXT_PUBLIC_BASE44_BUILD_SESSIONS=1 selects the push channel. Unset keeps the old path byte-for-byte, so this repo still works against a platform host that predates the endpoints and the two mechanisms can be demoed back to back. live is the only real branch in AppBuilderSidebar.

Shape

Five ops go through the server proxy (mintBuildSessionToken, sendBuildSessionMessage, respondToBuildSession, cancelBuildSessionTurn, revokeBuildSessionGrant), then the browser streams directly from Base44 — which keeps SSE off Netlify's function path, where buffering would break it.

The stream deliberately carries no tool results, so message.updated triggers one debounced getConversation. The transcript read still happens; it just no longer happens on a timer.

One credential reaches the browser, and only one

The build-session grant: read-only, ~15 minutes, scoped to a single session id, revocable by id. The vended access token it was minted with never leaves the server. The grant never goes in a URL either — it is exchanged in a header for a single-use 60-second ticket, so what can appear in an access log is already spent. subscribeToSession hands each grant back on teardown; a build re-mints every twelve minutes, so that is the difference between one live read credential and one per refresh.

This is a real amendment to this repo's rule that vended tokens stay server-side, so CLAUDE.md now states it — including why it does not generalise to "tokens may reach the browser."

Three defects, found verifying against a live preview

  • A send derived its idempotency key from the message text. Sending the same thing twice read as a network retry: the platform held the first turn's claim for ten minutes and answered the second with a 202 that ran nothing. Sends now carry an opaque per-click id; only a respond keeps a waitpoint-derived key, which is where retry-safety is actually wanted. Pinned by two regression checks.
  • conversation.reset and files.changed were ignored, so a checkpoint restore left the transcript disagreeing with the server.
  • requestRefresh dropped a refresh whenever one was in flight — survivable for message.updated, where another event follows; permanent for conversation.reset, which is the only notice there is. It re-arms instead of dropping.

One host again

IDENTITY_BASE44_PLATFORM_HOST and SSE_BASE44_PLATFORM_HOST existed only because the two halves of this integration sat on unmerged branches that had to be aimed at different deploy previews. Both have landed, so the two vars, the identityHost / buildSessionHost helpers, and the per-action host override in the proxy are all gone.

Local .env needs a matching edit: drop IDENTITY_BASE44_PLATFORM_HOST, and point BASE44_PLATFORM_HOST at a host whose preview carries SERVICE_PRINCIPAL_ALLOWLIST_ORG_IDS — otherwise Connect answers 403 principals_not_enabled. On an apper PR preview that means an overrides.env for the branch; see apper's docs/infrastructure/pr-preview-env-vars.md.

Rebased onto this repo

The original branch was written against NitzansiPublic/base44-platform-starter, which shares no history with this repo, so this is a replay rather than a merge. 18 of the files applied unchanged; AppBuilderSidebar.jsx was re-applied by hand onto the marketplace work, and two hunks were dropped because this repo had already solved the same problems better:

  • setAppReadyFor is gone here in favour of a derived appReady — which fixes the same "only the client that watched the build finish sees it" bug the turn.finished handler was written for. onTurnFinished now just pulls the transcript.
  • The preview-refresh nonce is unnecessary: seenCommitRef already re-resolves an open preview when the commit moves, and appRefresh.ts already broadcasts to every embedded copy. files.changed just requests a refresh and lets those fire.

The marketplace publish paths all use an awaited refresh(), so switching the poll off does not affect them.

Also included

docker-compose.yml (local Postgres) and scripts/dev-session.ts (mint a local session cookie without a Google OAuth client) are the harness this was developed against. Unrelated to the push channel — say the word and I will split them out.

Verification

  • npm run typecheck — clean
  • npm run lint — clean (2 pre-existing warnings in views/Board*.jsx, untouched here)
  • npm run session:smoke29/29, new suite covering the trigger allow-list, the decision-vs-action wire-name trap, the quota kind's deliberate absence, and the fresh-key regression
  • npm run base44:smoke, rls:smoke, auth:smoke, entities:smoke — pass
  • sunny:smoke / market:smoke fail on this branch's base too: the local database has 2 of 4 migrations applied and predates marketplace_listings / app_installs

Still needs a linked account with credits to exercise end to end: a real interrupt widget resuming via /responses, Stop mid-build, sending the same message twice (both turns must run), and a checkpoint restore re-reading the transcript. The manual checklist is at the bottom of scripts/build-session-smoke.ts.

@netlify

netlify Bot commented Aug 27, 2026

Copy link
Copy Markdown

👷 Deploy request for base44-platform-starter pending review.

A Netlify team Owner will need to approve the deploy before you can run your build.

Are you a team Owner? Visit the deploys page to approve it

Need more help? Learn more in the Netlify docs

Name Link
🔨 Latest commit 6dc08f3

@ozsay
ozsay force-pushed the feat/build-session-push-channel branch from 13a6be2 to 15b5aab Compare August 27, 2026 14:40
The sidebar drove Base44's builder the only way the platform API allowed:
`sendMessage` held an HTTP request open for the whole turn (~28s, hence the
120s timeout), progress came from re-reading `getApp` + `getConversation`
every 2.5s, and "the build finished" was inferred from a polling edge — an
app seen `processing` that stopped being `processing`. There was no way to
stop a turn, and an out-of-credits build read as a generic error.

Base44's build-session API replaces all four. This wires Sunny to it as the
reference implementation.

## Two mechanisms, one switch

`NEXT_PUBLIC_BASE44_BUILD_SESSIONS=1` selects the push channel; unset keeps
today's blocking-plus-polling path byte-for-byte. Both are kept so they can
be demoed back to back, and so this repo still works against a platform host
that predates the endpoints. `live` is the only real branch in the sidebar.

## What the browser holds

Five ops go through the server proxy — mint a grant, send, respond, cancel,
revoke — and then the browser streams **directly** from Base44 over SSE,
keeping the stream off Netlify's function path where buffering would break
it. State and completion arrive as events; a `message.updated` triggers one
debounced `getConversation`, because the stream deliberately carries no tool
*results*. So the transcript read still happens, but only when the transcript
actually moved.

`turn.finished` is a real completion signal, so the edge detector and the ref
that remembered which app it belonged to are confined to the poll path. Stop
is a button now. Out of credits arrives as `status: blocked, reason: quota`
and reads as a pause rather than a failure — the old mechanism could only
ever see `status: error`.

## One credential reaches the browser, and only one

The build-session *grant*: read-only, ~15 minutes, scoped to a single session
id, revocable by id. The vended access token it was minted with never leaves
the server. The grant never goes in a URL either — it is exchanged in a
header for a single-use 60-second ticket, so what can land in an access log
is already spent. `subscribeToSession` also hands each grant back on
teardown; a build re-mints every twelve minutes, so that is the difference
between one live read credential and one per refresh.

This is a genuine amendment to the repo's own rule that vended tokens stay
server-side, so `CLAUDE.md` states it rather than leaving it implied.

## Three defects found verifying this against a live preview

A send derived its idempotency key from the message text, so sending the same
thing twice read as a network retry: the platform held the first turn's claim
for ten minutes and answered the second with a 202 that ran nothing. Sends
now carry an opaque per-click id; only a *respond* keeps a key derived from
its waitpoint, which is where retry-safety is wanted.

`conversation.reset` and `files.changed` were ignored, so a checkpoint
restore left the transcript disagreeing with the server. Both re-read now.

`requestRefresh` dropped a refresh whenever one was in flight — survivable
for `message.updated`, where another event follows, permanent for
`conversation.reset`, which is the only notice there is. It re-arms instead.

## One host again

`IDENTITY_BASE44_PLATFORM_HOST` and `SSE_BASE44_PLATFORM_HOST` existed only
because the two halves of this integration sat on unmerged branches and had
to be aimed at different deploy previews. Both have landed, so the split and
the per-action `host` override in the proxy are gone.

## Also here

`docker-compose.yml` (local Postgres) and `scripts/dev-session.ts` (mint a
local session cookie without a Google client) are the harness this was
developed against. Unrelated to the push channel and easy to split out.

Verified: `typecheck` and `lint` clean, `session:smoke` 29/29,
`base44:smoke`, `rls:smoke`, `auth:smoke`, `entities:smoke` all pass.
`sunny:smoke` and `market:smoke` fail on this branch's base too — the local
database predates the two marketplace migrations.
@ozsay
ozsay force-pushed the feat/build-session-push-channel branch from 15b5aab to 6dc08f3 Compare August 27, 2026 14:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant