Repository navigation
fix(STU-380): truthful outcomes for unreachable push/provision paths - #99
Conversation
Devin Review on #91 flagged three places where the deploy could still misreport what FlutterFlow did: - pushCodeWithRetry flattened exhausted retries into a generic failure even when attempts ended in transport errors (the request may have been applied). Track sawTransportError/lastHttpStatus and throw an UnconfirmedDeployError when any attempt lost its response; keep a plain error only when every attempt was an HTTP refusal. An ok-but-unparseable sync response now reports unconfirmed for the same reason. - provisionMissingCodeFiles only bounded the stream read — a runner that accepts the connection but never flushes headers waited forever. Move the fetch inside the bounded wait (fetch rejection -> unconfirmed, since the POST may have landed), and stop feeding progress updates to the UI after the bound expires. - targetIdentity.projectId was only assigned after provisioning succeeded, so an unconfirmed provisioning outcome reported a null target. Assign it immediately after project validation in both executeCommit and executeBundleCommit; commitToFlutterFlow's catch now also returns unconfirmed instead of a generic failure. UnconfirmedDeployError moves to src/deployOutcome.js next to the outcome taxonomy, with exhaustedPushError encoding the transport-error rule. readProvisionResponse now converts a mid-stream read failure into the no-final-result path (unconfirmed) instead of throwing, with a comment documenting the runner contract that makes a non-2xx always definitive. Unit: exhaustedPushError covers both branches; provisionStream covers a mid-stream drop. E2e: four transport regressions drive the real fetch/ retry/timeout code against routed fixtures (stalled headers, truncated stream, all-abort push, all-500 push), each falsified against the old behaviour. Co-Authored-By: Stuart Gardoll <sgardoll@gmail.com>
|
I'll fix CI failures and address comments from users with write access that start with 'Devin'.
|
|
This PR does not match any of the 1 configured review trigger rule. |
| uiTimeoutMs, | ||
| (resolve, reject) => { | ||
| progressLive = false; | ||
| reject( | ||
| new UnconfirmedDeployError( | ||
| "The deploy is still working on the FlutterFlow server, but this browser tab has stopped waiting. " + | ||
| "Your custom classes may or may not have been written — the outcome is not yet known. " + | ||
| "Open your FlutterFlow project to reconcile before retrying the deploy.", | ||
| ), | ||
| ), | ||
| ); | ||
| }, |
There was a problem hiding this comment.
There was a problem hiding this comment.
Accurate description, but I'd call it inherent rather than fixable client-side: once the UI bound expires the request is deliberately left running (aborting would not cancel the server-side write anyway — the POST already reached the runner). The mitigation in this PR is that the outcome is now honestly reported as unconfirmed with guidance to open FlutterFlow and reconcile before retrying, and the project-source cache is dropped so a follow-up deploy re-reads remote state instead of trusting the stale snapshot.
True reconciliation for overlapping attempts needs a server-side idempotency/dedup key on the runner (e.g. the runner rejecting or coalescing a second deploy for the same project while one is in flight). That belongs in a follow-up on the provision endpoint — flagging it here as out of scope for this client-side fix.
… error Devin Review on #99 flagged that a response body lost mid-flight shared the wrong catch in two places: - parsePushCodeResponse awaited the clone's text() unguarded, so an ok response whose body never arrived propagated a raw read error -> rendered failed/partial instead of unconfirmed. The read is now best-effort: a non-ok status still returns a definitive failure; an ok status throws UnconfirmedDeployError even when nothing of the body survived. - pushCodeWithRetry read the error body inside the same try as fetch, so a body read failure set sawTransportError and could send a non-500 refusal back through the retry loop. fetch rejections are now the only thing that marks a transport error; the logging read is best-effort and non-500 statuses return immediately. E2e: a 200/500 whose body resets mid-read classify on their status (unconfirmed vs failed), a 500 with an unreadable body is reported as the known refusal rather than an unknown outcome, and a 403 is never retried. Co-Authored-By: Stuart Gardoll <sgardoll@gmail.com>
|
Fixed in 33f5880 — the clone read is now best-effort inside its own try/catch. A body dropped mid-flight still classifies on the received status: non-ok returns |
|
Fixed in 33f5880 — |
|
Acknowledged — this is deliberate: |
…oning Devin Review on #99: a provisioning request that ends unconfirmed (UI bound expired, fetch rejected, stream dropped before a result) may still have written classes on the runner, but resolveProjectPubspec's cached export predates them — the next deploy would read the stale snapshot and provision the same class again. provisionMissingCodeFiles now wraps the bounded wait and result classification in a catch that invalidates the project source cache whenever the thrown error is UnconfirmedDeployError; definitive refusals (plain Error, no write happened) keep the cache. E2e: seed the cache via __CCC_RESOLVE_PROJECT_PUBSPEC__ (exportCode routed), stall provisioning past the UI bound, then resolve again — the export must run a second time. Falsified: without the invalidation the second resolve hits the cache. Co-Authored-By: Stuart Gardoll <sgardoll@gmail.com>
|
Fixed in eafd7ff — |
|
Already reconciled — the PR body now says 8 transport regressions (the second commit added the four body-read cases); this run's finding predates that edit. Current count is 9 after the cache-invalidation case in eafd7ff; I'll keep the body in sync. |
Devin Review: erroring a stream synchronously after enqueue() discards the queued chunk (per the Streams spec, error() clears the internal queue), so the test could only exercise a pre-first-chunk failure. Error in pull() instead so the phase event is delivered, then the connection drops — and assert onPhase received it, pinning the mid-stream path. Co-Authored-By: Stuart Gardoll <sgardoll@gmail.com>
|
Fixed in c74d7cf — |
#step3-output sits inside permanently-hidden legacy containers, so showCommitFailureModal painted the refusal where no user could see it — the progress overlay closed and nothing appeared. A definitive rejection now also populates the shared commit-terminal-modal (same identity rows, per-file outcomes, and guidance as partial/unconfirmed); the hidden step3-output render stays for the regenerate wiring and legacy readers. Found while driving the real confirm flow in the browser: a provisioning HTTP 500 classified correctly as FAILED but produced zero visible UI. Co-Authored-By: Stuart Gardoll <sgardoll@gmail.com>
… attempt is unconfirmed A definitive refusal only makes the deploy definitively failed if no earlier attempt was uncertain. Any 5xx from the sync endpoint — or a lost response — may have landed the write, so: - non-500 5xx throws UnconfirmedDeployError immediately - a later definitive refusal after any uncertain attempt stays unconfirmed - retry-exhaustion always returns UnconfirmedDeployError FAILED therefore only covers provably pre-write refusals, keeping the failure modal's 'nothing applied, retry is safe' copy accurate. Co-Authored-By: Stuart Gardoll <sgardoll@gmail.com>
…g the deploy pushCodeWithRetry had no timeout: a fetch that never flushed headers (or a response body that stalled mid-parse) parked the deploy forever. All three commit paths now run push + parse under pushCodeWithUiTimeout, which expires into UnconfirmedDeployError and leaves the in-flight write running rather than aborting a request that may have landed. The exported project snapshot was already invalidated before every push. Co-Authored-By: Stuart Gardoll <sgardoll@gmail.com>
| // A 5xx is the server's own failure report: it can be raised after | ||
| // the write was applied, so it can never prove a refusal. | ||
| if (response.status >= 500) { | ||
| sawUncertainAttempt = true; | ||
| } | ||
| if (response.status === 500) { | ||
| console.warn(`Endpoint ${baseUrl} returned 500, trying next...`); | ||
| await new Promise((r) => setTimeout(r, 1000 * (attempt + 1))); | ||
| continue; |
There was a problem hiding this comment.
🔍 Uncertain pushes still retry across endpoints
A lost response or HTTP 500 can follow an applied write, yet pushCodeWithRetry sends another request. The result stays unconfirmed, but retrying can issue multiple writes. Confirm FlutterFlow's deduplication behavior before relying on these retries.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Fair flag — worth stating the reasoning rather than changing behavior.
Every retry sends the same pushCodeRequest payload (same file_map/functions_map/serialized_yaml), so a repeated write is idempotent at the payload level: a second application converges to identical project state, not duplicate artifacts. The retry-across-endpoints loop predates this PR and is what makes a push survive a production-endpoint outage — dropping it on first uncertainty would trade a possibly-redundant write for a definitely-failed deploy, which seems like the worse failure mode for a sync endpoint.
The unconfirmed outcome + "reconcile in FlutterFlow before retrying" guidance is the honest guardrail here. If FlutterFlow ever documents that duplicate syncs are unsafe (or we add a client-generated idempotency key to the request so the server can dedupe), the retry policy should be revisited — I'd fold that into the same follow-up as the provisioning idempotency flag below.
…dy is unreadable A non-2xx response with a body that fails to read reported 'connection dropped' instead of the refusal status — hiding the reason the deploy was refused. The status is already received at that point, so the error now names it (httpRejected/httpStatus were already correct; only the message was wrong). Co-Authored-By: Stuart Gardoll <sgardoll@gmail.com>
| function showCommitFailureModal(result) { | ||
| hideCommitProgress(); | ||
| showCommitError(result); | ||
| populateCommitTerminalModal(result, { | ||
| heading: "Deploy failed", | ||
| title: | ||
| "FlutterFlow refused this deploy — nothing was confirmed written.", | ||
| guidance: | ||
| "Review the error and any per-file outcomes above, fix them in your code or in FlutterFlow, then deploy again. " + | ||
| "A refusal is definitive: no part of this deploy was applied, so retrying is safe once the problem is fixed.", | ||
| }); |
There was a problem hiding this comment.
🔍 Regenerate action remains hidden
The visible failure modal offers no regeneration control. showCommitError still places “Fix Errors & Regenerate” in hidden legacy output; reviewers can decide whether the new terminal needs that action.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Good catch — agreed this is a real gap worth a product decision rather than a fix in this PR.
The legacy #step3-output render had a "Fix Errors & Regenerate" action wired to the regenerate flow; the shared terminal modal deliberately only offers reconcile-in-FlutterFlow / close, because for PARTIAL and UNCONFIRMED a blind regenerate could double-apply writes. For FAILED (provably nothing applied) a regenerate action is safe to offer — adding it means deciding whether the terminal modal grows a second action or whether the user closes and re-runs the pipeline manually.
Leaving this thread open for @sgardoll — happy to wire the regenerate button into the FAILED terminal if you want it.
…orrectly - pushCodeWithUiTimeout hands the retry loop a stopToken set on expiry: a request already in flight is left to settle, but no further attempts may start — they could overlap a deploy the user just retried. - Definitive remote decisions (push refusal, provisioning non-2xx, runner failure result) are marked remoteRefusal=true and carried onto the result; showCommitFailureModal only says 'FlutterFlow refused' for those. Local failures (credentials, validation, compile gate) get a pre-write title instead — still truthful that nothing was applied. Co-Authored-By: Stuart Gardoll <sgardoll@gmail.com>
… refusals
A streamed result{success:false} only proves the runner finished — the
deploy CLI can fail or time out after its upload phase already wrote
classes. Marking every runner-reported failure remoteRefusal rendered the
FAILED modal's 'nothing was applied / safe to retry' copy over state the
runner could not verify.
Classify runner failures by stage: the stream's last phase (deploy_start
or later → unconfirmed; workspace/verifying → failed), analyzerErrors as
a compile-gate proof, an explicit preWrite field new runners now emit,
or a non-streaming runner's real HTTP status (4xx refused, 5xx unknown).
Co-Authored-By: Stuart Gardoll <sgardoll@gmail.com>
… failures Three review findings on the stage-aware classification: - An unconfirmed throw after deploy_start sat outside the cache-invali- dating catch, leaving the pre-write snapshot for the next deploy to re-provision already-written classes. The final-result classification now runs inside the protected region. - A stalled error body hid a definitive push refusal: the retry loop awaited the diagnostic clone().text() and the parser awaited response.json() unboundedly, so a 4xx could expire the UI bound as unconfirmed. The log read is detached and the parse reads run under PUSH_BODY_READ_TIMEOUT_MS — status alone classifies on expiry. - Provisioning refusals carried remoteRefusal, so a runner-side compile gate or request-validation rejection rendered 'FlutterFlow refused this deploy'. The provisioning endpoint is our runner — its answers are pipeline failures, not FlutterFlow's — so the flag is dropped there and stays only on true sync-endpoint refusals. Co-Authored-By: Stuart Gardoll <sgardoll@gmail.com>
…them The detached error-body log read in pushCodeWithRetry and the bounded reads in parsePushCodeResponse settled their waiters but left the underlying stream reader pending — each stalled body held its connection open past the UI bound. readBodyBounded keeps the reader handle so the bound expiry cancels the stream for real (reader.cancel() works where body.cancel() rejects on the locked stream). Covered by asserting the source stream's cancel() fires on a stalled refusal body. Co-Authored-By: Stuart Gardoll <sgardoll@gmail.com>
…re-invalidate on late settle - Runner: FormatException from request validation is pre-write, but the same exception decoding CLI output can fire after deploy_start — it now falls to the generic catch (preWrite:false) instead of masking a write. - pushCodeWithRetry: the diagnostic read consumes the response itself when the loop won't return it (500-retry, 5xx/uncertain throws) — clone() would tee the body and leave the original branch's connection open; only the definitive-refusal path logs a clone since its original body goes to parsePushCodeResponse. - provisionMissingCodeFiles: an unconfirmed provision's underlying work keeps running past the UI bound; it now invalidates the project-source cache again when that work actually settles, so a snapshot exported mid-write by a later deploy can't survive to provision the class twice. Co-Authored-By: Stuart Gardoll <sgardoll@gmail.com>
…oves them A provision request settling — stream closed, timeout, dropped connection — is not evidence the runner finished writing; a disconnect can precede the write. Settlement-time cache invalidation could still leave a snapshot exported mid-write permanently cached. Instead, each file an unconfirmed provision may have written is marked dirty per project: while any dirty path is outstanding, fresh exports are never cached, and the dirty path clears only when an export actually contains the file (the authoritative read). A confirmed provisioning result clears the paths it verified. Co-Authored-By: Stuart Gardoll <sgardoll@gmail.com>
…ay honest A dirty path cleared on the next provision's success let a cached export be poisoned by an earlier writer still in flight: provision A times out (dirty), provision B writes different content and succeeds (cleared the path), an export caches B's snapshot, then A lands last. Dirty state is now per (path, content): a confirmed result removes only the contents it verified, and an export clears a write only when it contains that path with the exact content carried — a sighting of different content keeps the path uncached since the earlier writer may still overwrite it. Co-Authored-By: Stuart Gardoll <sgardoll@gmail.com>
… in flight Two related stale-snapshot holes: - Dirty tracking now counts per (path, content) with separate uncertain / verified tallies. A confirmed provision credits a verified write instead of clearing shared state, and an export sighting consumes a verified credit before it can retire an uncertain write — so an identical retry's success can never retire an earlier timed-out attempt whose write may still land over newer edits. A late-arriving verified result (success → verified credit; provable pre-write failure → retired) also resolves its own attempt's entries. - An unconfirmed push whose request is still in flight now marks the project pending: syncCustomCodeChanges writes inside its own request handling, so once that request settles no write can arrive later — until then, exports must not be cached or a mid-push snapshot (missing the push's dependency/file changes) could poison later deploys. Co-Authored-By: Stuart Gardoll <sgardoll@gmail.com>
6dd8edb
into
milestone/stu-implement-updated-ui-ux
…-ui-ux
* fix: classify a block-form dependency by any nested source key, not just the first
(cherry picked from commit d5a3c922469ccb5da7bf43e6b378c9d913115a2f)
* fix: classify a block-form dependency by any nested source key, not just the first
(cherry picked from commit d5a3c922469ccb5da7bf43e6b378c9d913115a2f)
* fix: skip verification conservatively when an unrepresentable source can reach a class transitively
(cherry picked from commit 9d338ad4fbcc7a06f63b12fd6ef702552ca80620)
* fix: skip verification conservatively when an unrepresentable source can reach a class transitively
(cherry picked from commit 9d338ad4fbcc7a06f63b12fd6ef702552ca80620)
* fix(ci): scan the bundle for every provider endpoint and credential shape
(cherry picked from commit ff9bfacbdba3fa536689eb90890ab2703309198e)
* fix(ci): scan the bundle for every provider endpoint and credential shape
(cherry picked from commit ff9bfacbdba3fa536689eb90890ab2703309198e)
* ci: pin workflow actions to commit SHAs and minimize token permissions
(cherry picked from commit 1ed78b6ffc6b43531593526d44dd3179f7ee1442)
* ci: pin workflow actions to commit SHAs and minimize token permissions
(cherry picked from commit 1ed78b6ffc6b43531593526d44dd3179f7ee1442)
* test: derive the deploy guard's expected absent bundles from the built HTML
(cherry picked from commit ece12373880e744669bda50a9bad4aa1fc6c1893)
* test: derive the deploy guard's expected absent bundles from the built HTML
(cherry picked from commit ece12373880e744669bda50a9bad4aa1fc6c1893)
* fix: classify a block-form dependency by any nested source key, not just the first
(cherry picked from commit d5a3c922469ccb5da7bf43e6b378c9d913115a2f)
* fix: skip verification conservatively when an unrepresentable source can reach a class transitively
(cherry picked from commit 9d338ad4fbcc7a06f63b12fd6ef702552ca80620)
* fix(ci): scan the bundle for every provider endpoint and credential shape
(cherry picked from commit ff9bfacbdba3fa536689eb90890ab2703309198e)
* ci: pin workflow actions to commit SHAs and minimize token permissions
(cherry picked from commit 1ed78b6ffc6b43531593526d44dd3179f7ee1442)
* test: derive the deploy guard's expected absent bundles from the built HTML
(cherry picked from commit ece12373880e744669bda50a9bad4aa1fc6c1893)
* fix: classify a block-form dependency by any nested source key, not just the first
(cherry picked from commit d5a3c922469ccb5da7bf43e6b378c9d913115a2f)
* fix: skip verification conservatively when an unrepresentable source can reach a class transitively
(cherry picked from commit 9d338ad4fbcc7a06f63b12fd6ef702552ca80620)
* fix(ci): scan the bundle for every provider endpoint and credential shape
(cherry picked from commit ff9bfacbdba3fa536689eb90890ab2703309198e)
* ci: pin workflow actions to commit SHAs and minimize token permissions
(cherry picked from commit 1ed78b6ffc6b43531593526d44dd3179f7ee1442)
* test: derive the deploy guard's expected absent bundles from the built HTML
(cherry picked from commit ece12373880e744669bda50a9bad4aa1fc6c1893)
* fix: classify a block-form dependency by any nested source key, not just the first
(cherry picked from commit d5a3c922469ccb5da7bf43e6b378c9d913115a2f)
* fix: skip verification conservatively when an unrepresentable source can reach a class transitively
(cherry picked from commit 9d338ad4fbcc7a06f63b12fd6ef702552ca80620)
* fix(ci): scan the bundle for every provider endpoint and credential shape
(cherry picked from commit ff9bfacbdba3fa536689eb90890ab2703309198e)
* ci: pin workflow actions to commit SHAs and minimize token permissions
(cherry picked from commit 1ed78b6ffc6b43531593526d44dd3179f7ee1442)
* test: derive the deploy guard's expected absent bundles from the built HTML
(cherry picked from commit ece12373880e744669bda50a9bad4aa1fc6c1893)
* fix: bound verification sdkPackages like the dependency maps and dedupe in a Set
The public deploy route accepted an unbounded sdkPackages array and deduped
it with repeated result.contains scans, so an unauthenticated caller could
submit a very large list for quadratic parsing work and an equally large
scratch pubspec. Cap the list at maxDependenciesPerRequest (200) - the same
per-request budget the dependency maps use, since every accepted name is
emitted as a dependencies: entry in the generated pubspec - and reject with
the same 'Too many entries in verification...' error style. Deduplicate via
a Set instead, keeping insertion order and the List<String> contract.
Also rebuild dist/ so the committed output carries this branch's token
changes (new CSS bundle beside the unchanged JS).
* test(e2e): abort un-fixtured external requests in browser fixtures
The default fixture router continued every unrecognized request, so an
endpoint missing from the fixture map (runpipeline-image, the staging
FlutterFlow sync, an env-overridden deploy URL) could silently reach a
paid generation or a real project. The catch-all now aborts unmatched
requests so un-fixture'd calls fail tests loudly.
Only three request classes are allowed through, all explicit:
- fixture-map URLs are fulfilled as before;
- static assets index.html references are fulfilled with inert stubs
(Tailwind, Google Fonts CSS, YouTube embeds), removing the page
load's dependency on live CDNs and killing the YouTube player/ad/
tracking request cascade;
- same-origin requests the local Vite dev server serves (the document,
modules, Vite internals, public assets) continue, except /api paths,
which vite.config.js proxies to real AI providers;
- the SRI-pinned static scripts (JSZip, highlight.js, FingerprintJS)
continue because a local stub can never match the pinned digest and
app.js needs FingerprintJS to resolve identity; SRI verifies them
byte-for-byte.
Verified by rerunning the smoke suite (6 passed, 10x faster with the
YouTube cascade gone) and by probing that fetches to runpipeline-image,
the staging sync and /api proxy paths all abort.
STU-372
* test(e2e): build fixture deployOrder from the artifacts' real IDs
buildArtifactBundle synthesized deployOrder values like "file-1" while
the artifacts themselves get real IDs such as
"custom-action-greet-user". Normalization preserves unknown order IDs,
and the deployment planner matches order entries against artifact.id,
filtering out every synthetic value — so a deploy journey driven by
these fixtures produced an empty deployment plan instead of exercising
artifact deployment.
Map the artifacts first and derive deployOrder from the mapped
artifacts' IDs, the same relationship src/artifactBundle.js guarantees.
Verified against the real contract: oneArtifact now plans
lib/custom_code/actions/GreetUser.dart and manyArtifacts plans all
three files in class -> action -> widget order with no planner errors.
STU-372
* ci: install Playwright Chromium and run the browser tests
The pull-request workflow ran only unit, build, deploy-guard, proxy and
leak-scan checks, so regressions in fixture routes, selectors and smoke
assertions could merge while CI stayed green.
Add the Chromium browser install and npm run test:ui after the
production build, so the deterministic browser suite gates every pull
request. Step style and the pinned-action/least-permission hardening
from the earlier CI commits are unchanged.
STU-372
* ci: run the accessibility modal tests in CI with Playwright Chromium
* fix(auth): derive the magic-link guard contract from the shared fixture
Greptile: the provider domains and response codes were hardcoded in
src/authMagicLink.js while the tests used literals instead of reading
authMagicLinkFixtures.json, so a backend fixture update could leave the
frontend stale while every frontend test still passed.
- src/authMagicLink.js now takes domains, codes and the rejection message
template from authMagicLinkFixtures.json (JSON import attribute works
in both the node --test run and the Vite bundle).
- app.js compares the server's validation code against the exported
PLUS_ALIAS_REJECTED_CODE instead of a local literal, so the button and
input handling cannot drift from the fixture either.
- src/authMagicLink.test.js feeds every allowlisted domain and response
code from the same fixture, verifying the wiring against the contract
instead of restating it.
dist/ rebuilt.
* fix(auth): announce the magic-link rejection message to screen readers
Greptile: the asynchronous PLUS_ALIAS_REJECTED result updated a plain
paragraph while keyboard focus stayed on the submit button, so screen
readers might never hear it. #signin-message now carries role="status",
an implicit polite live region appropriate for a non-critical async
validation result. The element exists at page load and app.js only
mutates its textContent, so the updates are announced without any JS
change; the codebase had no prior live-region pattern to match.
dist/ rebuilt.
* test(auth): assert the exact rejection message instead of substrings
CodeQL flags the substring assertions over the email-bearing rejection
message as an incomplete URL-substring-sanitization pattern: 'gmail.com'
can appear anywhere in the message, so the check proves nothing about the
rendered result. Both message assertions now compare the full,
deterministic string rendered from the fixture template.
The pinned strings are a deliberate snapshot of the reviewed backend
contract: a fixture change now breaks these tests loudly and forces a
conscious update, while the fixture-driven wiring tests keep verifying
that the guard honors whatever the fixture says.
* fix: keep Plans reachable on the mobile topbar
Below 680px the topnav was hidden with display:none, and it holds the
only link to the new #plans surface, so mobile users could reach just
Home, Account and the credits modal — Plans required hand-editing the
URL.
Replace the mobile display:none with an adaptive active-view layout:
the nav stays visible and wraps to its own topbar row under 680px with
equal-width 44px touch targets, and --topbar-h grows to 100px so the
fixed topbar keeps the viewport offset exact. No horizontal scrolling
is introduced at 360px and hidden views stay out of the focus order.
Extend the hero navigation suite with a 360x800 test asserting all
three surfaces stay reachable with touch-sized targets and that the
wrapped nav does not force horizontal scrolling.
STU-375
* fix: move keyboard focus into the revealed view on navigation
switchView revealed the selected surface but never moved focus into
it, and the view sections were not programmatically focusable, so
focus stayed on the topbar trigger while the content changed underneath
— keyboard and assistive-technology users were never placed in the
newly selected surface.
Give each view section tabindex=-1 and focus the revealed container
once its is-active class lands (inside the same animation frame, so
the element is visible and focusable). Focus only follows
user-initiated navigation: the first hash restore on page load keeps
focus wherever the browser put it, while later hash changes, popstate
traversals and avatar-triggered switches move focus into the surface.
Hidden views are already display:none and inert, so focus cannot land
in them. Extend the hero navigation suite with assertions that initial
load steals no focus, that keyboard and pointer navigation both land
focus in the revealed view, and that the hidden view keeps zero
focusable controls.
STU-375
* ci: run the browser and accessibility modal tests on every pull request
The workflow ran the unit tests and the production build but never
executed the Playwright navigation suite or the accessibility modal
suite, so regressions in responsive navigation, history, zoom,
overflow and modal accessibility could merge while CI stayed green.
Install Playwright Chromium once after the production build, then run
npm run test:ui for the e2e navigation suite and npm run
test:accessibility for the modal suite. The test:ui script invokes
the Playwright runner against the checked-in config. Step naming
follows the existing workflow and the pinned-action SHA and
least-permission hardening are unchanged.
STU-375
* fix: scope the verification skip to direct importers and disclose the scratch-graph approximation
* fix: scope the verification skip to direct importers and disclose the scratch-graph approximation
(cherry picked from commit e300b76b8e56463bf7a26fb0f8e4767dd0d012d8)
* fix: scope the verification skip to direct importers and disclose the scratch-graph approximation
(cherry picked from commit e300b76b8e56463bf7a26fb0f8e4767dd0d012d8)
* fix: scope the verification skip to direct importers and disclose the scratch-graph approximation
(cherry picked from commit e300b76b8e56463bf7a26fb0f8e4767dd0d012d8)
* fix: scope the verification skip to direct importers and disclose the scratch-graph approximation
(cherry picked from commit e300b76b8e56463bf7a26fb0f8e4767dd0d012d8)
* fix: scope the verification skip to direct importers and disclose the scratch-graph approximation
(cherry picked from commit e300b76b8e56463bf7a26fb0f8e4767dd0d012d8)
* test(e2e): serve SRI-pinned assets from byte-identical vendored copies
Greptile P1: the fixture router let the five SRI-pinned third-party assets
(including FingerprintJS, required during page initialization) continue to
the live network, so a blocked or rate-limited CDN could fail
otherwise-correct tests. Vendor the pinned bytes in e2e/fixtures/vendor/ —
each verified against index.html's integrity attribute — and fulfill them
locally. The same-origin Vite dev server is now the only request still
allowed through; everything else fulfills from a fixture, an inert stub,
vendored bytes, or aborts.
STU-372
* build: regenerate dist for the integrated redesign baseline
* merge resolution: align STU-372 smoke fixtures with the integrated redesigned shell
- Unify the Playwright config on the STU-372 form (vite.config.js port 3000)
- Smoke tests assert the redesigned headline/composer/send control and reach
the Account surface for usage and tier badges (STU-375 shell reality)
- Suppress the first-visit walkthrough in smoke tests via its own flag
- Inertly stub PostHog analytics so a local .env with a real key cannot leak
telemetry or fail the console assertion
* feat: wire the redesigned prompt composer, attachments and local suggestions
The redesigned hero shell shipped the composer markup but never wired it:
#hero-send invoked runThinkingPipeline through an inline handler while the
pipeline still dereferenced the retired #btn-run-pipeline button outside its
try block, so every submission crashed silently and left
pipelineState.isRunning stuck true. Chips only copied their prompt, and the
ghost layer, tab hint and live announcement were inert markup.
Add src/composerAdapter.js and bind it from app.js (initComposer replaces
bindHeroChips): the ghost layer mirrors the textarea and surfaces the 13
local prototype suggestion patterns after the prototype's 220ms debounce
and six-character gate — no completion provider, credential or network
request is involved. Tab accepts only an active suggestion and otherwise
moves focus; Escape dismisses; Enter submits unless Shift is held or an IME
is composing, so Shift+Enter keeps its newline. Acceptance records the
accepted text and cannot re-trigger on it in a loop.
Submission goes through the existing pipeline exactly once per action: send
follows canSubmit (non-whitespace prompt, idle pipeline), hands its arrow
to the spinner while in flight, blocks duplicate submission, and never
touches the prompt or attached images, so an error preserves the user's
work. Attach and settings keep their existing wiring — the size/count
validation in handlePromptImageSelect is untouched and the settings icon
still opens the real generation settings. Chips fill their full source
prompt behind the demo caret with a settled aria-pressed state, exactly as
the prototype specifies.
Also define .sr-only inline (the composer label and live region leaned on
the Tailwind CDN for it), port the demo caret and busy-field styles with
their reduced-motion handling, and guard the legacy run button so surfaces
without it stay functional.
Cover the wiring with unit tests for the patterns, gates and the
tab-accept/escape-dismiss state machine, and a browser suite exercising
typing, Tab/Escape, chips, whitespace and duplicate-submission guards,
settings, attachment validation and touch controls at 360px and desktop.
STU-376
* STU-386: restyle magic-link sign-in and preserve return-to-task
Applies the redesign's design tokens and accessible-modal contract
(STU-374's shared sharedControls.js primitive) to the sign-in modal's
markup and styles, and fixes two behavioral gaps in the underlying
magic-link flow:
- The submit button was permanently disabled after a successful send,
so a user who wanted to resend or use a different address had no way
back in. It now always re-enables, with a transient "Sent!" label.
- Sign-in requests from the composer, plans, or billing surfaces had no
memory of where they came from. openSignInModal() now records the
current view (via the existing hash-based switchView router) in
localStorage before opening, and a successful magic-link verification
restores that surface and refreshes usage/plan state — without
triggering checkout or deploy.
- A failed/expired token now reopens the sign-in modal with the error
shown inline (in addition to the existing toast), so retry is a click
away instead of a dead end.
- STU-149's alias-policy error already had a message path; it now also
marks the input aria-invalid and preserves the rejected address.
Existing auth endpoints, request/response contracts, and token
stripping are unchanged. Scope is limited to sign-in markup/styles and
the UI state adapter, per file ownership.
Adds e2e/stu386-magic-link-signin.spec.js covering valid/invalid email,
send failure + retry, sent + resend, alias-policy rejection, token
failure, successful session refresh, and the Plans return-to-task
round trip, plus new deterministic fixtures in e2e/fixtures/apiFixtures.js.
Linear: STU-386
* STU-377: bind the generation view to real stage events and recoverable failures
The generation panel previously eased a fake progress bar toward 95% on a
120s timer and marked every stage completed when it hid, while real failures
were written into hidden legacy step panes - and the run itself threw on the
removed #btn-run-pipeline, so the redesigned shell could not run a pipeline
at all.
- Bind the three-stage view to the real Architect -> Generator -> Review
responses: the track advances only on a stage response, a stage that never
ran is marked skipped rather than completed, and the elapsed counter is the
only time-driven element.
- Show the submitted prompt with an Edit prompt control, and give each stage a
semantic button that is reachable only once the stage actually ran; announce
the status line politely via aria-live.
- Render a persistent failure state in the panel for safety rejection, quota
rejection, network failure, timeout and ordinary errors, each with a useful
retry / edit / upgrade action; a terminated run restores the send control
and lands focus on a permitted action. A failure never falls through to a
result.
- Report a generator model fallback while the run continues, and stop
retrying a quota refusal on the fallback model. Tag the 429 with its
pipeline step so the failing stage is attributed correctly.
- Carry a run id through every stage update so a response from an abandoned
run cannot overwrite a newer one; retries re-send the submitted prompt and
images unchanged.
- Restore the authored coral outline morph (composer -> panel -> Results) on
the --t-morph 1120ms token. It is decorative: the destination view is in
place before it plays, and it is skipped under reduced motion and cancelled
on resize, navigation and interruption.
Tests: 295 node tests (classifyPipelineError covered in
src/pipelineErrors.test.js) and 13 new Playwright journeys driving the three
stage transitions, the failure states at 360px and 390px, retry, fallback,
reduced motion and morph settlement via the STU-372 fixture harness.
* build: regenerate dist for the STU-377 generation view
* test: let parallel worktrees run the dev server and browser suite on their own port
CCC_TEST_PORT now drives the Vite port, the Playwright baseURL/webServer and the
fixture origin together. Without it every worktree competed for port 3000 and
reuseExistingServer would silently point a run at another worktree's server,
so browser evidence could validate the wrong revision.
--strictPort makes a taken port fail loudly rather than drifting to a free one,
which would leave baseURL pointing at a foreign server. test-results/ is now
ignored so run artifacts cannot be committed by accident.
* STU-389 port the hero mark field with motion and rendering fallbacks
- Add isolated src/heroMarkField.js module with WebGL2/2D canvas fallbacks
- Add scoped styles in src/heroMarkField.css
- Wire module into app.js DOMContentLoaded; no coupling to generation state
- Stop rendering on reduced-motion, hidden/offscreen, pointer:none and visibility changes
- Clean up listeners on pagehide/dispose and via IntersectionObserver
- Add Playwright tests for a11y, WebGL failure, reduced motion, navigation loops,
live motion toggling, typing/scrolling, and desktop visual/frame measurements
* STU-389: assert canvas pointer-transparency as an explicit contract
The click-through test does not genuinely falsify the pointer-events:none
contract: the canvas already sits behind the composer in z-order, so controls
stay clickable even without pointer-events:none. Assert the computed style so
removing the property fails the test.
* STU-378: rebuild Results Summary and artifact inspection with stable keyboard nav
- WAI-ARIA tablist/tab/tabpanel on the Summary + artifact tabs with roving
tabindex; ArrowRight/ArrowLeft/Home/End selection that re-applies focus
after the selection re-render.
- Bundle warnings rendered on the summary surface as a distinct class,
kept out of per-file review.
- Out-of-range/malformed review scores (e.g. 'Score: 999/100') are treated
as UNKNOWN, never fabricated as 0 or 100.
- Results copy uses the real Clipboard API with a non-blocking failure
notice when permission is denied.
- __CCC_RENDER_RESULTS__ test hook renders an arbitrary bundle + review
without running paid generation.
- e2e spec asserts tab rendering, keyboard navigation, unknown scores,
bundle-warning separation, clipboard denial/copy, and the ordered
equal-prominence result actions at mobile and desktop widths.
- Node and Playwright suites green; source screenshots at 360x800 and
1440x900 captured under docs/screenshots/stu378/.
* STU-381: bind topbar allowance and usage dialog to real monthly metering
Show the true remaining monthly runs in the topbar (clamped to zero) and the
used/limit text (e.g. "31 / 50 runs this month") plus resolved plan in the
usage/billing dialog, driven by getUsage()/getRunLimit() and the subscription
state. Remaining is clamped to zero; a loading or failed plan shows
checking/error copy, never a fake balance. All usage surfaces render from one
presentation adapter so a metered run and an auth/subscription refresh update
them together. Manage Subscription now passes the clicked trigger so every
billing entry point shares the single fresh portal session request and its
loading/error state (signed-out users follow the sign-in path). The dialog
content drops the leftover credits/wallet/top-up/sample-balance copy.
* fix(STU-394): restore the generation trigger after the redesign removed #btn-run-pipeline
STU-375 (f76ce38) replaced the legacy Run Pipeline button with the hero send
control but never updated runThinkingPipeline(), which still looked the old id
up and dereferenced it without a guard:
const btn = document.getElementById("btn-run-pipeline");
btn.disabled = true; // throws - the element no longer exists
Every Generate click therefore threw before any network call, so generation
was dead on the redesigned shell. Nothing caught it: the 288 Node tests, the
Playwright smoke suite and CI were all green, because none of them click
Generate and assert a run begins.
Point the lookup at #hero-send and drive the busy state the design already
defines (.send.is-busy swaps the arrow for a spinner) instead of rewriting the
button markup, which is now a two-colour-scheme SVG pair. Guard both the set
and the reset so a missing control can never break a run again.
Also drop the now-dead #btn-run-pipeline lookup in updateDeployButtonVisibility;
it was already null-guarded, but it referenced an element that no longer exists.
The new spec fails against the previous code and passes against this one:
- clicking Generate raises no uncaught error and the control goes busy
- the control returns to idle once the run settles (it must go busy first,
so the assertion is not satisfied by a control that never ran)
Verified: 288 Node tests, production build, 15/15 browser tests, and an A/B in
a real Chrome session where the previous code raised
"TypeError: Cannot set properties of null (setting 'disabled')" and this one
raises nothing and enters the busy state.
* test(STU-394): assert a real pipeline request, not just the busy state
Greptile is right: reaching the button setup only proves the handler ran that
far. A caught failure between the lookup and runPromptArchitect() would still
set is-busy, still be restored by the finally block, and still raise no
uncaught error — so both tests could pass without generation being requested.
Count requests to ENDPOINTS.pipeline and require at least one. The previous
test's PR description overclaimed what the assertion delivered.
Still fails against the pre-fix code and passes against this one:
broken: 2 failed fixed: 2 passed
Verified: 288 Node tests, full browser suite 15 passing.
* test(STU-389): make the frame-measurement test falsifiable and relabel the render artifact
Answer Finding 1 (AC4 vacuous frame measurement): replace the nav-timing
sanity check (duration<60s, could never fail) with a real before/after
frame metric on a NAMED device (Desktop Chrome HiDPI) - counts
requestAnimationFrame callbacks the field drives: >5 frames while running,
exactly 0 once reduced motion is enabled, and strictly fewer after. This
fails if the module is a no-op or stops animating (verified: breaking the
loop re-schedule -> Expected >5, Received 0; no-op module -> test fails).
Answer Finding 2 (screenshot mislabeled a 'comparison' with no baseline):
rename the test to 'captured as a human-review artifact', drop the
comparison/regression-gate implication, and assert the field is actively
rendering before capture so a field that never initializes cannot pass.
PR body Note corrected to state the artifact is human-reviewed, not a
pixel baseline.
* fix(STU-378): gate the results render hook on DEV so it is absent from production (finding 1)
window.__CCC_RENDER_RESULTS__ lets any script on the page force the results
view with arbitrary review content, bypassing generation, and it was shipped
unconditionally in the production bundle. The browser suite runs against the
Vite dev server, so wrapping the hook and its backing function in
if (import.meta.env.DEV) keeps it available to tests while Vite tree-shakes
the whole block out of npm run build output (verified: grep finds zero
occurrences of the hook in dist/). The e2e suite continues to drive the
results surface through the same seam under dev.
* test(STU-378): assert equal prominence of the three result actions (finding 2)
The spec header claimed 'equal-prominence ordered result actions' but the
test only asserted count, labels and visibility, so a button styled with a
smaller font, tighter padding or a hollow/outline treatment would have passed
while violating the criterion. Add an assertion that compares computed
font-size, all four paddings and fill (background-image != none) across the
three actions within a 0.6px tolerance, plus bounding-box height at desktop
(heights differ at mobile only because the longer labels wrap to more lines,
not because of styling).
Falsified: styling button two with font-size:10px and a transparent
background makes the assertion fail at both viewports; restoring the gradient
makes the suite pass again.
* test(STU-381): drive the post-run metering path through a real generation run
Finding 1 (review): the 'surfaces update after a successful metered run'
criterion was untested — the covering test only mutated the identity fixture
and page.reload()'d, never exercising the post-run reconcile path in
app.js (generator branch of callBuildShip). Drive-write a new test that fills
#pipeline-input, clicks #hero-send (the restored STU-394 trigger) with the
pipeline endpoint fixtured via ENDPOINTS.pipeline, and asserts the count moves
4 -> 7 (topbar 46 -> 43, account row and dialog show '7 / 50 runs this
month') as a consequence of the run, with no reload anywhere after the click.
Falsified: neutering updateUsageDisplay() in the generator reconcile branch
makes this test fail (topbar stays at 46); restored, it passes.
* feat(STU-388): make review feedback reflect real submission outcomes
Submit up/down review feedback with a real submission lifecycle: preserve the
connectFeedback endpoint payload and analytics path, but only mark a vote saved
after a confirmed 2xx. Add accessible names, aria-pressed (mutually exclusive),
an aria-live announcement region for pending/saved/error states, a pending lock
that swallows rapid double-clicks, and reset stale feedback state (visual,
aria-pressed, pending lock, status) when a new generation renders so a prior
vote is never silently carried into new code.
Includes browser tests asserting success, non-2xx and network-failure retry
(current generation payload), rapid double-click dedup, and cross-generation
reset.
* fix: skip verification of package-importing classes when the dependency graph cannot be reproduced exactly
Co-Authored-By: bot_apk <apk@cognition.ai>
* STU-383: build the account overview with real identity, usage and honest history states
Rebuild the account view into a single-source-overview bound to the same
session/subscription/metered state as the topbar (renderAccountOverview feeds
from renderUsageSurfaces): identity from the signed-in email, real plan/price,
a metered usage meter, and honest period information. A renewal/reset date is
shown only from a real subscriptionState.periodEnd — never fabricated. The
per-run breakdown/history the redesign illustrated is not persisted, so the
overview renders a truthful no-history state and keeps current results
reachable via the generation workspace. Guest/loading/unresolved states are
preserved and never surface stale private identity.
Verified: npm test (290), npm run build, and a targeted Playwright spec whose
load-bearing assertions were falsified (break -> FAIL -> restore -> PASS).
* feat(STU-382): build verified plans surface and paywall states on existing subscription contracts
- Unify Free/Pro/Power tier labels + limits across topbar, account badge, plans
page, usage dialog and generation gating via a single src/plansSurface.js
- Reconcile the plan feature matrix to CURRENT supported capabilities: drop
unsupported 'API & MCP Access' and 'early access' prototype promises, keep
BYOK (Configure API Keys), Code Regeneration, Code Review
- Defer the 'plan active' confirmation until the get-subscription contract
confirms a paid tier; a checkout return alone never claims subscribed
- Add plans/paywall e2e coverage (port 3092) and plansSurface unit tests
Generated-by: Claude
* feat(STU-380): truthful deployment terminal outcomes
Only a confirmed success reports committed. 403/file-rejection/compile-gate
stay failures; classes-written-but-sync-rejected is partial; a client wait
expiry or dropped stream is explicitly unconfirmed with reconciliation
guidance and never aborts/retries the in-flight write. Adds a bounded UI
timeout bound (120s, derived below Cloud Run's 900s server deadline), a pure
deploy-outcome classifier, and partial/unconfirmed terminal rendering with
consistent identity and per-file outcomes. Duplicate submit is prevented and
the UI busy state is released in every terminal state.
Attribution: Generated with Claude Code.
* test(STU-382): scan full document source for mock purchase-success to catch a hidden mock
The design's mock success copy lived in a hidden modal, so a body.innerText
scan could not catch its reintroduction. Assert on page.content() so an
inert/hidden mock is caught. Falsified: injecting the mock reddened the test;
restored: green.
Generated-by: Claude
* STU-377: isolate pipeline runs and make mid-run edit a real cancel
- Claim the run (isRunning + runId) before the first preflight await so a
second submission can never start a concurrent run, and commit each stage
result to shared state only after revalidating its run id — applied to the
main run and all three regeneration paths (Devin Review #83).
- editPipelinePrompt now abandons the run: invalidates the run id, aborts
in-flight BuildShip requests, and restores the send control, so an
abandoned run can never finish behind the composer and reopen Results.
- runCodeGenerator preserves a 429 on the fallback model: isUsageLimit
survives instead of being wrapped into a generic error, so the failure
panel still offers the upgrade action.
- Track the delayed hide timer and cancel it in showPipelineProgress /
showPipelineFailure / editPipelinePrompt so a run starting inside the
400ms window is not blanked by the previous run's hide.
- e2e: cover overlapping submissions, mid-run edit cancel, skipped
Architect on refinement, hide-window regeneration, and fallback 429.
Co-Authored-By: Stuart Gardoll <sgardoll@gmail.com>
* build: regenerate dist for the merged STU-377 generation view
Co-Authored-By: Stuart Gardoll <sgardoll@gmail.com>
* STU-377: keep regeneration failures on the persistent failure panel
runRefinement, regenerateFromPastedErrors and regenerateWithErrors used to
catch a failed run with hidePipelineProgress() plus a transient toast,
leaving an empty generation stage with no recovery control. Their catch
blocks now route through resolvePipelineErrorStep + showPipelineFailure
like the main run, and showPipelineFailure takes a retry callback so each
flow's Retry re-enters the same regeneration rather than a fresh pipeline
run.
Co-Authored-By: Stuart Gardoll <sgardoll@gmail.com>
* build: regenerate dist for the merged STU-377 generation view
Co-Authored-By: Stuart Gardoll <sgardoll@gmail.com>
* STU-377: unreachable skipped stages, zeroed elapsed clock, faithful retry payload
- setPipelineStageState disables a skipped stage's button and
selectPipelineStage ignores it, so a stage that never ran can no longer
be selected into active-stage wording.
- showPipelineProgress resets the elapsed label to 0s when a run starts
instead of showing the previous run's final reading until the first tick.
- pipelineState.submittedImages snapshots the attached images at submit
time and retryPipelineRun restores it (and re-renders thumbnails), so a
retry resends the submitted prompt and images unchanged even if the
composer's attachments changed since.
- e2e: assert the skipped Architect dot is disabled during refinement, and
that a retry carries the same image payload as the original run.
Co-Authored-By: Stuart Gardoll <sgardoll@gmail.com>
* build: regenerate dist for the merged STU-377 generation view
Co-Authored-By: Stuart Gardoll <sgardoll@gmail.com>
* fix: restore regeneration controls for runs abandoned by edit
Cancelled refinements/regenerations skipped their finally cleanup
because restoring the trigger button lived inside the current-run
guard, leaving Refine / Fix Errors disabled until another run.
Each flow now restores only its own button unconditionally while
shared pipeline state stays guarded. Adds an e2e journey covering
edit-mid-refinement control restore and discarded late responses.
Co-Authored-By: Stuart Gardoll <sgardoll@gmail.com>
* build: regenerate dist
Co-Authored-By: Stuart Gardoll <sgardoll@gmail.com>
* fix: keep isUsageLimit through Architect and Review wrappers
A 429 from Architect or Review was wrapped into a plain Error, so the
failure view classified it generic — Retry offered though the quota
was exhausted, no View plans, and pipelineStep lost. Both wrappers now
rethrow isUsageLimit unchanged, matching runCodeGenerator. Adds e2e
journeys asserting quota classification and the upgrade action on
stages 1 and 3.
Co-Authored-By: Stuart Gardoll <sgardoll@gmail.com>
* build: regenerate dist
Co-Authored-By: Stuart Gardoll <sgardoll@gmail.com>
* fix(STU-388): require positive confirmation before announcing a saved vote
A 2xx response lacking a truthy success field was treated as saved: the
old guard (`!result || result.success === false`) fell through on a live
200 carrying an error-shaped body ({"error":"quota exceeded"} or
{"ok":false}, no top-level success), marking the direction pressed,
announcing "Review feedback saved." and clearing the pending lock — a
failed submission reported as saved with no retry.
Require the explicit `success: true` the backend contract returns before
claiming a save; any unconfirmed 2xx now falls into the failure/retry
state. Adds a browser test covering a 200-with-error-shaped body (both
the {"error": ...} and {"ok":false} shapes, then a real success with the
current generation payload), which was falsified against the old check
(pressed vote on an error body) and passes against the fix.
* test+fix(STU-380): classify explicit HTTP rejections as FAILED and falsify the submit guard
Finding 1 (major): a definitive server rejection is FAILED, not UNCONFIRMED.
A 403/5xx where response.ok === false and no streamed "result" event arrives
was previously thrown as UnconfirmedDeployError, telling the user to reconcile
in FlutterFlow when the server had plainly refused the write. The stream
handler now surfaces httpRejected/httpStatus, and provisionMissingCodeFiles
uses a shared decision (deployOutcomeOfStreamResult) that maps an explicit
HTTP rejection to FAILED; only an ok response whose stream dropped (or a wait
expiry) stays UNCONFIRMED. Coverage now drives the real stream handler against
a true 403/5xx Response instead of only the render hook. Falsified: breaking
the classifier to mislabel a rejection as UNCONFIRMED fails the new tests.
Finding 2 (minor): the duplicate-submit guard and busy-release are now
falsified, not just asserted. New DEV hooks drive the real confirm flow
(setDeployBusy, openCommitConfirmModal); a test proves a duplicate confirm
while in flight is ignored (fails when the guard is removed), and a test proves
a terminal state releases the busy lock on the deploy controls (fails when
setDeployBusy(false) is dropped from renderCommitTerminal).
Attribution: Generated with Claude Code.
* STU-383: fix duplicate h1, cover the connected card branch, and gate the screenshot test on layout
Finding 1 (BLOCKING): the account view title was a second <h1>, breaking the
smoke spec's page.locator('h1') strict-mode assertion and turning CI red.
The account view's title is now an <h2> so the document keeps a single
top-level heading and the existing smoke selector resolves to one element
(duplicate h1 was the defect, not the assertion; verified 1/6 FAIL -> 6/6 PASS).
Finding 2: the connected branch of the FlutterFlow connection card was never
exercised (every test ran keyless). Added a test that seeds real encrypted-at-
rest FlutterFlow credentials (AES-GCM under a session-migrated JWK, decrypted
through the app's genuine getApiKey path) and asserts 'Connected to
FlutterFlow', the ok dot, the default-project row and the endpoint label.
Falsified: breaking the connected branch fails the test; restoring passes.
Finding 3: the screenshot test captured PNGs without asserting anything,
so any visual regression passed unnamed. Relabelled it explicitly as a
human-review artifact (pixels not compared) and added a genuine falsifiable
layout gate (no horizontal overflow, card inside the viewport, non-empty).
That gate caught a real pre-existing mobile overflow (the connection row's
Configure button pushed past 360px by the long status text), which is fixed
with a mobile-only flex-wrap. Screenshots regenerated to reflect the fix.
Falsified with an injected 8000px wide element (FAIL at 8389px) then restore.
Full CI sequence green: npm test (290), npm run build, 57 browser tests,
accessibility (7 shells), deploy-guard (9/9), proxies (30/30), leak scan.
* fix(STU-382): hermetic price test, wire PLAN_FEATURES to the UI, restore owner rows
Addresses the four review findings on the plans/paywall surface:
1. [BLOCKING] Make the plans-card price assertions hermetic. C5 asserted
A\$11/A\$49 while formatPrice converts whenever the detected currency is
not AUD, so CI (UTC) rendered \$7.15/\$31.85 from the same code that a
Sydney machine renders A\$11/A\$49 from. Pin the suite's timezone/locale
to Australia/Sydney/en-AU so the verdict no longer depends on the host;
the now-failed-in-CI assertion passes deterministically in any timezone.
2. [major] Wire PLAN_FEATURES to the UI instead of leaving it a dead
constant. app.js now renders every .pm-card's feature list (both the
plans page and the pricing modal) from src/plansSurface.js PLAN_FEATURES,
and index.html's duplicated hardcoded row copies are removed. The unit
and browser tests now assert the constant that actually drives the DOM,
and the pricing modal's unsupported-promise path is covered, not just the
plans page.
3. [minor] Make the "verified return refreshes allowance" test title honest:
assert #topbar-credits-count shows the refreshed allowance (50-12=38)
rather than only claiming it.
4. [PRODUCT DECISION] Restore the "API & MCP access" and "All models + early
access" rows the owner asked to keep, in both rendered surfaces, marked
clearly not-yet-available ("(coming soon)") so nothing unavailable reads
as available. Pro/Power remain the canonical labels (the divergent
"Professional"/"Power Developer" display map is gone).
* fix(STU-382): present API & MCP access as available, not "coming soon"
The previous revision restored the owner-requested rows but marked them
unavailable, on the stated reasoning that "the product exposes no user-facing
API/MCP surface". That inference was drawn from the codebase and is wrong: both
capabilities ship. API & MCP access is reached by contacting us rather than
through a self-serve signup.
- API & MCP access now renders as "API & MCP access (contact us)" — the
capability is presented as available and names its access path.
- "All models + early access" is no longer suffixed.
- Replace UNSUPPORTED_PROMISES/UNAVAILABLE_MARKER with CONTACT_TO_ACCESS_MARKER.
The two rows are now entries in the supported-capability whitelist, so the
criterion-5 guard rejects a not-yet-available reading rather than requiring it.
- Tests assert the rows are NOT marked unavailable.
Falsified: reintroducing "(coming soon)" fails 1 unit test and the C5 browser
test; restoring passes 296/296 and 9/9.
Full CI sequence green: npm test 296, build, 54 browser, 7 accessibility
modals, 9/9 deploy guard, 30/30 proxies, leak scan clean.
* feat(STU-384): response-driven account connection state and endpoint invalidation
- Add canonical ffConnectionState driven by real listProjects outcomes
(not-configured/validating/connected/no-projects/unauthorized/network-error).
- Render account connection card from that state; 'Connected' only after an
actual projects response. Masked/raw keys never appear in status labels.
- Endpoint change invalidates stale project selection and re-fetches.
- Account card default project mirrors the deploy-confirmation stored value.
- Startup validates a stored key so the card is truthful on load.
* feat(STU-387): implement account access & data actions without fake deletion or allowance resets
- Sign out clears the session and immediately re-renders identity/plan UI
- Send new link reflects the real response/failure, duplicate sends guarded
- Clear cache scoped to named disposable UI caches; allowance/identity kept
- Delete account presented as truthful, keyboard-discoverable, unavailable
- Behavioral unit tests + Playwright spec; all 4 criteria falsification-verified
Generated by Claude Code.
* docs(STU-387): add visual slices of the account access & data surface at 360x800 and 1440x900
* feat(STU-384): browser fixtures for account connection states
7 hermetic browser tests covering not-configured, validating->connected,
no-projects vs 401/403 vs network-error, endpoint-change invalidation,
save/reload/remove single-value, and signed-out entry. Expose
validateFlutterFlowConnection for a visible retry path.
* docs(STU-384): reference screenshots of connected account state
1440x900 and 360x800 captures of the account connection card showing a
response-driven Connected state, default project, and endpoint.
* chore(STU-384): restore untouched STU-383 screenshots to upstream state
* test(STU-386): assert the badge renders "Pro", not "Professional"
The tier label was unified to Free/Pro/Power by STU-382, which landed on the
integration branch after this PR was cut. The assertion passed on this branch's
original base (which rendered "Professional") and fails on the merged tree —
the sole failure, 89/90. Found by an independent reviewer building the real
merged tree rather than testing the branch in isolation.
This merge commit also brings the branch up to date with the integration branch
(merge, not rebase). The only conflicts were in generated dist/ artifacts, which
CI rebuilds; they were resolved to the integration branch's version.
* feat(STU-379): preserve previous results through refinement and build-error regeneration
Refinement and pasted-build-error regeneration now hold the prior
successful result (selected artifact, full bundle, prompt, review, selection)
until a replacement generation AND review both succeed. A generator or
reviewer failure restores that result behind a persistent error + retry
banner - never a transient toast or a blanked Results surface.
- Snapshot/restore shared pipeline result state around replacement runs
- Commit the new result atomically only after generation + review succeed
- Reconcile artifact selection to the new bundle (no stale tabs)
- Add persistent, keyboard-accessible error + retry banner over the results
- Restyle the pasted-build-errors panel with shared controls; scope labels
- Guard duplicate clicks (no double charge), keep telemetry/usage contracts
* feat(STU-390): port authored logo animation into the pipeline presentation
Add an isolated logo-motion module driving the hero's two-piece mark and
twelve-beat, seamless 120s loop as a decorative element in the pipeline
arena. Decorative only: pipeline completion depends solely on real backend
events. Static rest frame for reduced motion; pause clock/rendering when the
tab is hidden, the pipeline is CSS-hidden, or the arena is offscreen; single
guarded rAF loop with cleanup-safe dispose. Includes unit tests for the
pose(0)==pose(120) loop boundary and brand geometry, plus Playwright lifecycle
and progress-independence coverage and per-beat review screenshots.
* fix(STU-379): surface upgrade affordance on quota failure in replacement path
A quota/usage-limit failure during refinement or build-error regeneration
rendered only a title, a message, and a retry that re-fired generation into
an already-exhausted allowance — a dead end. Route the replacement-failure
banner through classifyPipelineError's canUpgrade signal so a usage-limit
rejection offers 'View plans' (opening the pricing modal) as the primary
action instead of retry, matching the non-replacement path. The previous
result stays visible and copyable behind the persistent banner, and focus
lands on the action that can resolve the failure. Covers both refinement
and the shared build-error renderer with hermetic browser tests using the
existing quotaExhausted fixture.
* fix(STU-384): honor configured endpoint in connection/project fetches; add remove-leg browser test
- validateFlutterFlowConnection, fetchProjects and populateConfirmProjectSelect
now pass getFlutterFlowEndpoint() into FlutterFlowApiClient instead of
relying on the production default, so the account card and project dropdown
reflect the configured (staging) server like the deploy flow does.
- Endpoint-change test now feeds DIFFERENT lists to production vs staging and
asserts the staging-only project appears, so it fails if the client falls
back to production.
- New browser test removes the stored key and asserts the connection card
returns to not-configured with no raw/masked key material remaining.
* fix(STU-387): make the e2e genuinely refuse a second send and surface the real failure reason
Address two review findings on the account Access & data slice:
1. The 'second send refused' e2e never attempted a second send, so it passed
even with the controller's in-flight guard deleted. It now fires the send
handler twice in one synchronous tick (the first deterministically sets the
in-flight flag before the second call runs) and asserts exactly one request
reaches the endpoint. The fixture no longer passes a never-resolving
Promise into route.fulfill, which silently aborted the first request and
was resetting the controller's in-flight state.
2. The reauth error UI threw away the specific failure reason, so a 429
rate-limit and a 500 read identically. renderSendNewLinkState now surfaces
the propagated user-safe reason (e.g. 'HTTP 429' vs 'HTTP 500'), falling
back to the generic copy only when no reason escaped, and a new e2e asserts
rate-limit and server-error copy differ.
* ci: re-trigger CI
The pull_request CI run for the preceding commit was never created (no 'CI'
check-run exists for it, while the sibling Code scanning job did run). Re-pushing
to obtain a CI verdict; no source change.
Unrelated: the 'Code scanning AI findings' job fails with GitHub's own
'CAPIError: 400 The requested model is not supported' from the Copilot autofind
agent. It fails identically on other PRs and on this branch's earlier sha, so it
is not caused by this change.
* STU-391: bring tutorials and onboarding into the new visual system
Restyle the welcome/tutorial modal with the new design tokens (Delight,
coral accent, OKLch surfaces), add an accessible, non-autoplaying tutorial
video (CCC.mp4) with native keyboard-reachable controls, add a first
onboarding step that routes to the real FlutterFlow account editor, and
return to the originatng step on success (forward) or failure (same step).
Closing the shared settings editor opened OUTSIDE onboarding no longer
launches or advances the walkthrough (onboarding-scoped re-entry flag).
Add behavioral e2e coverage: fresh onboarding, dismiss remembered, reopen
via the Tutorial nav entry, connection success->composer and failure->
same-step, settings-outside-onboarding, reduced-motion usability, and
video non-autoplay/keyboard/controls.
* test(STU-391): poll for walkthrough reopen instead of a hard sleep to avoid CI-load timing flakes
* fix(STU-391): gate walkthrough advance on real connection, not stored bytes
The connect step advanced the tour whenever key bytes were stored, even
when the routed listProjects call rejected the key. Gate the advance on
the STU-384 canonical ffConnectionState (only 'connected' after a real
successful projects fetch) instead of hasStoredKey, and await the real
connection outcome in saveApiKeys so the decision never rests on an
in-flight 'validating' state.
Tests: the rejected-key e2e now types a key the routed-401 endpoint
rejects and asserts the walkthrough stays on the same step with the
failure surfaced; the cancel path stays its own case. Reopen waits are
state-based (fixing the cold-start deferred-module race), the fixed
sleep became a poll, and the video keyboard-reach assertion Tabs to the
video instead of the near-vacuous tabIndex check.
Verified: node tests 331/331, build, full browser suite 155/155
(repeated cold+warm runs), accessibility, deploy-guard, proxies, leak
scan.
* test(STU-391): gate walkthrough clicks on the open animation settling
The failing CI run had a flake where, after the walkthrough reopen was
asserted open, the inner step-link click raced the modal's fade/scale-in and
the pointer hit #main-stage. toBeVisible and .open/aria-hidden all pass the
instant the transition starts, so the previous state check did not prevent a
mid-animation click under load.
Replace the reopen/click path with state waits tied to the animation itself:
waitForWalkthroughOpen waits for the .modal-content transform to settle back
to scale(1) and the overlay opacity to reach 1; every walkthrough interaction
(step links and Got-it) now asserts its target is visible and waits for that
settled state before clicking, rather than timing on a fixed sleep.
* test: fix load-fragile browser specs blocking CI (STU-391, STU-390)
STU-391 (e2e/stu391-tutorial-onboarding.spec.js): waitForWalkthroughOpen no
longer matches a single exact computed-style string. Parse the 1:1 matrix so
any settled scale(1)/identity spelling (and 'none') is accepted while a
mid-open value (scale<1 or a translate) still returns false, and treat any
unrecognised transform as settled so the wait can never deadlock on a refactor.
Keep the aria-hidden==false and overlay opacity==1 checks; the click target
still passes Playwright actionability. This keeps the race removal (no click
lands mid-open) and is satisfiable in every settled open state.
STU-390 (e2e/stu390-logo-loop.spec.js): the resumed-loop 'no doubling' claim
compared two separate wall-clock frame windows (framesResumed vs framesOn),
which a contended CI runner stalls and tripped (framesResumed < framesOn*1.7).
Replaced with a load-invariant ratio: logo frames per display frame measured
over the SAME window. A single loop paints at most once per display frame; a
genuine second loop paints ~2x. Contention sags both counts together, so it
cannot falsely fail on a slow runner while still catching a real doubling.
This is a carve-out to unblock the shared CI gate, not a STU-391 change —
#96 changes no logoMotion file.
* fix: restore attachment thumbnails/model-capability wrapper in the redesigned composer and cancel pending suggestion on Escape
Co-Authored-By: bot_apk <apk@cognition.ai>
* build: add rebuilt dist bundle
Co-Authored-By: bot_apk <apk@cognition.ai>
* fix: Devin Review findings — bind composer before awaited startup, gate submit during chip typing, restore disclosed-approximation verification
- initComposer now runs before the first awaited startup call (initializeAuth), so the send button's listener exists while auth/subscription requests are in flight; early clicks are no longer dropped.\n- Chip demo-typing now counts as busy: send stays disabled, Enter is rejected while a chip types its prompt, and doSubmit re-checks the state, so a half-typed chip prompt can never submit.\n- Restore the disclosed-approximation verification policy (planCustomCodeVerification returns approximate notices plumbed to the deploy result and success modal) instead of skipping every package-importing class — matching the approach defended in this PR's review thread.\n\nCo-Authored-By: Stuart Gardoll <sgardoll@gmail.com>
Co-Authored-By: Stuart Gardoll <sgardoll@gmail.com>
* fix: Devin Review round 2 — await startup session before entitlement checks, cancel stale suggestion timer on chip fill
Co-Authored-By: Stuart Gardoll <sgardoll@gmail.com>
* fix: Devin Review round 3 — reserve attachment slots, gate submit on pending uploads, resync composer on prompt restore
Co-Authored-By: Stuart Gardoll <sgardoll@gmail.com>
* fix: Devin Review round 4 — ordered attachment slots, disable attach controls during a run
Co-Authored-By: Stuart Gardoll <sgardoll@gmail.com>
* fix: Devin Review round 5 — abort discarded uploads with timeout, make busy composer inert
Co-Authored-By: Stuart Gardoll <sgardoll@gmail.com>
* fix: Devin Review round 6 — freeze submitted attachments before preflight awaits
Co-Authored-By: Stuart Gardoll <sgardoll@gmail.com>
* fix: let pending-thumbnail remove button receive clicks through spinner
Co-Authored-By: Stuart Gardoll <sgardoll@gmail.com>
* STU-385: move generation options into the composer settings dialog
The composer's gear opens a generation-settings dialog that presents the
existing model selector and the supported generation option (image input).
#code-generator-model remains the single element the pipeline reads, so
selections reach the actual request unchanged; provider keys stay a shortcut
into the canonical account connection editor; ordinary close returns focus to
the composer and never reopens the walkthrough.
Regenerates the tracked dist/index.html and prunes the orphaned bundle.
* STU-385: keep the canonical model selector on the page; dialog mirrors it
Removing the on-page #code-generator-model flattened the hero so it no longer
overflowed at 390x844, breaking the hard 'page is reachable' scroll invariant
(stu389). Keep the canonical selector in hero-settings and make the composer
settings dialog's model control a synchronized mirror that forwards to it, so
dialog selections still reach the actual generation request while availability
labels and the free-tier upgrade action stay in step. stu377 back to baseline.
* STU-385: capture composer-settings dialog screenshots for review
Free-tier and paid-tier dialog at 360x800, 1440x900.
* chore(STU-385): drop dist/ from this PR — the committed artifact was broken
The branch regenerated dist/index.html and deleted dist/assets/index-oXwmwfKe.js
without committing a replacement bundle, so dist/index.html referenced
/assets/index-BdVVPYTYC.js while dist/assets held only a font and a CSS file —
a built page whose entire application script 404s.
dist/ is regenerated by CI (`npm run build`) and by the documented deploy path,
and tracked build output is what caused a cross-PR merge conflict earlier today.
Reverted to the integration branch's copy; no source change.
* build: regenerate dist so the tracked artifact carries the final milestone wave
An independent acceptance audit found the committed bundle stale: the tracked
dist/assets/*.js contained no STU-385 code (`openComposerSettings` grepped 0 in
the committed bundle, 1 in a fresh build). scripts/deploy_ftp.py only checks that
a bundle EXISTS, not that it is current, so deploying the tracked dist unchanged
would have shipped the final milestone surface missing.
This is the standing consequence of keeping dist/ out of feature PRs (which is
still correct — it avoids cross-PR merge conflicts on generated output). The
tracked artifact is refreshed once here, at integration, from the tip.
* fix: read every own-key for a dependency's source, count exports, bound sdkPackages
Review follow-ups on STU-368.
readSourceDirective returned the first own-key, but YAML mapping order is
not significant: 'version:' written above 'hosted:' read as a plain
constraint and the manifest resolved the package from pub.dev rather than
the project's host. Every own-key is now read and any source key wins over
'version:'.
extractPackageImports matched only 'import'. A class exporting a git or
path package was compiled against a manifest missing it and the analyzer
refused the deploy over an unresolvable URI, instead of the class being
skipped with a warning. 'export' directives now count.
The runner's sdkPackages array had no count bound after the allowlist was
removed; it now shares maxDependenciesPerRequest, and package names in
every manifest field are capped at pub's 64-character limit.
Co-Authored-By: bot_apk <apk@cognition.ai>
* fix(STU-380): truthful outcomes for unreachable push/provision paths
Devin Review on #91 flagged three places where the deploy could still
misreport what FlutterFlow did:
- pushCodeWithRetry flattened exhausted retries into a generic failure even
when attempts ended in transport errors (the request may have been
applied). Track sawTransportError/lastHttpStatus and throw an
UnconfirmedDeployError when any attempt lost its response; keep a plain
error only when every attempt was an HTTP refusal. An ok-but-unparseable
sync response now reports unconfirmed for the same reason.
- provisionMissingCodeFiles only bounded the stream read — a runner that
accepts the connection but never flushes headers waited forever. Move the
fetch inside the bounded wait (fetch rejection -> unconfirmed, since the
POST may have landed), and stop feeding progress updates to the UI after
the bound expires.
- targetIdentity.projectId was only assigned after provisioning succeeded,
so an unconfirmed provisioning outcome reported a null target. Assign it
immediately after project validation in both executeCommit and
executeBundleCommit; commitToFlutterFlow's catch now also returns
unconfirmed instead of a generic failure.
UnconfirmedDeployError moves to src/deployOutcome.js next to the outcome
taxonomy, with exhaustedPushError encoding the transport-error rule.
readProvisionResponse now converts a mid-stream read failure into the
no-final-result path (unconfirmed) instead of throwing, with a comment
documenting the runner contract that makes a non-2xx always definitive.
Unit: exhaustedPushError covers both branches; provisionStream covers a
mid-stream drop. E2e: four transport regressions drive the real fetch/
retry/timeout code against routed fixtures (stalled headers, truncated
stream, all-abort push, all-500 push), each falsified against the old
behaviour.
Co-Authored-By: Stuart Gardoll <sgardoll@gmail.com>
* fix(STU-380): classify dropped bodies on their status, not their read error
Devin Review on #99 flagged that a response body lost mid-flight shared the
wrong catch in two places:
- parsePushCodeResponse awaited the clone's text() unguarded, so an ok
response whose body never arrived propagated a raw read error -> rendered
failed/partial instead of unconfirmed. The read is now best-effort: a
non-ok status still returns a definitive failure; an ok status throws
UnconfirmedDeployError even when nothing of the body survived.
- pushCodeWithRetry read the error body inside the same try as fetch, so a
body read failure set sawTransportError and could send a non-500 refusal
back through the retry loop. fetch rejections are now the only thing that
marks a transport error; the logging read is best-effort and non-500
statuses return immediately.
E2e: a 200/500 whose body resets mid-read classify on their status
(unconfirmed vs failed), a 500 with an unreadable body is reported as the
known refusal rather than an unknown outcome, and a 403 is never retried.
Co-Authored-By: Stuart Gardoll <sgardoll@gmail.com>
* fix(STU-380): drop the cached project snapshot on unconfirmed provisioning
Devin Review on #99: a provisioning request that ends unconfirmed (UI bound
expired, fetch rejected, stream dropped before a result) may still have
written classes on the runner, but resolveProjectPubspec's cached export
predates them — the next deploy would read the stale snapshot and provision
the same class again. provisionMissingCodeFiles now wraps the bounded wait
and result classification in a catch that invalidates the project source
cache whenever the thrown error is UnconfirmedDeployError; definitive
refusals (plain Error, no write happened) keep the cache.
E2e: seed the cache via __CCC_RESOLVE_PROJECT_PUBSPEC__ (exportCode routed),
stall provisioning past the UI bound, then resolve again — the export must
run a second time. Falsified: without the invalidation the second resolve
hits the cache.
Co-Authored-By: Stuart Gardoll <sgardoll@gmail.com>
* test(STU-380): deliver the phase chunk before the mid-stream drop
Devin Review: erroring a stream synchronously after enqueue() discards the
queued chunk (per the Streams spec, error() clears the internal queue), so
the test could only exercise a pre-first-chunk failure. Error in pull()
instead so the phase event is delivered, then the connection drops — and
assert onPhase received it, pinning the mid-stream path.
Co-Authored-By: Stuart Gardoll <sgardoll@gmail.com>
* fix(STU-380): surface definitive deploy failures in the terminal modal
#step3-output sits inside permanently-hidden legacy containers, so
showCommitFailureModal painted the refusal where no user could see it —
the progress overlay closed and nothing appeared. A definitive rejection
now also populates the shared commit-terminal-modal (same identity rows,
per-file outcomes, and guidance as partial/unconfirmed); the hidden
step3-output render stays for the regenerate wiring and legacy readers.
Found while driving the real confirm flow in the browser: a provisioning
HTTP 500 classi…
Summary
Follow-up fixes for the three Devin Review findings on #91 (already merged). The theme: when a remote write's response is lost, the code must not guess an outcome —
unconfirmedfor "the request may have landed",failedonly for a definitive refusal.Unknown push outcome appeared partial/failed.
pushCodeWithRetryretried on both HTTP 500s and transport errors but flattened exhausted retries into a generic failure — even though a dropped connection means the request may have been applied. It now trackssawUncertainAttempt/lastHttpStatusand throwsexhaustedPushError(), which always yieldsUnconfirmedDeployErrorsince exhaustion is only reachable through attempts whose outcome can't be proven.Provisioning request could wait forever.
provisionMissingCodeFilesonly wrappedreadProvisionResponseinwithUiTimeout; thefetchitself sat outside, so a runner that accepts the connection but never flushes headers hung the deploy indefinitely. Fetch + stream read now run inside one bounded promise; afetchrejection maps toUnconfirmedDeployError(the POST may still have reached the server), and progress handlers stop mutating the UI once the bound expires (progressLiveflag).Unconfirmed deploy lost project identity.
targetIdentity.projectIdwas assigned only after provisioning succeeded, so an earlyunconfirmedoutcome reported a null target. BothexecuteCommitandexecuteBundleCommitnow assign it immediately aftervalidateFlutterFlowProjectId.commitToFlutterFlow's catch also returnsunconfirmed: trueforUnconfirmedDeployErrorinstead of a generic failure.Dropped bodies classified on the wrong catch (second commit, from review on this PR): a response whose body resets mid-read shares the connection's failure across
json()and the clone'stext().parsePushCodeResponsenow treats the clone read as best-effort — non-ok status still returns a definitive failure, ok status throwsUnconfirmedDeployErroreven when no body survived.pushCodeWithRetrymarks uncertainty only for fetch rejections and 5xx; the error-body logging read is best-effort, and non-5xx refusals return immediately instead of being retried.Unconfirmed provisioning left a stale project snapshot (third commit, from review on this PR):
resolveProjectPubspeccaches the project's exported source before provisioning, and the cache was invalidated only on confirmed success — so a provision that timed out client-side but completed server-side left a pre-write snapshot that made the next deploy re-provision the same classes.provisionMissingCodeFilesnow callsinvalidateProjectSourceCache(apiClient)on everyUnconfirmedDeployErrorpath (fetch rejection, UI-bound expiry, stream dropped before a result) as well as on success; definitive refusals keep the cache since no write occurred.FAILED outcomes rendered nowhere visible (found driving the real confirm flow in the browser):
showCommitFailureModalpainted the refusal only into#step3-output, which sits inside permanently-hidden legacy containers (#step3-content/#step3-result) — the overlay closed and the user saw nothing. It now also populates the sharedcommit-terminal-modalwith the same identity rows, per-file outcomes, and guidance as the other terminals; the hidden#step3-outputrender stays for the regenerate wiring and legacy readers.Later refusal hid an uncertain push; push 5xx counted as definitive (fourth commit, from review on this PR): a non-500 refusal was returned without consulting the uncertainty flag, so "lost response → 403" reported FAILED even though the dropped attempt may have landed — and a post-request HTTP 500 could be labelled a clean refusal.
syncCustomCodeChangeshas no documented rollback contract, so any 5xx or dropped response is now uncertain: a later refusal after an uncertain attempt stays UNCONFIRMED, a non-500 5xx throwsUnconfirmedDeployErrordirectly, and FAILED only covers provably pre-write refusals — which is what makes the failure modal's "nothing was applied, retrying is safe" copy true. The provisioning endpoint keeps its documented contract (non-2xx = pre-write).Stalled push parked the deploy forever (fifth commit, from review on this PR): the push had no timeout, so a fetch that never flushed headers — or a body that stalled mid-parse — blocked the retry loop and the terminal UI indefinitely. All three commit paths now run
pushCode+parsePushCodeResponseinsidepushCodeWithUiTimeout, which expires intoUnconfirmedDeployErrorand leaves the in-flight write running rather than aborting a request that may have landed. AstopTokenalso stops the loop issuing new attempts once the bound expires, so a late 500 can't fire another POST over a user's fresh retry.Failure copy lied about provenance (sixth commit): "FlutterFlow refused this deploy" also showed for failures that never reached the server (missing credentials, local validation, compile gate). Definitive remote decisions now carry
remoteRefusalonto the result; the terminal says "refused" only for those, and "failed before FlutterFlow confirmed anything" otherwise — both keep the honest "nothing was applied, retry is safe" guidance, which is true because FAILED is provably pre-write. Separately, a provisioning refusal with an unreadable body reported "connection dropped" instead of its HTTP status; the status now wins the message.Runner failures after the deploy began masqueraded as refusals (seventh commit, from review on this PR): a streamed
result{success:false}was markedremoteRefusalunconditionally, but the runner reports CLI failures/timeouts even after the deploy script's upload phase already wrote classes — so the modal's "nothing was applied / safe to retry" copy could lie. Runner failures are now classified by stage:readProvisionResponserecords the last reported phase, andisPreWriteRunnerFailurecounts a failure as a refusal only when it's provably pre-write (runner'spreWritemarker,analyzerErrorscompile-gate report, a phase beforedeploy_start, or a non-streaming runner's 4xx). Post-deploy_startfailures throwUnconfirmedDeployError("…classes may already be written…reconcile before retrying"). The runner now stampspreWriteon every failure payload so the contract is explicit for future deploys.Post-start unconfirmed skipped cache invalidation; stalled error bodies hid refusals; provenance misattributed (eighth commit, from review on this PR): three follow-ups — (a) the new post-
deploy_startunconfirmed throw sat outside the cache-invalidating catch, so a stale snapshot could cause a second provision of written classes; the classification now runs inside the protected region. (b) A stalled error body could hide a definitive refusal behind the UI bound, and a detached-but-unbounded read would still leak the stream's connection: the retry loop's log read andparsePushCodeResponse's body reads all go throughreadBodyBounded, which holds the reader so thePUSH_BODY_READ_TIMEOUT_MS(10s) expiry cancels the stream for real (reader.cancel()works wherebody.cancel()rejects on the locked stream) — the status alone classifies on expiry. (c) Provisioning-path failures carriedremoteRefusal, but the endpoint is our runner — a compile-gate or validation rejection is not FlutterFlow's answer, so they now render the pre-write failure copy;remoteRefusalis reserved for realsyncCustomCodeChangesrefusals.Connection-hygiene and cache-settlement follow-ups (ninth commit, from review on this PR): (a) the runner's
FormatException → preWritehandler was scoped to request parsing — the same exception from the CLI-output UTF-8 decoder can fire afterdeploy_startand now falls to the genericpreWrite: falsecatch instead of masking a written class. (b) The push loop's diagnostic read consumes the response's own stream when the response won't be returned (500 retries, 5xx/uncertain throws) — aclone()read would tee the body and leave the original branch's connection open; only the definitive-refusal path clones, since its original goes toparsePushCodeResponse. (c) An unconfirmed provision leaves files maybe-in-flight on the runner — a request settling is not proof the write finished (a dropped stream can precede it). Possibly-written files are tracked inprojectSourceDirtyWritesas per-(path, content){uncertain, verified}tallies: unconfirmed attempts adduncertain, confirmed provisions addverified(never clearing shared state), and an export sighting consumes a verified credit before it can retire an uncertain write — so neither a later success nor an identical retry can retire an earlier write that may still land over a newer edit. A late-arriving verified result resolves its own attempt. (d) The same hole existed on the sync-push side:pushCodeWithUiTimeoutnow marks the project pending while an unconfirmed push's request is still in flight, blocking export caching until it settles — a request-response write can't land after its connection ends, so settlement is the reliable boundary there.Supporting changes:
UnconfirmedDeployErrormoved tosrc/deployOutcome.jsbeside the outcome taxonomy;exhaustedPushError()encodes the uncertainty rule there.readProvisionResponseconverts a mid-stream read failure into the no-final-result path (→ unconfirmed) instead of throwing; it also surfacesrunnerPhase/httpStatusso callers can tell where a runner-reported failure happened.provisionMissingCodeFilestakes an optionaluiTimeoutMsfor testability; new__CCC_PROVISION_CUSTOM_CLASSES__/__CCC_PUSH_CODE_WITH_RETRY__/__CCC_PUSH_CODE_WITH_TIMEOUT__/__CCC_PARSE_PUSH_RESPONSE__hooks drive the real transport code in e2e.Tests
npm test: 354 pass (new unit tests:exhaustedPushError→UnconfirmedDeployError; mid-stream drop → unconfirmed; unreadable-body refusal keeps its HTTP status; post-deploy_start/uploadingfailures → unconfirmed; pre-deploy runner failures → failed; non-streaming 4xx refused vs 5xx unconfirmed).npm run build: clean; the trackeddist/artifact is regenerated so the deployed bundle carries these fixes.npx playwright test: 206 pass, incl. 24 transport regressions driving the real fetch/retry/timeout/parse code — stalled headers, truncated stream, all-abort push, all-500 push, refusal-after-lost-response, refusal-after-500, lone 502, never-answered push, stalled body read, stalled refusal body still reporting its status, mid-read body drop on 200 vs 403, unreadable 500 body, a never-retried 403, cache invalidation after an unconfirmed provision (both kinds), a dropped provision stream keeping exports uncached until its exact written content appears in an export, an overlapping provision's success not clearing an earlier write, a discarded 500 response's diagnostic read consuming its own stream, an identical retry's success not retiring an earlier uncertain write, a still-in-flight push keeping exports uncached until it settles, post-deploy-start runner failure → unconfirmed, and compile-gate failure → refusal. Each was falsified against the pre-fix behaviour. The three failure-path specs now also assert the terminal modal is visible (the hidden-#step3-outputassertions could not catch the missing presentation).Link to Devin session: https://app.devin.ai/sessions/10cc3d6133494b26addecbcb8973cc92
Open in Devin Desktop: https://app.devin.ai/desktop/session/10cc3d6133494b26addecbcb8973cc92?variant=devin
Requested by: @sgardoll