Skip to content

STU-380: Apply the new deployment UI with truthful pending, partial and terminal outcomes - #91

Merged
sgardoll merged 2 commits into
milestone/stu-implement-updated-ui-uxfrom
sgardoll/stu-380-apply-the-new-deployment-ui-with-truthful-pending-partial
Sep 22, 2026
Merged

sgardoll merged 2 commits into
milestone/stu-implement-updated-ui-uxfrom
sgardoll/stu-380-apply-the-new-deployment-ui-with-truthful-pending-partial

Conversation

@sgardoll

Copy link
Copy Markdown
Owner

STU-380 — Apply the new deployment UI with truthful pending, partial and terminal outcomes

Implements truthful deployment terminal outcomes without rewriting the deployment transport (STU-311/STU-312 remain the hardening links). Only a confirmed success reports "committed".

What changed

  • New outcome classifier src/deployOutcome.js (classifyDeployResult, DeployOutcome, DEPLOY_UI_TIMEOUT_MS) — single source of truth: COMMITTED / FAILED / PARTIAL / UNCONFIRMED.
  • Bounded UI waiting (withUiTimeout) around the provisioning stream in app.js. The UI bound is DEPLOY_UI_TIMEOUT_MS = 120s, derived as comfortably longer than observed provision latency yet well below Cloud Run's 900s server request deadline, and it is a rendering bound only — on expiry the UI reports UNCONFIRMED and never aborts or retries the in-flight remote write.
  • Disconnect/timout → UNCONFIRMED: readProvisionResponse now returns finalResultReceived so a stream that ends with no definitive result is treated as an unknown remote outcome, never a fabricated failure. Constant-timeout raises UnconfirmedDeployError.
  • Partial: if the runner upserts custom classes (provisionSucceeded) but the subsequent sync/push is rejected, the result is PARTIAL (classes written, rest failed) with per-file outcomes, instead of the previous false "Commit Failed" that concealed written classes.
  • 403 / file rejection / compile gate remain definitive failures (never "committed"), per-file errors rendered.
  • New shared terminal modal for partial + unconfirmed: consistent Project/Endpoint/File/Artifact identity from confirmation, per-file outcome list, reconciliation guidance that never hides the manual FlutterFlow step ("open the project before retrying" for unconfirmed; "classes not lost" for partial), and Open-in-FlutterFlow / Close actions.
  • Duplicate submit prevented and UI busy state released in every terminal state (deployInFlight guard; confirm + deploy triggers disabled during the run).

Acceptance-criteria checklist

  • Browser fixtures cover verified success, 403, file rejection, compile gate, partial, disconnect, timeout; only confirmed success says committed — e2e/stu380-deploy.spec.js (9 tests).
  • Project/endpoint/file identity stays consistent from confirmation to terminal state; duplicate submit prevented — terminal/identity assertions + deployInFlight guard.
  • Every terminal state releases UI busy state and provides safe next actions without concealing manual FlutterFlow tasks — busy-release + guidance assertions.
  • NOT verified here: designated-test-project integration result (STU-392) — mocks do not prove remote application. State plainly NOT done.

Test commands + results

  • npm test → 295 pass / 0 fail.
  • npm run build → OK.
  • npm run test:accessibility → "Accessible modal browser checks passed (7 shells, 360x500 viewport)" (new terminal modal included).
  • CCC_TEST_PORT=3091 npx playwright test e2e/stu380-deploy.spec.js → 9 passed.
  • CCC_TEST_PORT=3091 npx playwright test e2e/stu380-deploy.spec.js e2e/smoke.spec.js e2e/stu381-monthly-usage.spec.js → all passed (no regressions).

Falsification evidence (each break → FAIL → restore → PASS)

  • Classifier (Node): patched classifyDeployResult to always return COMMITTED → 3 of 5 deployOutcome.test.js tests failed ("committed only on confirmed success", "partial", "unconfirmed"); restore → 5 pass.
  • Browser forcing non-success to committed: patched classifyDeployResult to always return COMMITTED → 7 of 9 e2e tests failed (403/partial/disconnect/timeout wrongly claimed committed); restore → 9 pass.

Screenshots

  • screenshots/stu380/partial-mobile.png, partial-desktop.png (360x800 and 1440x900)
  • screenshots/stu380/unconfirmed-mobile.png, unconfirmed-desktop.png
  • screenshots/stu380/committed-mobile.png, committed-desktop.png

What is verified vs NOT

Verified: classifier, terminal rendering, identity consistency, busy release, duplicate-submit guard, bounded-wait wiring at the source level, accessibility of the new modal.
NOT verified: remote application on a designated test project (owner → STU-392 integration acceptance); real end-to-end push over the network.

Linear: https://linear.app/stuart-gardoll/issue/STU-380/apply-the-new-deployment-ui-with-truthful-pending-partial

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.
…lsify 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.
@sgardoll
sgardoll marked this pull request as ready for review September 22, 2026 16:08
@sgardoll
sgardoll merged commit be54664 into milestone/stu-implement-updated-ui-ux Sep 22, 2026
3 checks passed

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 5 potential issues.

Devin Review

Comment thread app.js
Comment on lines +3221 to +3230
if (provisionClassesWritten) {
return {
success: false,
partial: true,
error: error.message,
errorMap,
targetIdentity,
state: commitState.currentState,
elapsedTime: commitState.getElapsedTime(),
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Unknown push outcome appears partial

When pushCode loses its response after provisioning, provisionClassesWritten forces partial. The sync may have committed, so the UI falsely reports it failed.

Learn more

A partial outcome requires proof that custom classes landed and the remaining sync failed. After provisioning succeeds, this catch also receives transport errors from pushCodeWithRetry, including a lost connection after FlutterFlow accepted the request. No response then proves whether the final sync failed or committed. The same unconditional partial branch exists in executeBundleCommit.

Example: The runner creates GaugeModel, then FlutterFlow applies the final widget and pubspec push but the response connection drops. pushCodeWithRetry eventually throws, and the UI says the deploy was partially applied even though every write landed.

Recommended fix: Preserve the failure category through pushCode and response parsing. Return partial only for a definitive rejection or a local failure before the push starts; return unconfirmed when the final push request may have reached FlutterFlow but no definitive response arrived. Apply the same distinction in both commit flows.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #99: pushCodeWithRetry tracks sawTransportError/lastHttpStatus and throws exhaustedPushError() — UnconfirmedDeployError when any attempt lost its response, a plain failure only when every endpoint returned an HTTP refusal. parsePushCodeResponse throws UnconfirmedDeployError for an ok-but-unreadable body, and both commit flows render unconfirmed rather than partial/failed when the push outcome is unknown.

Comment thread app.js
Comment on lines +2232 to +2237
const result = await withUiTimeout(
readProvisionResponse(response, {
onPhase: (message) => commitProgress.setSubstatus(message),
onLog: (message) => console.log(`[custom class deploy] ${message}`),
}),
DEPLOY_UI_TIMEOUT_MS,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Provisioning request can wait forever

When provisioning stalls before response headers, withUiTimeout never starts because it wraps only readProvisionResponse. The deploy stays locked beyond the 120-second bound.

Learn more

The provisioning operation has two waits: fetch waits for response headers, then readProvisionResponse consumes the body. The new timeout covers only the second wait because fetch completes before withUiTimeout is called. A runner that does not flush headers promptly can therefore keep the progress modal and deployInFlight lock active indefinitely.

Example: The provisioning endpoint accepts GaugeModel but emits no headers for five minutes while starting its workspace. The promised 120-second terminal outcome never appears, and all deploy controls remain disabled.

Recommended fix: Start the bounded wait around one promise that performs both fetch and readProvisionResponse. Preserve the response classification once headers arrive, and treat expiry without a definitive server result as unconfirmed.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #99: the fetch and readProvisionResponse now run inside a single bounded promise, so a runner that accepts the connection but never flushes headers resolves at the UI bound instead of hanging past it. E2e coverage: a never-answering route settles as UnconfirmedDeployError inside a 60ms bound.

Comment thread app.js
Comment on lines +3118 to +3119
targetIdentity.projectId = projectId;
provisionClassesWritten = provisioning.provisionSucceeded === true;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Unconfirmed deploy loses project identity

When provisioning becomes unconfirmed, targetIdentity.projectId stays null because its assignment follows the awaited call. The modal loses the chosen project and its direct link.

Learn more

The project ID is known and validated before provisioning begins, but it is copied into the terminal identity only after provisionMissingCodeFiles resolves. Both timeout and dropped-stream outcomes throw from that await, so the catch returns an identity whose projectId is still null. The bundle flow repeats the same ordering in executeBundleCommit.

Example: The user confirms project ff-proj-0007, then the provisioning stream drops. The unconfirmed modal displays Project: —, and “Open in FlutterFlow” points to the homepage instead of that project.

Recommended fix: Assign targetIdentity.projectId = projectId immediately after project validation, before any remote or fallible work. Make the same change in both single-artifact and bundle commit flows.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #99: targetIdentity.projectId = projectId now runs immediately after validateFlutterFlowProjectId in both executeCommit and executeBundleCommit, so an unconfirmed provisioning outcome still names the project it was deploying to.

Comment thread app.js
Comment on lines +2232 to +2246
const result = await withUiTimeout(
readProvisionResponse(response, {
onPhase: (message) => commitProgress.setSubstatus(message),
onLog: (message) => console.log(`[custom class deploy] ${message}`),
}),
DEPLOY_UI_TIMEOUT_MS,
(resolve, reject) =>
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.",
),
),
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Timed-out stream keeps updating progress

After timeout, readProvisionResponse keeps invoking commitProgress handlers behind the terminal modal. A later deploy resets this state, but cancellation or detached handlers would isolate completed UI runs.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in #99: a progressLive flag detaches the onPhase/onLog handlers the moment the UI bound expires, so a late-arriving stream can no longer mutate a terminal state behind the modal. The underlying request is still intentionally not aborted — aborting the client can't prove the server stopped the write; see the withUiTimeout docstring.

Comment thread src/provisionStream.js
Comment on lines +107 to +114
export function deployOutcomeOfStreamResult(result) {
if (result?.finalResultReceived) {
return classifyDeployResult(result);
}
if (result?.httpRejected) {
return DeployOutcome.FAILED;
}
return DeployOutcome.UNCONFIRMED;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 HTTP rejection contract needs confirmation

httpRejected treats every non-2xx response as proof no write occurred. Confirm the runner cannot emit a gateway 5xx after applying changes.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed against cloud-run/ffai-runner/bin/server.dart _ResponseChannel: once the client sends stream: true (always), beginStream() writes the 200 status and every post-work failure (init 502, compile 422, timeout 504, crash 500) is delivered as an NDJSON result event under that status — a non-2xx can only arrive before streaming starts, i.e. before the first remote write. So httpRejected → FAILED holds; the contract is now documented in a comment on the httpRejected return in provisionStream.js (#99).

sgardoll added a commit that referenced this pull request Sep 27, 2026
…ixes

* 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 classified correctly as FAILED but produced zero visible UI.

Co-Authored-By: Stuart Gardoll <sgardoll@gmail.com>

* fix(STU-380): treat push 5xx as uncertain; refusal after an uncertain 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>

* fix(STU-380): bound the sync push so a stalled FlutterFlow cannot hang 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>

* fix(STU-380): keep the HTTP status on a provisioning refusal whose body 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>

* fix(STU-380): stop retries after UI timeout; label failure terminal correctly

- 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>

* fix(deploy): runner failures after deploy begins are unconfirmed, not 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>

* fix(deploy): keep provisional cache + honest provenance on post-start 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>

* fix(deploy): cancel stalled diagnostic body reads instead of leaking 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>

* fix(deploy): scope preWrite to parse errors, drain discarded bodies, 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>

* fix(deploy): track possibly-written files as dirty until an export proves 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>

* fix(deploy): key dirty writes by content so overlapping provisions stay 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>

* fix(deploy): account writes per attempt; hold exports while a push is 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>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Stuart Gardoll <sgardoll@gmail.com>
sgardoll added a commit that referenced this pull request Sep 29, 2026
…-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…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant