Skip to content

fix: drive character create on the migrated Flow host - #703

Merged
ffroliva merged 1 commit into
developfrom
fix/character-create-on-migrated-host
Sep 6, 2026
Merged

ffroliva merged 1 commit into
developfrom
fix/character-create-on-migrated-host

Conversation

@ffroliva

@ffroliva ffroliva commented Sep 6, 2026

Copy link
Copy Markdown
Owner

Summary

gflow character create was believed impossible on flow.google.com. It was not. The editor is fully present there, backed by the same labs tRPC and aisandbox APIs — the migrated page calls flow.projectInitialData and v1:checkAppAvailability itself. Only the view layer differs: labs renders React + Slate, flow.google.com renders Angular + ProseMirror. Our selectors missed, the 20 s readiness gate timed out, and the absence of a match was recorded as the absence of the feature.

v0.69.0 then hardened that misreading: a raise_if_migrated guard aborted with exit 36 before probing the DOM, so the premise could never be disproved by running the code. That guard was the defect. It is gone.

What changed

Seven anchors, all the same class of mistake:

was now
readiness gate Slate only also div.ProseMirror[contenteditable]
migrated-host guard aborted before probing removed from _enter_character_editor
result listener labs batchGenerateImages (never fires there) read the ids off the entity
model chip i.google-symbols also mat-icon
body mode labs sibling-of-image <flow-slot-chip-button> component boundary
body settle signal media.getMediaUrlRedirect also flow-content.google/image/
prompt-box counter Slate only (reported 0 on a mounted editor) the readiness anchor

Reading the result off the entity also proves the character binding by construction, which is stronger than trusting the labs path's self-reported parentEntityId.

--model is now deterministic

The picker was best-effort: every failure logged a warning and let the generation run on whatever tier the editor showed, so --model nano2 could silently return a Nano Banana Pro image. Three faults did exactly that — it skipped the click entirely for nano2 (assuming it was the editor default, true on labs, false on migrated); its option selector :has-text(...) was unanchored so .first resolved to <html>; and its menu lookup was unscoped, so [role='menuitem'] mixed the open menu with hidden entries from the editor's other menus and nth(i) clicked an unclickable node.

It now reads the visible menu, matches in Python with an explicit exclusion (Nano Banana 2 is a prefix of Nano Banana 2 Lite; the menu offers three tiers, not the documented two), refuses an ambiguous or absent match, and verifies by re-reading the chip rather than trusting the click. Anything else raises ConfigurationError or UiSelectorDriftError. Aborting is free — the picker runs before submit, so a refusal costs no quota, while proceeding produces a paid artifact from the wrong model.

Two data-integrity fixes

  • Each slot takes its own primaryMediaId, read from the project listing. The entity exposes only a thumbnail id; reusing it across slots made the body slot claim the face's media, which commit_workflow then PATCHed onto the body workflow, corrupting it.
  • A failed create no longer strands an "Untitled Character." The saga keeps its STARTED row for resume, but that row is keyed on (project_id, name), so a retry under any other name missed it and minted a second entity. A zero-progress failure now rolls the free entity back and marks the row FAILED — after asking the backend, because an empty local workflow list means only that this process failed to read a result.

HAR/DOM spike harness vendored in

scripts/dev/har-spike/ (88 KB) with its agent-browser version pinned, artifacts and caches gitignored, its test now running in tests/scripts/, and AGENTS.md documenting the two spike modes. It lived outside git, so no agent could discover it and nothing pinned it — which is why this session wrote three capture scripts from scratch before learning it existed. Repo hygiene immediately caught a hardcoded user path in it.

Test plan

Live on a moved account (ci-probe), verified by independent read-back:

  • Complete character — name, personality (UTF-8 round-tripped), voice, portrait and body; two distinct workflow ids, two distinct media ids, both images on disk with different checksums
  • Model determinism — four consecutive runs alternating --model nano2 / --model nanopro, correct tier every time
  • Orphan rollback A/B control — 1 → 2 entities without the fix, 2 → 2 with it
  • media.getMediaUrlRedirect probed against a migrated portrait id: HTTP 200, 696 767 bytes, JFIF (the 404 recorded in migrated_composer.py was measured on a video id and does not generalise)
  • e2e rewritten as a two-sided invariant that never skips: a create leaves a correctly-named character or nothing
  • ruff clean, pyright 0 errors, all six doc/hygiene gates, 4051 passed / 92% coverage

Not verified: the labs.google path. All three accounts here are migrated, so the labs branches of these selectors are covered by unit tests only. Named as a blocker rather than claimed.

Recon: scripts/dev/spike_migrated_character_*.py, spike_migrated_vs_labs_provenance.py.

`gflow character create` was believed impossible on flow.google.com. It was
not: the editor is fully present there, backed by the SAME labs tRPC and
aisandbox APIs (the migrated page calls flow.projectInitialData and
v1:checkAppAvailability itself). Only the view layer differs — labs renders
React + Slate, flow.google.com renders Angular + ProseMirror — so our
selectors missed, the 20 s readiness gate timed out, and the absence of a
match was recorded as the absence of the feature.

v0.69.0 then hardened that misreading: a `raise_if_migrated` guard aborted
with exit 36 BEFORE probing the DOM, so the premise could never be
disproved by running the code. That guard was the defect, and it is gone.

Seven anchors, all the same class of mistake:

  * readiness gate      Slate -> also ProseMirror
  * migrated-host guard removed from _enter_character_editor
  * result listener     labs batchGenerateImages never fires; read the ids
                        off the ENTITY, which proves the character binding
                        by construction rather than trusting a
                        self-reported parentEntityId
  * model chip          i.google-symbols -> also mat-icon
  * body mode           anchored on <flow-slot-chip-button>, the component
                        boundary (the Characters nav shares the ligature,
                        and there is no add_2 control on that host)
  * body settle signal  the face reference mounts from
                        flow-content.google/image/, a different origin
  * prompt-box counter  counted Slate only, reporting 0 boxes on a visibly
                        mounted editor

--model is now deterministic. The picker was best-effort: every failure
logged a warning and let the generation run on whatever tier the editor
showed, so `--model nano2` could return a Nano Banana Pro image silently.
It now reads the visible menu, matches in Python with an explicit
exclusion (`Nano Banana 2` is a prefix of `Nano Banana 2 Lite`), refuses an
ambiguous or absent match, and VERIFIES by re-reading the chip instead of
trusting the click. Anything else raises. Aborting is free — the picker
runs before submit, so a refusal costs no quota, while proceeding produces
a paid artifact from the wrong model.

Each slot now takes its own primaryMediaId, read from the project listing.
The entity exposes only a thumbnail id, and reusing it across slots made
the body slot claim the face's media — which commit_workflow then PATCHed
onto the body workflow, corrupting it.

A failed create no longer strands an "Untitled Character". The saga keeps
its STARTED row for resume, but that row is keyed on (project_id, name),
so a retry under any other name missed it and minted a second entity. A
zero-progress failure now rolls the free entity back and marks the row
FAILED — after asking the backend, because an empty local workflow list
means only that this process failed to read a result.

The agent-browser CDP/HAR spike harness is vendored into
scripts/dev/har-spike/ and documented in AGENTS.md as one of two spike
modes. It lived outside git, so no agent could discover it, nothing pinned
its agent-browser version, and its one test ran nowhere — which is why
this session wrote three capture scripts from scratch before learning it
existed. Its artifacts and caches are gitignored; its test now runs, and
repo hygiene immediately caught a hardcoded user path in it.

Verified live on a moved account (ci-probe): a complete character — name,
personality, voice, portrait and body — with two distinct workflow ids,
two distinct media ids and both images on disk, confirmed by read-back.
Model selection verified over four consecutive runs alternating tiers.
The orphan rollback verified by A/B control: 1 -> 2 entities without the
fix, 2 -> 2 with it.
@ffroliva
ffroliva merged commit 219fe20 into develop Sep 6, 2026
25 of 26 checks passed
@ffroliva
ffroliva deleted the fix/character-create-on-migrated-host branch September 6, 2026 20:27
ffroliva added a commit that referenced this pull request Sep 6, 2026
The ledger recorded the bug as the fix. Its exit-code table presented
`character create` on a moved account exiting 36 as a v0.70.0 improvement,
and its not-verified list said CHARACTER entities "cannot run on any
account available here". Both were false, from one root: a 20 s readiness
timeout was read as proof the feature does not exist on flow.google.com,
and #701 then added a guard that aborted before probing the DOM — which
made the claim unfalsifiable, because no run could look.

#703 disproved it. The editor is fully present on the migrated host, on
the same labs tRPC + aisandbox backend; only the view layer differs (React
+ Slate vs Angular + ProseMirror). Replaces both statements with what was
actually run: a complete character on ci-probe — name, personality, voice,
portrait and body, two distinct workflow ids, two distinct media ids, both
images on disk with different checksums, confirmed by read-back. Plus
`--model` determinism over four alternating runs and the orphan-rollback
A/B (1 -> 2 entities without the fix, 2 -> 2 with).

Records what was NOT verified rather than omitting it: the labs.google
path for all seven changed anchors, since every account here is migrated.

Also updates the #692 entry with evidence that changes what that issue is.
flow.google.com CAN mint a reCAPTCHA Enterprise token: on /project/<id>
the enterprise script and site key are present and a mint returns a
2404-char token, while the root grid carries no script at all. The mint
runs on the pooled bootstrap page, which after the handoff IS the root
grid — which is exactly why the reporter's bundle showed route: "/". So
"any mint failure on a migrated page yields exit 36" refuses the host
rather than fixing the cause.

The probe and its write-up are deliberately not linked from here while
they sit on the spike-skill branch: `check_doc_links.py` does not cover
this file, so a cross-branch reference would have shipped dangling with
every gate green.
ffroliva added a commit that referenced this pull request Sep 6, 2026
git never descends into an ignored directory, so the negation added in #703
for scripts/dev/har-spike/artifacts/.gitkeep could never take effect — the
file was never tracked. Removed rather than restructured: the capture
scripts create the directory at runtime, so nothing needs to hold it open.
ffroliva added a commit that referenced this pull request Sep 6, 2026
The ledger recorded the bug as the fix. Its exit-code table presented
`character create` on a moved account exiting 36 as a v0.70.0 improvement,
and its not-verified list said CHARACTER entities "cannot run on any
account available here". Both were false, from one root: a 20 s readiness
timeout was read as proof the feature does not exist on flow.google.com,
and #701 then added a guard that aborted before probing the DOM — which
made the claim unfalsifiable, because no run could look.

#703 disproved it. The editor is fully present on the migrated host, on
the same labs tRPC + aisandbox backend; only the view layer differs (React
+ Slate vs Angular + ProseMirror). Replaces both statements with what was
actually run: a complete character on ci-probe — name, personality, voice,
portrait and body, two distinct workflow ids, two distinct media ids, both
images on disk with different checksums, confirmed by read-back. Plus
`--model` determinism over four alternating runs and the orphan-rollback
A/B (1 -> 2 entities without the fix, 2 -> 2 with).

Records what was NOT verified rather than omitting it: the labs.google
path for all seven changed anchors, since every account here is migrated.

Also updates the #692 entry with evidence that changes what that issue is.
flow.google.com CAN mint a reCAPTCHA Enterprise token: on /project/<id>
the enterprise script and site key are present and a mint returns a
2404-char token, while the root grid carries no script at all. The mint
runs on the pooled bootstrap page, which after the handoff IS the root
grid — which is exactly why the reporter's bundle showed route: "/". So
"any mint failure on a migrated page yields exit 36" refuses the host
rather than fixing the cause.

The probe and its write-up are deliberately not linked from here while
they sit on the spike-skill branch: `check_doc_links.py` does not cover
this file, so a cross-branch reference would have shipped dangling with
every gate green.
ffroliva added a commit that referenced this pull request Sep 7, 2026
Nine-dimension council on 7c1d4c6 returned 4 GREEN / 5 YELLOW. Every finding
below was raised by a reviewer, not by the author.

D1/D14 - the retained 2026-07-27 comment still said, present tense, that the EN
  `span:text-is('Format')` fallback "has to be" the structural anchor, describing
  an entry the same commit deletes. The block also told the spike doc's story a
  second time, in the copy that will drift. Cut to the two DOM captures (the
  evidence) plus four lines, and a link to the finding.

D4/D6 - `test_clicks_ligature_selector_first` kept its name and docstring while
  only its assertion moved to the custom element; the e2e's failure message still
  told a future drift victim the cascade is "anchored on the personal_recommendations
  ligature". A label that lies outlives the bug it describes. Renamed, reworded,
  and the e2e message now names the probe to run. Assertions themselves untouched -
  only the string a failure prints.

D4 - the fall-through test drove only `PROMPT_FORMAT_SELECTORS[-1]`, so the new
  `<mat-icon>` entry at index 1 was never exercised. Parametrized over the whole
  cascade.

D6 - `is_visible(timeout=1000)` is inert: Playwright documents the argument as
  ignored, and the call answers from the current DOM without waiting. Dropped, with
  the reason, so nobody re-derives "a 4-entry cascade costs 4s before the submit".

D9 - the CHANGELOG called the capture "DOM read only" while the method it cites
  does a free `createEntity`, which is a write. Two files, one method, disagreeing.

D5 - `flow-locale-leak-icon-ligatures.md` still prescribed the `<i>`-only pattern
  that caused this: #703 fixed three constants for the carrier split and never
  updated the memory that teaches it, so the fourth was written the old way on
  purpose. Corrected, and the split promoted to its own entry.

D13/D2 - the spike put its guard in FRONT of its probe: a control miss raised
  before the dump, the screenshots and the report, deleting the evidence that
  would correct it. That is the #701 failure this file cites in its own docstring.
  Both controls now raise from a `finally` that has already written everything.
  A second control was missing entirely - nothing checked that the typed text
  landed, so a click intercepted by a consent overlay would have recorded a false
  absence indistinguishable from a real measurement. The scratch entity is deleted
  again, and the output path is root-anchored: `.gitignore`'s entry is slash-anchored,
  so running the spike from `scripts/dev/` wrote an UNIGNORED capture carrying the
  profile name, live URL, entity ids and authenticated outer_html.

D14 - the occlusion probe measured the prompt box, not the button under
  investigation, and the spike doc filed that under "confirmed, not assumed".
  Deleted; the live e2e click landing is stronger evidence anyway.

Re-verified live on denon82 after the rewrite ($0, 14s, clean exit):
  control_check    count=1
  typed_control    chars=47
  flow-format-prompt-button button                          -> 1
  button:has(mat-icon:text-is('personal_recommendations'))  -> 1
  button:has(i.google-symbols:text-is(...))                 -> 0
  button:has(i:text-is(...))                                -> 0

Declined, with reason: dropping `button:has(i.google-symbols:...)` as redundant
with the broader `button:has(i:...)`. The superset argument is correct, but those
are the labs anchors and no unmoved account exists here to re-verify against -
narrowing an untested surface to save one line is the wrong trade.

Refs #727, #730, #731
tranzmatt pushed a commit to tranzmatt/gflow-cli-unofficial that referenced this pull request Sep 9, 2026
…t a tag that moved

`--format-prompt` had degraded to a silent no-op on the migrated host: all three
entries of PROMPT_FORMAT_SELECTORS missed, `format_button_not_found` fired, the
prompt submitted as typed and the run exited 0 having spent image quota on a
prompt-engineering step that never happened.

Nothing was absent. Measured live on denon82 ($0 — navigation, one free
createEntity, DOM reads), with a control anchor so a flat zero could not be read
as absence:

  control_check  count=1   (prompt box resolved — the probe was in the editor)
  personal_recommendations via i.google-symbols   -> 0
  personal_recommendations via i                  -> 0
  span:text-is('Format')                          -> 0
  the button itself, visible the whole time       -> 1

  <flow-format-prompt-button>
    <button aria-label="Formatar">
      <mat-icon class="... google-symbols ...">personal_recommendations</mat-icon>

Two independent misses, either one fatal:

  a. Angular renders the ligature in <mat-icon>, not <i>. The ligature never
     changed and mat-icon even carries the google-symbols class, so the class
     was never the problem — the `i` tag was. This is the same carrier split
     already fixed for add_2 / arrow_drop_down / accessibility_new in ffroliva#703;
     this constant was simply not swept with them, and nothing proves it was
     the last one that was missed.
  b. The button now has an aria-label, but Flow localises it ("Formatar" on a
     pt account), as it does the <span>. The EN text fallback is deleted rather
     than translated — display labels are banned as anchors (AGENTS.md).

The cascade now leads with the custom element <flow-format-prompt-button>: a
component boundary rather than a layout accident, unique among the six composer
buttons, and the strongest anchor class this frontend offers.

Two unit tests pin the contract that was missing: the cascade must carry BOTH
ligature carriers, and it must carry no display-label anchor. Both were watched
red against the old constant before the fix.

Verified by a live run, not by relaxing the assertion:
  tests/e2e/test_character_create_e2e.py::test_character_create_format_prompt_clicks_format_button
  1 passed in 219.90s — denon82, real generation, zero credits

Not measured: the labs frontend. No unmoved account exists here, so the two <i>
entries ride on the 2026-07-27 capture alone and were not re-verified today.

Closes ffroliva#727
tranzmatt pushed a commit to tranzmatt/gflow-cli-unofficial that referenced this pull request Sep 9, 2026
… the false VERIFIED

Fixes ffroliva#731. docs/CHARACTER.md section 12 marked rows **VERIFIED** that stopped
being true at ffroliva#703, and the file is mirrored to website/docs, so the false claims
are published.

A row marked verified that is not is worse than no row: it is what a reader
trusts INSTEAD of reading the code. That is how ffroliva#727 got written - the carrier
split was documented nowhere a selector author would look, and the table
confidently said otherwise.

Every row re-derived by reading the constant, not edited from memory. What that
turned up, beyond what ffroliva#731 reported:

  - Model picker: the documented selector carried `aria-haspopup='menu'`, which
    appears in NONE of the four shipped entries. Not merely stale - it never
    matched the shipped tuple.
  - Generate / submit: SUBMIT_BUTTON_SELECTORS is single-carrier, and on the
    migrated host BOTH `<i>` entries miss. Only the third, `has-text('arrow_forward')`,
    fires - matching the mat-icon's text rather than its tag. Observed live
    2026-09-07: `prompt_submitted via="button:has-text('arrow_forward')"`. It works
    by luck, not by design; noted in the row and tracked in ffroliva#730.
  - Prompt box: the row claimed PROMPT_INPUT_SELECTORS[0]. On the migrated host [0]
    misses and [1] `div[contenteditable="true"]` is what matches (live, same run).
  - Editor-ready anchor and body-mode chip: both gained mat-icon / custom-element
    alternatives at ffroliva#703 that the rows never picked up.

The undated **VERIFIED** marker is replaced by a per-row date naming what the row
was checked against - code, live run, or both - so a row cannot silently outlive
its evidence again. The carrier split is stated at the top of the section, where
someone writing a selector will actually meet it.

Deliberately NOT included: the Format-button row and the docs/USAGE.md
`--format-prompt` entry. Both describe behaviour that is still changing in ffroliva#729,
and documenting a flag from this branch would describe the broken version. They
ship with that PR.

Also removed before commit: a [[ligature-carrier-differs-by-host]] wikilink I had
added here. That memory file lands with ffroliva#729, not develop - a pointer to something
that does not exist yet is the same defect this PR is fixing.

Verified: website mirror regenerated and in sync, doc links resolve, PII scan
clean, hygiene 1023 files. Three pre-existing dangling wikilinks remain in this
file (private-store-only memories); left alone, out of scope.
tranzmatt pushed a commit to tranzmatt/gflow-cli-unofficial that referenced this pull request Sep 9, 2026
…her half

Phase 0 for ffroliva#730, $0, denon82 migrated host. Two surfaces, controls recorded
before any count, hard-error only after the dump is written.

CARRIER RESULT - unambiguous. i.google-symbols matched ZERO for every ligature on
both surfaces; mat-icon matched; and .google-symbols matched exactly the same
count as mat-icon every time. The class sits on both carriers, so a class-only
anchor covers labs AND migrated with no extra cascade entry: the fix for the 24
<i>-only constants is one character each, not 24 duplicated entries.

Two selectors confirmed broken-but-masked:
  - SUBMIT_BUTTON_SELECTORS: both <i> entries miss; only has-text('arrow_forward')
    fires, matching the mat-icon's TEXT rather than its tag. Independently seen
    live the same day. It works by luck.
  - IMAGE_MODEL_PICKER_TRIGGER: arrow_drop_down i=0/mat=1, no twin, no fallback.

THE FINDING A CARRIER-ONLY FIX WOULD HAVE SHIPPED PAST: the migrated host renders
`add`, and renders `add_2` NOWHERE (composer add=1/add_2=0, editor add=2/add_2=0).
That is a ligature NAME change, not a carrier change. NEW_PROJECT_SELECTORS,
ADD_MEDIA_BUTTON and _CHARACTER_SLOT_ADD_SELECTOR all anchor on add_2 - and the
last one ALREADY covers both carriers, so ffroliva#703 swept it and left it broken.
Changing the carrier on those three would have produced a green sweep and three
selectors that still match nothing.

It also corrects ffroliva#730's own number: 55 counts literals and double-counts cascades
that alternate carriers. 24 constants is the actionable figure.

The probe found the name drift only because it counted whether the ligature was
present AT ALL, alongside the carrier comparison - a control column added so a
zero could not be misread as absence. Added for epistemic hygiene; it is what
turned up the second defect class.

Also recorded: the first run of this spike died at `Could not find 'New project'
CTA`, because it entered via _enter_editor, which clicks NEW_PROJECT_SELECTORS -
one of the constants under test. A probe must not route through its own subject.
Now navigates straight to the project URL, with the reason in the code.

NOT measured, and stated in the doc: labs (no unmoved account here), ligatures
absent from both surfaces are not proven absent from the product, and whether
broadening to .google-symbols changes .first picks where document order matters.
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