Skip to content

fix(migrated): name Flow's one-time upload-terms dialog instead of blaming the file (#719) - #757

Merged
ffroliva merged 5 commits into
developfrom
fix/719-upload-consent-dialog
Sep 8, 2026
Merged

ffroliva merged 5 commits into
developfrom
fix/719-upload-consent-dialog

Conversation

@ffroliva

@ffroliva ffroliva commented Sep 8, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes #719 shape A. Builds on the instruments and findings in #754.

Flow holds an account's first upload behind a one-time "Rights to use this image" confirmation. It renders after the file chooser hands the file over — so _dismiss_dialog, which runs back in ensure_editor, never sees it — and Flow sends nothing until a human accepts it. The driver spent its full 60 s budget waiting for a request the page had already declined to make, then reported:

MediaUploadRejectedError (exit 27): migrated host: no maseQ reply within 60s of choosing
the file — the upload never reached Flow or was dropped

…and advised re-encoding the image to strip metadata. Neither the file nor the network was ever involved. This blocked both video i2v --initial-frame and video r2v --ref, which is every form that binds a local file on this host.

The guard

dialogs_before = await page.locator(DIALOG).count()
await chooser.set_files(str(image_path))
try:
    status, text = await asyncio.wait_for(reply, timeout=FRAME_UPLOAD_S)
except TimeoutError:
    try:
        opened = await page.locator(DIALOG).count() > dialogs_before
    except Exception:            # a probe is never the failure it reports
        opened = False
    if opened and not sent:      # `sent` comes from a request listener
        raise MediaUploadRejectedError(detail=..., remediation_hint=...)
    raise MediaUploadRejectedError(...)   # now says whether the request left the page

It sits in _upload_via_toolbar, where i2v --initial-frame and r2v --ref already converge, so one check covers both surfaces.

It counts dialogs rather than matching one, and that is the load-bearing decision. The dialog's two buttons are button.flow-button-medium with no ligature and no data attribute, separable only by DOM order, and its copy is translated — there is no anchor there that satisfies this project's locale-invariance rule. "A dialog appeared between the file being chosen and the wait expiring" is upload-related by construction: it needs no selector, and it cannot rot when Angular renames a class.

The _default_remediation ("re-encode the image to strip metadata") is not changed — it is still correct for the original #287 case, a 4xx on one file's metadata. It is overridden on this branch only.

gflow does not accept the dialog, and there is no flag to make it

The dialog affirms that you hold the rights to the content being uploaded. That is not a claim a script may make on an account owner's behalf. It is also one-off per account, on a path that already requires an interactive gflow auth login — so the manual step costs 30 seconds once in an account's life, and automating it would assert something on the user's behalf to save nothing.

No config flag either: a setting that can never safely default to on, for an event that happens once, is a flag nobody sets.

Verification

Measured live on ci-probe (#754 carries the instrument). The order was forced — the dialog is one-off, so the guard had to be written and verified against the live dialog before anything was accepted, or the failing state would be gone forever.

# Run Result
1–3 upload, pre-fix no maseQ reply within 60s — the misleading message
4 upload, with this guard a dialog opened after the file was chosen and no maseQ request was ever sent — Flow is holding the upload behind its one-time upload-terms confirmation (the pre-review wording; it is now hedged to "most likely" and backed by an observed request listener — see below)
5 accept (owner-authorised) → retry, same session media_id 8914400f-…
6 plain upload, fresh session uploaded, dialog: None, 4 POSTs

The dialog blocks the send (4), accepting unlocks it (5), it never returns (6).

Offline: 90 tests in test_migrated_composer.py, full tests/api/transports suite, and the nine local gates.

/code-review high ran before this PR, and found three things

Per the process change from #752 — the gate goes before the merge, not after. All three are fixed in this branch:

  1. The dialog probe could become the failure it describes. The count() runs inside except TimeoutError, and the likeliest reason no reply arrived is that the page died or navigated — in which case the count raises too, escapes in place of MediaUploadRejectedError, and the run exits with an unmapped code 1 and a traceback instead of the mapped 27 with its remediation. Before this change the timeout path had no awaits and could not fail that way. It now degrades to the plain timeout. Same lesson as Follow-up on #751: code review found 13 issues in the agent-mode fix, 2 of them real defects in the tests #752, one surface over.

  2. The message asserted two things the code never observed. "no maseQ request was ever sent" was inferred from a response listener, so a request that did leave and was answered slower than FRAME_UPLOAD_S produced it falsely — and that constant's own comment concedes a 20 MB file on a slow link needs longer. Fixed by watching the request too, which is worth more than the wording: sent now splits migrated host: an upload request leaves the page and Flow never answers (intermittent, ~1 in 4 on a consented account) #719 shape A (nothing left the page) from shape B (it left, Flow didn't answer) at the point of failure, and those argue for opposite fixes — one for a retry, one against. That is exactly what the spike doc recommended, and it costs one boolean.

    "Flow is holding the upload behind its one-time upload-terms confirmation" was asserted as fact from a bare dialog count, so any modal in that window inherited it — an error modal, a quota notice, a re-login prompt. It now says "most likely", and the remediation tells a user who sees a different dialog that that is the one blocking them. A count is evidence for a guess, not a fact — which is the whole thing migrated host: an upload request leaves the page and Flow never answers (intermittent, ~1 in 4 on a consented account) #719 is about.

  3. A #: block documented an UPLOAD_CONSENT_DIALOG constant that was never defined, so it dangled between DIALOG_CLOSE and SUBMIT_RPCS and the guard's comment pointed at a name not in the file. I caught this one myself reading the diff, before the review returned it. Nothing to rename — the guard counts precisely so it needs no selector — so the rationale moved to the guard.

Four unit cases, and the last two came out of the review:

  • the terms branch produces the new message and drops the wrong "re-encode" advice;
  • a control proving the guard does not fire on a dialog that was already open — otherwise a lingering changelog modal (Handle Google Flow changelog overlays before UI interactions #26) would rewrite every unrelated upload timeout into a terms message and send the user to accept something never asked of them. The plain-timeout test also now asserts the terms wording does not leak onto it;
  • the shape-B wording on a request that left the page and went unanswered;
  • exit 27 surviving a probe that dies mid-upload — the regression the review found.

The fake now models the upload request leaving the page, and a page that dies once the file is handed over, so both states are constructible rather than argued.

On the Iron Law — why there is no new e2e for the positive branch

The guard's firing branch was verified live (run 4 above, against the real dialog), and that is the strongest evidence available for it. It is also unrepeatable: the dialog is one-off per account, and all three accounts on this machine have now accepted it. Reproducing the un-consented state needs a Google account that has never uploaded on flow.google.com — a named external blocker, not a skipped step.

The non-firing branch is where the regression risk actually lives (a guard that fires wrongly would mislabel every unrelated upload timeout), and that is covered offline by the control test plus the existing e2e_video i2v/r2v tests, which exercise the healthy upload path end to end.

If a fresh account ever appears, scripts/dev/spike_migrated_upload_wire.py (#754) reproduces the whole six-run sequence, including --accept-terms.

KNOWN_ISSUES.md corrected

The entry for this dialog said:

Affects: the legacy in-tree Compiled Growth worker, NOT gflow-cli itself … gflow-cli's API-driven path bypasses this dialog entirely (the REST endpoint already implies acceptance). Workaround in gflow-cli: none needed.

True of the REST path, and false since the migrated driver began using the editor's own upload — so the entry a user hitting #719 would find was reassuring them about the exact thing breaking their run. Rewritten with the measurement, the one-off workaround, and why gflow does not click it.

Scope

Not fixed here: #719 shape B — 1/4 on a funded account, the maseQ POST goes out with 8 675 B, no reply arrives, and 2.6 s later the whole project-load rpcid inventory fires. Unrelated to consent, still open, and #719 stays open for it.

MCP parity: no CLI leaf, option, queued-payload key or tool docstring claim changes. The guard is in the shared transport, so the MCP twin gets the improved error for free — same code path, no adapter change.

…aming the file

#719 shape A. Flow holds an account's FIRST upload behind a one-time 'Rights to
use this image' confirmation. It renders AFTER the chooser hands the file over —
so _dismiss_dialog, which runs back in ensure_editor, never sees it — and Flow
sends nothing until a human accepts it. The driver spent its full 60 s budget
waiting for a request the page had already declined to make, then reported 'the
upload never reached Flow or was dropped' and advised re-encoding the image.
Neither the file nor the network was ever involved.

Detected by COUNTING dialogs across the upload, not by matching one. The dialog's
two buttons are button.flow-button-medium with no ligature and no data attribute,
separable only by DOM order, and its copy is translated — there is no anchor there
that satisfies this module's locale rule. 'A dialog appeared between the file
being chosen and the wait expiring' is upload-related by construction, needs no
selector, and cannot rot when Angular renames a class.

The guard is in _upload_via_toolbar, where both i2v --initial-frame and r2v --ref
already converge, so one check covers both surfaces.

gflow does NOT click it, and there is no flag to make it: the dialog affirms that
the ACCOUNT OWNER holds rights to what is uploaded, it is one-off per account, and
the path already requires an interactive gflow auth login. A setting that could
never safely default to on, for an event that happens once in an account's life,
is a flag nobody sets.

Tests: the terms branch, and a control proving a dialog that was ALREADY open does
not get blamed — otherwise a lingering changelog modal (#26) would rewrite every
unrelated upload timeout into a terms message. The plain-timeout test now also
asserts the terms wording does not leak onto it.

Refs #719
…not exist

Found reading my own diff. The '#:' block was written as a Sphinx-style doc for
an UPLOAD_CONSENT_DIALOG constant, and no such constant was ever defined — so it
dangled between DIALOG_CLOSE and SUBMIT_RPCS, reading as documentation for
whichever came next, and the guard's own comment pointed readers at a name that
was not in the file.

Nothing to rename: the guard COUNTS dialogs precisely so it needs no selector, so
a constant would have been a name with nothing behind it. The rationale moves to
the guard, which is where a reader meets the behaviour.

Refs #719
…t the probe be the failure

/code-review high on this branch before the PR. Two findings, both real, and the
first is the #752 lesson repeating itself one surface over.

1. The dialog count runs INSIDE `except TimeoutError`, and it can raise. The
   likeliest reason no maseQ reply came is that the page died or navigated — in
   which case `count()` raises too, that escapes in place of
   MediaUploadRejectedError, and the run exits with an unmapped code 1 and a
   traceback instead of the mapped 27 with its remediation. Before this change
   the timeout path had no awaits and could not fail this way. The probe now
   degrades to the plain timeout: a probe is never the failure it reports.

2. The message asserted two things the code never observed.

   "no maseQ request was ever sent" was inferred from a RESPONSE listener, so a
   request that did leave and was answered slower than FRAME_UPLOAD_S produced it
   falsely — and that constant's own comment concedes a 20 MB file on a slow link
   needs longer. Fixed by watching the request too, which is worth more than the
   wording: `sent` now splits #719 shape A (nothing left the page) from shape B
   (it left and Flow did not answer) at the point of failure, and those two argue
   for opposite fixes — one for a retry, one against. The spike doc recommended
   exactly this; it costs one boolean.

   "Flow is holding the upload behind its one-time upload-terms confirmation" was
   asserted as fact from a bare dialog count, so any modal in that window
   inherited it — an error modal, a quota notice, a re-login prompt. It now says
   "most likely", and the remediation tells a user who sees a different dialog
   that THAT is the one blocking them. A count is evidence for a guess, not a
   fact, which is the whole thing this issue is about.

Two new tests: the shape-B wording on a request that left and went unanswered,
and exit 27 surviving a probe that dies mid-upload. The fake now models the
upload request leaving the page and a page that dies once the file is handed
over, so both states are constructible rather than argued.

90 unit tests in test_migrated_composer.py.

Refs #719
KNOWN_ISSUES.md read: 'Affects: the legacy in-tree Compiled Growth worker, NOT
gflow-cli itself', because 'gflow-cli's API-driven path bypasses this dialog
entirely (the REST endpoint already implies acceptance)', with 'Workaround in
gflow-cli: none needed.'

True of the REST path, and false since the migrated driver started using the
editor's OWN upload. So the entry a user hitting #719 would search for and find
was reassuring them about the exact thing breaking their run — the failure mode
this repo keeps correcting, in the document that exists to prevent it.

Rewritten with the measurement (8 runs, 3 profiles), the one-off workaround, why
gflow does not click it, and why there is no config flag. The old wording is
quoted inside the new entry rather than deleted, because 'this used to say the
opposite' is the part a returning reader needs.

CHANGELOG entry covers the guard, the count-not-match decision and its locale
rationale, and the product decision not to auto-accept.

Refs #719
LIVE_VERIFICATION_v0.71.0 listed ci-probe as 'labs, freemium, no credit'.
v0.70.0, one day earlier, listed it as migrated. Measured today with the account
signed in: labs.google/fx/tools/flow on that profile redirects to
flow.google.com/ — the one-way migration redirect. v0.70.0 was right.

Worth more than a typo fix. This repo's own rule is that 'Flow's UI shows X' is
not a fact until the host is named, so a mislabelled host in a verification
ledger silently re-scopes every conclusion keyed to it. This one had a cost: a
profile described as 'labs, freemium, no credit' made a credit-based theory for
 #719 look plausible for four runs, when the real cause was a one-time consent
dialog that the account simply had never accepted.

Could not be settled earlier today — the profile was signed out, and a signed-out
browser redirects to the account chooser from either host.

Refs #719
@ffroliva
ffroliva merged commit 256ed2b into develop Sep 8, 2026
15 checks passed
@ffroliva
ffroliva deleted the fix/719-upload-consent-dialog branch September 8, 2026 17:24
ffroliva added a commit to arjhinety/gflow-cli that referenced this pull request Sep 8, 2026
Two migrated-host error paths stop blaming the wrong thing (ffroliva#749/ffroliva#752, ffroliva#719
shape A). Both were found the same way — by asking what the app was SAYING,
which this driver had never done; it only ever watched the wire.

Release prep beyond the bump:

- LIVE_VERIFICATION_v0.71.1 with the agent-mode A/B (run twice — review fixes
  landed after the first run, so it no longer covered the code) and the consent
  guard's six-run chain: guard fires while the dialog is up -> owner-authorised
  accept -> uploads -> fresh session uploads clean. The order was forced, because
  the dialog is one-off and accepting it destroys the failing state; the guard was
  therefore written and verified BEFORE anything was clicked, and its firing branch
  is now unrepeatable here. Every "not verified" item names a blocker and an issue.

- PROJECT_STATUS: v0.71.1 current; v0.71.0 AND v0.70.0 demoted into <details>
  (the previous release left v0.70.0 inline). Three milestone rows added — the
  table's newest was v0.69.0, so two releases had never been recorded.

- Six "unreleased line" claims dated from the CHANGELOG rather than guessed:
  ffroliva#716 -> v0.71.0, ffroliva#673 -> v0.69.0, ffroliva#493 -> v0.60.0, expand_prompt -> v0.22.0,
  and this release's own two -> v0.71.1.

Four reviewers ran before the tag (/code-review, /ponytail-review, and the
doc-review council's three auditors). Seven findings applied, all of them mine:

- THE FALSE CLAIM, in two places. PROJECT_STATUS and INDEX both said the
  agent-mode recovery "already recovered as of v0.71.0's line" / "shipped
  working". It never shipped before 0.71.1 — `git show v0.71.0:...composer.py |
  grep agent` returns nothing — and KNOWN_ISSUES says the opposite three lines
  over. A v0.71.0 user reading the section an upgrade decision is made from would
  conclude their version self-recovers and file the drift bug this very release
  exists to prevent. Three reviewers found it; only one found the INDEX copy,
  which is the higher-traffic of the two.

- Shape B shipped a NEW user-visible message with nowhere to look it up, and no
  remediation_hint. A user hitting it grepped KNOWN_ISSUES, landed on the consent
  entry, and was told the one-time accept fixes it forever — the same failure mode
  that entry's own retraction block calls out, reintroduced one shape over. Now
  has its own entry, a hint, and a test pinning both.

- The ledger quoted a string the shipped build cannot emit (run 4 predates the
  review pass). Now quotes the shipped wording, with the history kept as a note.

- r2v was missing from the migrated-host capability sentence in README, llms.txt,
  AGENTS.md and ARCHITECTURE while INDEX and KNOWN_ISSUES had it right — stale
  since v0.70.0, and conspicuous because this release's headline fix is scoped to
  r2v --ref.

- A <details> opened for v0.68.0 at :196 and never closed, nesting every older
  release inside it. Tag counts balanced 51/51, which is exactly why the balance
  check I ran first missed it; verified now by DEPTH, which never exceeds 1.

- The queue spike had zero inbound links repo-wide. check_doc_links cannot see
  that — it verifies outbound resolution, not reachability.

Gates: all nine green. Full offline suite 4086 passed / 24 skipped with one
local-environment false red (ffroliva#758 — the test clears the env var but Settings also
reads .env); CI ran the identical code green on 3.11/3.12/3.13 in PR ffroliva#757.
Transports suite re-run at 961 passed AGAINST THE WORKTREE'S OWN src — the first
run silently imported the main checkout through the editable install, which is
filed as ffroliva#760 because it means a release branch can validate code it is not
shipping.

Deferred and filed, not dropped: ffroliva#759 (ponytail — 81 of 233 added lines are
comments restating the spike, and the queue spike ships an unrun download path).

Not in this release: PR ffroliva#750 (migrated image generation) — branched pre-ffroliva#751 and
conflicting across six files. ffroliva#719 stays OPEN for its second failure shape.
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.

migrated host: an upload request leaves the page and Flow never answers (intermittent, ~1 in 4 on a consented account)

1 participant