diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 17af9ac34..26d3fe181 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "gflow", - "version": "0.77.1", + "version": "0.78.0", "description": "Reusable development and operations workflows for gflow-cli", "author": { "name": "Flavio Oliva", diff --git a/AGENTS.md b/AGENTS.md index 3bcf1b177..c1c23e690 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,7 +47,7 @@ by construction, so Codex / Cursor / Aider / `agy` read exactly what Claude Code - Python CLI and MCP server for [Google Flow](https://labs.google/fx/tools/flow) — drives Veo (image-to-video, text-to-video) and Imagen (text-to-image) generations from the terminal by reverse-engineering Flow's private REST API at `aisandbox-pa.googleapis.com` — and, for accounts Google has moved to `flow.google.com`, that frontend's `batchexecute` wire (text-to-video, image-to-video from a local `--initial-frame`, reference-to-video from local `--ref` files, and text-to-image/local-file image-to-image, today; `GFLOW_CLI_FLOW_HOST`). - Python 3.11+ · `uv`-managed · `hatchling` builds · Playwright Chromium transport · `pyright` strict · `ruff` · `pytest`. - Single-package modular monolith. Top-level modules under `src/gflow_cli/`: `api/`, `auth/`, `data/`, `mcp/`, `services/`, `tools/`, `ui/`, `worker/`, `browser_manager.py`, `cli.py`, `_cli_helpers.py`, `diagnostics.py`, `json_output.py`, `media.py`, `profile_lease.py`, `redaction.py`, `storage.py`, `winsec.py`, `cli_project.py`, `cli_character.py`, `cli_credits.py`, `cli_data.py`, `cli_image.py`, `cli_instructions.py`, `cli_models.py`, `cli_movie.py`, `cli_run.py`, `cli_scene.py`, `cli_tools.py`, `cli_video.py`, `chain.py`, `chain_manifest.py`, `cli_doctor.py`, `cli_update.py`, `composition.py`, `config.py`, `errors.py`, `file_integrity.py`, `flow_selectors/`, `update_check.py`, `exceptions.py`, `image_batch.py`, `movie_manifest.py`, `observability.py`, `paths.py`, `profile_store.py`. -- Command surface: `gflow auth`, `gflow credits` (user/list — read-only Veo balance), `gflow image` (t2i/i2i/batch/upload/upscale), `gflow video` (t2v/i2v/r2v/chain/extend — `extend` continues an existing clip past Flow's 8s ceiling, server-seeded from the source so the join is continuous; no `batch` subcommand; the nonfunctional stub was removed, loop `gflow video t2v`/`i2v` from the shell for multi-clip runs), `gflow character` (create/list/show/rm/voices — reusable project-scoped Flow Character entities), `gflow scene` (create/show — Add Clip / Scenes, with `create --output` for credit-free server-side extended video), `gflow instructions` (persistent Agent-Mode brief cards — add/list/enable/disable/rm/apply/toggle-mode, credits-free, `--project` required), `gflow movie` (run/template — multi-scene manifest pipeline), `gflow tools` (list/show/run — prompt-rewriting tools, also `--tool` on generation commands), `gflow data` (catalog queries), `gflow doctor` (read-only pre-flight diagnostic, exit 33 = findings present), `gflow update` (self-update through the installer that put it here — uv tool / pipx / pip; `--check` only reports; source installs refused, exit 11; deliberately no MCP twin), `gflow project`, `gflow models`, `gflow run`, `gflow mcp` (run/setup — stdio MCP server), and `gflow serve` (Streamable HTTP at `/mcp`; `--transport sse` is deprecated). +- Command surface: `gflow auth`, `gflow credits` (user/list — read-only Veo balance), `gflow image` (t2i/i2i/batch/upload/upscale), `gflow video` (t2v/i2v/r2v/chain/extend — `extend` continues an existing clip past Flow's 8s ceiling, server-seeded from the source so the join is continuous; no `batch` subcommand; the nonfunctional stub was removed, loop `gflow video t2v`/`i2v` from the shell for multi-clip runs), `gflow character` (create/list/show/rm/voices — reusable project-scoped Flow Character entities), `gflow scene` (create/show — Add Clip / Scenes, with `create --output` for credit-free server-side extended video), `gflow instructions` (persistent Agent-Mode brief cards — add/list/enable/disable/rm/apply/toggle-mode, credits-free, `--project` required), `gflow movie` (run/template — multi-scene manifest pipeline), `gflow tools` (list/show/run — prompt-rewriting tools, also `--tool` on generation commands), `gflow data` (catalog queries), `gflow docs` (list/print/search the bundled documentation — read-only, offline, deliberately no MCP twin), `gflow doctor` (read-only pre-flight diagnostic, exit 33 = findings present), `gflow update` (self-update through the installer that put it here — uv tool / pipx / pip; `--check` only reports; source installs refused, exit 11; deliberately no MCP twin), `gflow project`, `gflow models`, `gflow run`, `gflow mcp` (run/setup — stdio MCP server), and `gflow serve` (Streamable HTTP at `/mcp`; `--transport sse` is deprecated). - Works with any Google account that has Flow access. All generations bill against the user's own Google account. ## Headed-browser dependency (architectural reality) diff --git a/CHANGELOG.md b/CHANGELOG.md index 587af3891..2a13c20e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,126 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.78.0] — 2026-09-17 + +### Added + +- **`--aspect 3:4` images now run on `flow.google.com`.** Flow's image settings there + render a fifth aspect radio (`3:4`) that the 2026-09-08 enumeration did not see, so gflow + refused it with exit 36. All five image aspects are now driven on both hosts. + ([#864](https://github.com/ffroliva/gflow-cli/issues/864)) + +- **`gflow docs` — the documentation, reachable from the terminal.** 126 pages in `docs/` + and nothing in the CLI pointed at any of them, so at the moment of use the knowledge was + unreachable and the reader guessed. `gflow docs` lists the topics, `gflow docs ` + prints one as raw Markdown (it pipes), and `gflow docs --search ` returns the + matching lines with `docs/FILE.md:LINE` beside each one. Read-only and offline — no + network, no account, no credits. + + **The pages now ship inside the wheel** (`gflow_cli/_docs`; 1.3 MB of Markdown, **+0.58 MB + compressed** — the wheel goes 0.74 MB → 1.32 MB), because the user this was filed for + installed from PyPI and has no checkout; a command that could only print GitHub links + would not solve it. A build hook globs `docs/*.md`, so the topic list cannot drift and + `assets/`/`superpowers/` stay out — measured, after `force-include` + `exclude` silently + shipped 148 files it was told to exclude. The hook **refuses to build** when it finds no + pages: `Path.glob` on a missing directory returns empty, and `release.yml` is `uv build` + then publish, with nothing in between that would notice a wheel whose `gflow docs` is + empty for every user. + + Search returns a **line, windowed**, not a file name: the rule that prompted this issue + lives inside a 4 000-character bullet in `MCP.md`, and answering "it is in MCP.md" leaves + the reader where they started. Curated `INDEX.md` § Topic shortcuts rank above raw body + text, all query terms must appear on the line, and a vague query reports how many hits it + held back instead of quietly truncating. A topic resolves from its slug, its file name, + either case or an unambiguous prefix — and never by building a path from what was typed, + so `gflow docs ../../etc/passwd` is an ordinary unknown-topic refusal. + + No MCP twin, deliberately (`gflow update` is the precedent); the reasoning is recorded in + `cli_docs.py` rather than left implicit. + ([#861](https://github.com/ffroliva/gflow-cli/issues/861)) + +- **`gflow video i2v --end-frame ` now runs on `flow.google.com`.** Start+end + frame interpolation was the last i2v form the migrated composer refused: it exited 36 + ("an end frame is not ported yet") on every account Flow has moved. Both frames are now + uploaded through the editor's own Upload entry and bound to the Start and End chips, and + the driver reads back two bound chips before it lets the app submit. + + A bound end frame switches Flow to a different submit contract — rpc `nprQif`, an + interpolation model key, and both media ids in the body — which is why watching only the + classic submit rpcs made every start+end run time out *after* Flow had already accepted + the submit and billed it. The submit body is asserted before Flow acts on it, so a frame + that silently failed to bind is a refusal rather than a wrong generation you paid for. + + The model key is **cohort-dependent** and matched by shape, not by literal: + `veo_3_1_interpolation_lite` and `omni_flash_i2v_8s_first_last` have both been captured + on live accounts (the second on 2026-09-17, see + `docs/superpowers/spikes/2026-09-17-migrated-end-frame-submit-contract.md`). Frames given + by media UUID or `@Name` are still not ported. + ([#639](https://github.com/ffroliva/gflow-cli/issues/639)) + +### Fixed + +- **The Frames picker could not bind the frame it had just uploaded.** On + `flow.google.com`, `video i2v` matched a picker option with an *anchored* regex + (`^\s*\s*$`), but a picker tile is a `mat-icon` ligature followed by the file + name and a locator reads both nodes as one string — `imageshero-ab12cd34.png` for an + asset named `hero-ab12cd34.png`. The anchors could never hold, so every i2v run on this + cohort ended in exit 32, *"the frame picker lists no asset named …"*, while the picker + had been listing it the whole time. The match is now containment on the display name, + which is run-unique by construction ([#792](https://github.com/ffroliva/gflow-cli/issues/792)) + and therefore still cannot bind a stale copy of the same file. Covered by a new $0 + route-intercepted e2e (`tests/features/frame_picker_binding.feature`) that renders the + icon node for real — a mocked picker passes against the bug. + ([#860](https://github.com/ffroliva/gflow-cli/issues/860)) + +- **Flow's promo modal blocked the first click on a freshly loaded editor.** On + `flow.google.com` a `role="dialog"` panel over a new editor made `image t2i` fail as + exit 23 — *".settings-trigger-button did not accept a click within 5000 ms — it is + covered by div.cdk-overlay-backdrop"* — telling the user to file a selector-drift bug + when one click on the page fixes it permanently. + + `_dismiss_dialog` already existed and its selector already matched. It was called at + the one moment it could not see anything: one frame after `domcontentloaded`, before + Angular has rendered the modal. That is why a blocked run logged **neither** + `migrated.dialog_dismissed` nor `migrated.dialog_not_dismissed` — the silence in the + incident's event stream was the evidence, not the absence of it. The dismissal now + also runs in `_open_pane`, where the image and video paths take their first click, the + same place the `glue` consent bar is already cleared. It stays best-effort: a modal + that refuses to go still falls through to the click post-mortem, which names the + backdrop, rather than becoming a second competing error path. + ([#859](https://github.com/ffroliva/gflow-cli/issues/859)) + +- **The migrated `--end-frame` lane rejected omni's interpolation key.** The start+end + submit validator pinned the model key to `veo_3_1_interpolation_lite`, so + `omni_flash_i2v_*_first_last` submits — what the second measured cohort actually sends — + were refused as wire-format errors before Flow could act on them. The check is now + key-shape based: a submit whose model key contains `interpolation` or `first_last` + counts as start+end interpolation for any model, and the submit observer accepts the + bare `batchexecute` reply (no `rpcids` param) that the interpolation rpc answers on. + ([#639](https://github.com/ffroliva/gflow-cli/issues/639)) +- **Generating without a project works again on accounts Flow serves from + `flow.google.com`, and it no longer tells you to log in.** Every run without `--project` + (CLI) or `project` (MCP) starts by creating a project through labs.google's + `project.createProject`. Google has retired that route: it answers *"Flow RPCs have been + deprecated and disabled"* (404), or 401 for a session holding no labs token — measured + 12/12 on four profiles. gflow reported the 401 as *"Authentication expired — run `gflow + auth login`"*, which no login could fix. When that route refuses, gflow now creates the + project on flow.google.com's projects page and reads the new id and title from Flow's own + reply; `gflow project create` and `gflow project rename` work there the same way. + `GFLOW_CLI_FLOW_HOST=labs.google` keeps the labs route and its error. Video runs without a + project now get it created up front, like images, so they route exactly as if you had + passed `--project`; MCP `gflow_generate_video` also honours `project_name`, which it + accepted and then ignored. Thanks to @iceblue03 (#863) and @ChandraLiuswanto (#561) for + the reports. ([#864](https://github.com/ffroliva/gflow-cli/issues/864), + [#561](https://github.com/ffroliva/gflow-cli/issues/561)) +- **An MCP server no longer fails a call because another call holds the profile.** Two + calls on one profile inside one server — a client retry, two agents sharing a server — + now run one after the other instead of the second failing with `ProfileLockedError`, and + a profile held by another process (a CLI run) is waited out for up to 180 s. A + `GFLOW_CLI_LEASE_WAIT_SECONDS` you set yourself, in the environment or a `.env`, still + wins. The CLI keeps failing fast. Thanks to @iceblue03 (#862). + ([#864](https://github.com/ffroliva/gflow-cli/issues/864)) + ## [0.77.1] — 2026-09-17 ### Fixed @@ -5349,7 +5469,8 @@ shell-script template that branches on these codes. First skeleton. Not functional end-to-end yet. -[Unreleased]: https://github.com/ffroliva/gflow-cli/compare/v0.77.1...HEAD +[Unreleased]: https://github.com/ffroliva/gflow-cli/compare/v0.78.0...HEAD +[0.78.0]: https://github.com/ffroliva/gflow-cli/compare/v0.77.1...v0.78.0 [0.77.1]: https://github.com/ffroliva/gflow-cli/compare/v0.77.0...v0.77.1 [0.77.0]: https://github.com/ffroliva/gflow-cli/compare/v0.76.0...v0.77.0 [0.76.0]: https://github.com/ffroliva/gflow-cli/compare/v0.75.0...v0.76.0 diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index 232715f34..9830bc126 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -16,7 +16,7 @@ Living list of behaviour that's broken, surprising, or limited by design — alo ### Flow is migrating to `flow.google.com`; generation coverage is partial but includes images -- **Status:** Open (partially resolved) · **Severity:** High for unported forms · **Affected:** on accounts the rollout has reached, `gflow video t2v`, local-file `video i2v` / `r2v`, `gflow image t2i`, and local-file `gflow image i2i` now run on the migrated host. Image mode supports Nano Banana 2 / Pro, the four aspect ratios its radiogroup was enumerated with (16:9, 4:3, 1:1, 9:16), and count 1–4; `3:4` had no radio in that enumeration and is refused before submit rather than reported as selector drift. Image refs by UUID, `@Name` / `--reference-entity`, Agent instructions, Imagen 4, video end frames, video refs by UUID/name, scenes, extend, instructions and tools are not ported yet and fail before submit. **`character` is NOT in that list any more** — `character create` and `character list` work on the migrated host. Of the remainder, only the i2v-by-UUID case rests on a positive observation of absence (the Frames picker tiles carry no media id); `scenes`, `extend`, `instructions` and `tools` remain *unported by gflow*, never proven impossible on the host. +- **Status:** Open (partially resolved) · **Severity:** High for unported forms · **Affected:** on accounts the rollout has reached, `gflow video t2v`, local-file `video i2v` / `r2v`, `gflow image t2i`, and local-file `gflow image i2i` now run on the migrated host. Image mode supports Nano Banana 2 / Pro, all five aspect ratios its radiogroup renders (16:9, 4:3, 1:1, 3:4, 9:16 — 3:4 appeared by 2026-09-17, #864), and count 1–4. Image refs by UUID, `@Name` / `--reference-entity`, Agent instructions, Imagen 4, video frame refs by UUID/name, scenes, extend, instructions and tools are not ported yet and fail before submit. **`character` is NOT in that list any more** — `character create` and `character list` work on the migrated host. Of the remainder, only the i2v-by-UUID case rests on a positive observation of absence (the Frames picker tiles carry no media id); `scenes`, `extend`, `instructions` and `tools` remain *unported by gflow*, never proven impossible on the host. - **Tracked:** [#639](https://github.com/ffroliva/gflow-cli/issues/639) · Reported 2026-09-02 against 0.59.0, 0.62.1, 0.63.0 and 0.65.0 - **Confirmed live 2026-09-03 on a second, independent account** (`ffroliva`) — see [LIVE_VERIFICATION_v0.66.0](docs/LIVE_VERIFICATION_v0.66.0.md). A read-only probe of the migrated origin measured `i_total: 0`, reproducing the reporter's central measurement. - **`--reference-entity` was refused on `r2v` but not on `t2v`** ([#716](https://github.com/ffroliva/gflow-cli/issues/716), fixed in v0.71.0): the "not ported, exit 36" refusal above sat inside the r2v branch of the routing gate, so a `t2v` request carrying a character entity returned from that gate without its entities ever being inspected — and nothing downstream attaches one on this host. The generation was **submitted and billed** with the entity silently dropped, returning a plausible clip of the wrong person. The check is now mode-independent and ahead of every early return. If you ran `gflow video t2v @Name …` or `--reference-entity` on a moved account before this fix, the identity in those clips was never bound. @@ -55,9 +55,8 @@ the clip). Two real clips were generated this way on 2026-09-05 — spike `docs/superpowers/spikes/2026-09-05-migrated-host-wire-protocol.md`. Routing (`GFLOW_CLI_FLOW_HOST=auto`): flow.google.com is the **default** host for that command on every account — moved or not; `flow.google.com` forces it for -everything, and `labs.google` switches the migrated composer off. Limits today: `--project` is required (project creation from the -migrated editor is not ported), and `t2v`, `i2v` from a local `--initial-frame` (no end frame, -no UUID/`@Name` frame — the migrated Frames picker exposes no media id in its DOM, so a frame is +everything, and `labs.google` switches the migrated composer off. Limits today: only `t2v`, `i2v` from local start (and end) frames (no UUID/`@Name` frame — +the migrated Frames picker exposes no media id in its DOM, so a frame is found by the run-unique name gflow uploads it under, #792), and `r2v` from local `--ref` files (see the next paragraph), plus `image t2i` and local-file `image i2i` — unsupported forms still exit 36. @@ -108,7 +107,7 @@ with the distinct, non-retryable exit 36 instead of the misleading `UiSelectorDriftError` (exit 23, "file a selector bug"). `_check_logged_in` also accepts the migrated host, so a migrated load is no longer misread as a logged-out session. The generation forms listed above and characters are driven; -scenes, extend, instructions, tools, project creation, and the named reference/model +scenes, extend, instructions, tools, and the named reference/model variants are the remaining work tracked here — no retry helps for those until each is ported. @@ -1440,7 +1439,7 @@ key — surfaces as a `RuntimeError` that `auth/cookies.py` normalizes to - **Status:** Mitigated (crash → typed fail-fast rejection) · **Severity:** Low · **Affects:** all versions -Chromium refuses to open two persistent contexts on the same `user-data-dir` simultaneously. Historically this surfaced as an unhelpful Chromium "ProcessSingleton: profile is locked" error partway through a run. As of the profile-lease hardening (production-readiness plan, slice D1/D3), gflow-cli enforces this itself: a cross-process advisory lock (`ProfileLease`, kernel `flock` on POSIX / `msvcrt.locking` on Windows) guards every profile directory. A second `gflow` invocation, `gflow serve` daemon task, or MCP call against an already-leased profile is rejected **immediately** by default — before any Chrome process starts — with a typed `ProfileLockedError` (**exit code 11**); it never silently corrupts the profile. Since #478, setting [`GFLOW_CLI_LEASE_WAIT_SECONDS`](docs/CONFIGURATION.md#gflow_cli_lease_wait_seconds) opts a waiter into a bounded wait that takes over as soon as the current holder finishes (holders always run to completion and are never asked to release early; same-process contention still fails fast — waiting on yourself would deadlock). +Chromium refuses to open two persistent contexts on the same `user-data-dir` simultaneously. Historically this surfaced as an unhelpful Chromium "ProcessSingleton: profile is locked" error partway through a run. As of the profile-lease hardening (production-readiness plan, slice D1/D3), gflow-cli enforces this itself: a cross-process advisory lock (`ProfileLease`, kernel `flock` on POSIX / `msvcrt.locking` on Windows) guards every profile directory. A second `gflow` invocation, `gflow serve` daemon task, or MCP call against an already-leased profile is rejected **immediately** by default — before any Chrome process starts — with a typed `ProfileLockedError` (**exit code 11**); it never silently corrupts the profile. Since #478, setting [`GFLOW_CLI_LEASE_WAIT_SECONDS`](docs/CONFIGURATION.md#gflow_cli_lease_wait_seconds) opts a waiter into a bounded wait that takes over as soon as the current holder finishes (holders always run to completion and are never asked to release early; same-process contention still fails fast — waiting on yourself would deadlock). Since #864 an MCP server waits 180 s by default, and queues its own calls on one profile rather than rejecting the second. **Workaround:** use different profiles for parallel work — different profiles acquire independent leases and run fully concurrently. diff --git a/docker/Dockerfile b/docker/Dockerfile index 08438aedd..29dd18dd6 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -45,7 +45,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ # Deliberately NOT pinned: google-chrome-stable. Chrome's apt repo keeps only the current # build, so a pinned version stops resolving within weeks. The trade-off is that this # layer caches indefinitely and a rebuild is what picks up a newer Chrome. -ARG GFLOW_VERSION=0.77.1 +ARG GFLOW_VERSION=0.78.0 RUN pip install --no-cache-dir "gflow-cli==${GFLOW_VERSION}" VOLUME ["/data"] diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index d0b4715f9..fbe0ce4f3 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -284,9 +284,9 @@ GFLOW_CLI_AUTH_LOGIN_TIMEOUT=120 gflow auth login # abort after 2 minutes **What:** How long a command waits for another gflow process to release the profile lease before giving up with `ProfileLockedError` (exit 11). The default `0` keeps the historical fail-fast behavior. With a positive value, the waiter polls the kernel lock (0.5 s cadence) and simply takes over when the current holder — a CLI command or a `gflow serve` daemon task, both of which release at their natural end — finishes. Holders are never interrupted or asked to release early. **Values:** `0`–`3600` seconds (fractions allowed) -**Default:** `0` (fail fast) +**Default:** `0` (fail fast) for CLI commands; `180` under `gflow mcp run` / `gflow serve`, where no human is there to retry — unless you set it yourself, in the environment or a `.env` file. **Note:** Same-process contention always fails fast regardless of this setting — the holder is the same process, so waiting would deadlock. See [KNOWN_ISSUES § Same profile can't be used in parallel](../KNOWN_ISSUES.md#same-profile-cant-be-used-in-parallel). -**Shipped in:** #478. +**Shipped in:** #478; MCP default #864. ### `GFLOW_CLI_DB_PATH` @@ -361,11 +361,11 @@ GFLOW_CLI_HISTORY_PROMPTS=redacted gflow image t2i "confidential brief" **What:** Which Flow frontend gflow drives. Google has moved Flow from `labs.google/fx/tools/flow` onto `flow.google.com` ([#639](https://github.com/ffroliva/gflow-cli/issues/639)) — measured 2026-09-14, the old labs URL answers **HTTP 308 Permanent Redirect** on every account we tested. The two are the same product (the new frontend's own root element is `aisandbox-root`) on different widget toolkits and different wire protocols, so each has its own driver. Which host you are served does **not** tell you which capabilities work — see [the survey](../docs/superpowers/spikes/2026-09-14-two-domain-protocol-survey.md). **Values:** -- `auto` (default) — **`flow.google.com` is the default host for every video request it can serve today** (`video t2v`, local-file `video i2v` and `video r2v`, all with `--project`). Image requests use the migrated composer for `image t2i` and local-file `image i2i` where `flow.google.com` is what Flow serves, and stay on labs where labs is. A request the new host cannot serve keeps the labs driver where labs is what Flow serves; where flow.google.com is, there is no labs fallback and unsupported forms exit 36/11 before submit. +- `auto` (default) — **`flow.google.com` is the default host for every video request it can serve today** (`video t2v`, local-file `video i2v` and `video r2v`; with no `--project`, one is created first, #864). Image requests use the migrated composer for `image t2i` and local-file `image i2i` where `flow.google.com` is what Flow serves, and stay on labs where labs is. A request the new host cannot serve keeps the labs driver where labs is what Flow serves; where flow.google.com is, there is no labs fallback and unsupported forms exit 36/11 before submit. - `flow.google.com` — force the migrated composer for everything, including what it cannot serve yet (those requests then exit 36/11 instead of falling back). - `labs.google` — never use the migrated composer; where Flow serves `flow.google.com`, requests fail with exit 36 (kill switch). **Default:** `auto` -**Scope today:** the migrated composer covers `gflow video t2v`, local-file `video i2v` / `r2v`, `gflow image t2i`, and local-file `gflow image i2i`. Images support Nano Banana 2 / Pro, the four aspect ratios enumerated on that host (16:9, 4:3, 1:1, 9:16 — `3:4` was not present and is refused before submit) and count 1–4; the page owns the `ogiZ0b` reCAPTCHA + submit and the response already contains completed signed image URLs. An end frame, UUID/name references, character entities, Agent instructions, Imagen 4, scenes, extend, instructions and tools are not ported yet and fail before submit where Flow serves flow.google.com. MCP uses the same image service and queue payload, and inherits this setting from the server/daemon environment rather than per call. +**Scope today:** the migrated composer covers `gflow video t2v`, local-file `video i2v` / `r2v`, `gflow image t2i`, and local-file `gflow image i2i`. Images support Nano Banana 2 / Pro, all five aspect ratios (16:9, 4:3, 1:1, 3:4, 9:16 — `3:4` was added to that host's radiogroup by 2026-09-17, #864) and count 1–4; the page owns the `ogiZ0b` reCAPTCHA + submit and the response already contains completed signed image URLs. Local-file start+end frames (`--initial-frame` + `--end-frame`) are ported (#639). Frames or references given by UUID/`@Name`, character entities, Agent instructions, Imagen 4, scenes, extend, instructions and tools are not ported yet and fail before submit where Flow serves flow.google.com. MCP uses the same image service and queue payload, and inherits this setting from the server/daemon environment rather than per call. ### `GFLOW_CLI_PREFER_CLASSIC` *(deprecated — use `GFLOW_CLI_UI_MODE=classic`)* diff --git a/docs/INDEX.md b/docs/INDEX.md index 55eebd4a2..7ef449dae 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -105,6 +105,7 @@ Slash commands for Claude Code, stored in `.claude/commands/gflow/`. All prefixe **"Should I use an `@mention`, `--reference-entity`, or `--ref` to tag an asset?"** → [REFERENCE_STRATEGIES](REFERENCE_STRATEGIES.md) — saved named identity → `@Name`/`--reference-entity`; one-off image → `--ref` **"How does Flow's media library / picker actually behave (names, tabs, scrolling, listing)?"** → [MEDIA_LIBRARY](MEDIA_LIBRARY.md) — identity = UUID, names are async AI captions; grid is virtualized; full pitfall table **"How do I turn a terse prompt into a vivid one before generating?"** → `--tool creative-director:style=cinema` on any generation command — see [PROMPT_EXPANSION](PROMPT_EXPANSION.md) · framework: [TOOLS](TOOLS.md) +**"Where is the page that explains X, right now, without leaving the terminal?"** → run `gflow docs` (list), `gflow docs ` (print), `gflow docs --search ""` (find the line) — see [USAGE § `gflow docs`](USAGE.md#gflow-docs). The pages ship inside the wheel, so it works with no checkout **"What tools are available and what styles do they support?"** → run `gflow tools list` / `gflow tools show creative-director` — see [TOOLS](TOOLS.md) **"How do I make Flow's agent follow a style/reference across a project's generations?"** → agent instruction cards — ephemeral `-i "text"` (shipped) or the persistent `gflow instructions` group with a generic `--ref` (image or character), see [INSTRUCTIONS](INSTRUCTIONS.md) **"What preset voices can a character use?"** → run `gflow character voices` — see [USAGE § `gflow character voices`](USAGE.md#gflow-character-voices) @@ -160,7 +161,7 @@ Slash commands for Claude Code, stored in `.claude/commands/gflow/`. All prefixe **"`gflow auth status` says verified but `gflow credits` returns 401 — which is lying?"** → neither. [MCP § 6.1 Which cohort is this account in?](MCP.md#61-first-question-which-cohort-is-this-account-in) — Google moves accounts onto `flow.google.com` in more than one step, and on some cohorts labs still authenticates while the `ya29` Bearer that aisandbox REST needs is gone. Both raise sites behind it now name that cause; on a migrated account SAPISID is usually fine and **re-running `gflow auth login` does not help** ([#795](https://github.com/ffroliva/gflow-cli/issues/795), [#791](https://github.com/ffroliva/gflow-cli/issues/791)) **"Which Flow UI arm does a run require, and why did it exit 2 or 28?"** → [CONFIGURATION § GFLOW_CLI_UI_MODE](CONFIGURATION.md#gflow_cli_ui_mode) (`auto` ≡ `classic` for **both** images and video — video since v0.57.0/#299, images since #595; unreachable classic aborts pre-submit with exit 28, $0; explicit `agentic` on video exits 2) -**"Which Flow frontend does a run drive — labs.google or the migrated flow.google.com — and why did it exit 36 or 11?"** → [CONFIGURATION § GFLOW_CLI_FLOW_HOST](CONFIGURATION.md#gflow_cli_flow_host) (`auto` = flow.google.com for every request it can serve — `t2v`, `i2v` from a local `--initial-frame`, `r2v` from local `--ref` files, `image t2i`, and `image i2i` from local files, with `--project`, on moved and unmoved accounts; `flow.google.com` forces it; `labs.google` is the kill switch. On a moved account an end frame, a UUID/`@Name` frame, references by `@Name`/`--reference-entity` and everything except `t2v`/`i2v`/`r2v` exit 36, non-retryable, and a missing `--project` exits 11) · recon: [character surface + reCAPTCHA mint](superpowers/spikes/2026-09-06-migrated-character-surface-and-recaptcha.md), [migrated-host wire protocol](superpowers/spikes/2026-09-05-migrated-host-wire-protocol.md), [handoff mechanism](superpowers/spikes/2026-09-04-migrated-host-handoff-mechanism.md), [agent mode hides the settings trigger](superpowers/spikes/2026-09-08-migrated-composer-agent-mode-hides-settings.md), [frames attach (i2v)](superpowers/spikes/2026-09-05-migrated-frames-attach.md) · evidence: [LIVE_VERIFICATION_v0.67.0](LIVE_VERIFICATION_v0.67.0.md) (t2v), [LIVE_VERIFICATION_v0.69.0](LIVE_VERIFICATION_v0.69.0.md) (i2v from a local start frame, v0.69.0), [LIVE_VERIFICATION_v0.70.0](LIVE_VERIFICATION_v0.70.0.md) (r2v on the migrated host; character create verified there in #703) +**"Which Flow frontend does a run drive — labs.google or the migrated flow.google.com — and why did it exit 36 or 11?"** → [CONFIGURATION § GFLOW_CLI_FLOW_HOST](CONFIGURATION.md#gflow_cli_flow_host) (`auto` = flow.google.com for every request it can serve — `t2v`, `i2v` from a local `--initial-frame`, `r2v` from local `--ref` files, `image t2i`, and `image i2i` from local files, with `--project`, on moved and unmoved accounts; `flow.google.com` forces it; `labs.google` is the kill switch. On an account served flow.google.com a UUID/`@Name` frame, references by `@Name`/`--reference-entity` and everything except `t2v`/`i2v` (local start, and local start+end since #639)/`r2v` exit 36, non-retryable, and a missing `--project` exits 11) · recon: [character surface + reCAPTCHA mint](superpowers/spikes/2026-09-06-migrated-character-surface-and-recaptcha.md), [migrated-host wire protocol](superpowers/spikes/2026-09-05-migrated-host-wire-protocol.md), [handoff mechanism](superpowers/spikes/2026-09-04-migrated-host-handoff-mechanism.md), [agent mode hides the settings trigger](superpowers/spikes/2026-09-08-migrated-composer-agent-mode-hides-settings.md), [frames attach (i2v)](superpowers/spikes/2026-09-05-migrated-frames-attach.md) · evidence: [LIVE_VERIFICATION_v0.67.0](LIVE_VERIFICATION_v0.67.0.md) (t2v), [LIVE_VERIFICATION_v0.69.0](LIVE_VERIFICATION_v0.69.0.md) (i2v from a local start frame, v0.69.0), [LIVE_VERIFICATION_v0.70.0](LIVE_VERIFICATION_v0.70.0.md) (r2v on the migrated host; character create verified there in #703) **"Will this get my Google account flagged or banned? What protects me?"** → [ACCOUNT_SAFETY.md](ACCOUNT_SAFETY.md) — the operational answer in one place: rate limit vs WAF block vs ban (they are different), what the tool does to stay unremarkable (headed real Chrome, ±25% interaction jitter, 0.5–1.5 s submission pacing, one project per run, isolated profiles, refuse-don't-retry), what it deliberately does **not** do (no proxies, no fingerprint spoofing, no headless unlock), the knobs, the [#241](https://github.com/ffroliva/gflow-cli/issues/241) field data on what actually triggers a 403, and what we cannot promise. Legal statement: [DISCLAIMER](../DISCLAIMER.md) **"I got a 403 / `PUBLIC_ERROR_UNUSUAL_ACTIVITY` — what now?"** → [ACCOUNT_SAFETY § When Google does push back](ACCOUNT_SAFETY.md#when-google-does-push-back) (stop, cool down 30–60 min, use real Chrome meanwhile, switch profile, widen jitter); cadence evidence in [DEBUGGING § WAF cadence](DEBUGGING.md#waf-cadence); full entry in [KNOWN_ISSUES](../KNOWN_ISSUES.md) @@ -169,7 +170,7 @@ Slash commands for Claude Code, stored in `.claude/commands/gflow/`. All prefixe **"A command failed — where is the incident bundle and what's in it?"** → [DEBUGGING § Automatic incident bundles](DEBUGGING.md#automatic-incident-bundles) (layout, triggers, review-before-sharing); privacy boundaries: [SECURITY § Automatic incident bundles](SECURITY.md#automatic-incident-bundles-gflow_cli_incident_capture-default-on); disable via [CONFIGURATION § GFLOW_CLI_INCIDENT_CAPTURE](CONFIGURATION.md#gflow_cli_incident_capture) **"Flow's UI broke a selector — how do I diagnose it?"** → [DEBUGGING § Inspecting Flow's live UI](DEBUGGING.md#inspecting-flows-live-ui) **"What does each `ui_automation.*` log event mean?"** → [DEBUGGING § Listener & HTTP-layer debugging](DEBUGGING.md#listener--http-layer-debugging) -**What was actually live-verified for the latest release?** → latest: [LIVE_VERIFICATION_v0.77.1](LIVE_VERIFICATION_v0.77.1.md) (**a four-fix patch release, verified 2026-09-17 at ZERO cost.** The headline is #846: the published package could not run a single interactive command on a clean Windows install, because `ConsoleRenderer(colors=True)` needs `colorama` and nothing declared it. Verified as a controlled A/B on a fresh Python 3.13 venv — `SystemError` before, exit 33 after — plus an arm with stderr piped that shows why the AUTO/JSON split hid this from every CI job, and the MCP twin run separately. #848's import probe is exercised against a **deliberately broken venv** and an intact one, including the contract that a probe which cannot run reports nothing rather than reporting breakage. #852 is re-measured on the release head: 40 000 base64-shaped chars fall from 12.07 s to 0.020 ms with identical output on Workspace and custom domains. **#849 is a NAMED BLOCKER, not an omission** — its end-to-end behaviour needs a fresh interactive Google sign-in, which no agent can perform; its live A/B is recorded on PR #851 and its e2e file re-ran 4/4 here. **Seven live e2e failures are recorded rather than hidden**, and controlled: the release changes exactly four source files and none of them is a failing test's module.) · previous: [LIVE_VERIFICATION_v0.77.0](LIVE_VERIFICATION_v0.77.0.md) (**the migrated-host login lockout (#791), verified as a controlled A/B on a real Google **Workspace** account at ZERO cost — same profile, same cookies untouched between arms, so the only variable is the code: exit 8 with the Chrome marker rolled back and `channel_for_profile` collapsed to `None`, then exit 0 with the marker intact and the account recorded. The refusal path is measured too — of 10 local profiles, 5 reproduce #791 and exactly one carries both required cookies: 4 refusals, 1 upgrade, 0 false positives. v0.76.0 recorded this fix as **withdrawn**; the approach that shipped is a different one, server-attested on the landing URL rather than on client-rendered DOM. An earlier revision gated auth on an `@gmail.com`-only pattern that silently declined every custom domain — the account used here is one, which is why this is its verification. **#796 is reproduced and prevented**, not merely reported: it was filed as *'not yet reproduced end-to-end'*. **Two rows are named blockers, not omissions:** the MCP Registry publish (#841) cannot be rehearsed before a real tag push, so this release is its first exercise; and the migrated-host generation e2e cannot run here at all — every account bounces to `/about`, A/B-controlled against `develop` so it is not mistaken for a regression.) · previous: [LIVE_VERIFICATION_v0.76.0](LIVE_VERIFICATION_v0.76.0.md) (**a diagnostics-and-distribution release, verified 2026-09-16 at ZERO cost — no Veo credits, no image generation, no Flow submit.** The decisive arm is #833's **negative** control, run first-hand on two real accounts rather than relayed: `flow-pinhole-unavailable-screen` is **0** on both `ffroliva` and `denon82` while `aisandbox-root` is **1**, so the guard does not fire on accounts that HAVE Flow access and the zero is a real absence rather than a page that never booted — a guard with only a positive arm is indistinguishable from one that is permanently on. Both accounts settled at **`/about`**, which is the state that broke this release's *withdrawn* #791 oracle, and the exit-39 signal reads a component that only exists on the unavailable screen, so `/about` cannot mimic it. mcpservers.org is verified with a **404 negative control** because the listing 403s to `curl` and a naive check would have called the link dead. **#791 is NOT in this release and was withdrawn rather than deferred**: a fix passed eight council dimensions and was then refuted by a five-day-old spike nobody had routed to it — Flow's `/about` hop is decided client-side with zero requests to Flow, and *"the backend grants access while the frontend declines to open it"* (#756), so the probe would have reported authenticated users as logged out, reintroducing #791 as its own cure; the e2e caught it by going red on a live account whose state moved mid-session. **Two rows are blocked by the platform rather than by effort:** the Docker sign-in needs a WSL-integrated Docker host, and the MCP Registry publish cannot be observed until this very tag reaches `main`, since both triggers resolve the workflow from the default branch.) · previous: [LIVE_VERIFICATION_v0.75.0](LIVE_VERIFICATION_v0.75.0.md) (**a distribution-and-hardening release, verified 2026-09-15 on `ffroliva` at ZERO cost — no Veo credits, no image generation, no Flow submit.** The `gflow serve` token fix is an **A/B against the released 0.74.0 wheel from PyPI**, not a diff read: the token was *set* in both arms, so the probe measures whether it is checked — before, `no token 200/200` with 13 tools listed and `wrong 200/200`; after, `401/401` with zero tools and `WWW-Authenticate: Bearer realm="gflow"`, while the correct token still gets 200. Loopback DNS-rebinding was re-confirmed at **421**/**403** because the fix forwards the bind host and getting that wrong would silently disable protection that works today — and an earlier draft of the accompanying advisory claimed that protection was *disabled*, a false claim caught by measuring rather than re-reading. #813's guard is verified against a **real wheel in a real venv installed without extras**, the arm first proving it is in that state (`av present: False PIL present: False`); the decisive row is that a **nonexistent** manifest still exits **20** on the dependency error rather than file-not-found, which is what shows the guard precedes the manifest read and not merely the generation. The Rich escape fix is verified on the **console render separated from the structlog JSON** — matching the JSON line would have been a false pass, since `--json` was never affected — with a **neutered control** through the same Rich version reproducing the bug exactly: `UNESCAPED -> pip install 'gflow-cli'` against `ESCAPED -> pip install 'gflow-cli[chain]'`. Also: `gflow-cli.exe` exists in the built wheel so `uvx gflow-cli` resolves at all, and the wheel carries 138 entries with **zero** ZIP duplicates. **Three items recorded as NOT verified rather than omitted:** a successful multi-link `chain` run spends Veo credits and stays outstanding e2e debt on #813 — what changed is the refusal path, which *is* verified; `gflow credits` against a working balance is impossible here because the account still returns 401, pre-existing **#795** and unrelated to this release; and the SSE arm of the token fix was exercised on loopback only, a deliberate scope limit, since binding a LAN address to test it would put an authenticated daemon on a live network for the run. **Two rows are blocked by the platform rather than by effort:** the PyPI metadata and the MCP Registry listing are both unobservable until this release is uploaded — PyPI renders the last artefact's metadata, and `mcp-publisher` reads its ownership token out of the *published* README.) · previous: [LIVE_VERIFICATION_v0.74.0](LIVE_VERIFICATION_v0.74.0.md) (**the migrated driver's run-unique uploads and its Frames-picker confirm, plus the `credits` remediation, verified 2026-09-14 on `ffroliva` at ZERO cost — every arm is a read or an upload-and-stop.** The confirm branch is the interesting one: this cohort's picker closes on its own, so the branch would never execute against live Flow by accident, and the e2e drives `FRAME_COMMIT_GRACE_S` to **0.001** so the grace expires before Flow can auto-close and **every** run enters it — the difference between a named blocker and an unrun claim. A *genuinely stuck* picker still cannot be produced here and stays `Refs #792`, verifier the reporter. #795 was verified on the cohort it is for, because this account is now in it — `auth status` green, `credits user` exit **3** with `detail` `credits endpoint returned 401` and `route: credits`, where v0.73.2 printed the class-default *"SAPISID cookie missing… re-run `gflow auth login`"*, advice that is wrong in every word there and that can roll a profile's strategy marker back and start the #791 spiral; the `credits list --json` envelope carries the per-profile `remediation_hint` that used to be dropped. **Two items recorded as NOT verified rather than omitted:** #799's exit-25 raise site needs an account in the agent-only cohort — the maintainer's accounts are served `flow.google.com` but still have the recoverable agent chip, so the state is structurally unreachable on them, a named blocker and #799 stays open; and neither `r2v` nor `i2i` had its own attach path driven to completion — `r2v` was not attempted (its e2e spends Veo credits), while `i2i` **was** attempted twice and failed both times at exit 23, before reaching the code under test, because the `--project` UUID came from `gflow project list` (the aisandbox catalog) and those ids 404 on `flow.google.com` — a harness error, recorded rather than dropped, which left a side finding: gflow reports Flow's own `/404?reason=project` as `UiSelectorDriftError`, blaming our selectors for Flow's 404. **#791 is not fixed and was not attempted blind** — all nine local profiles were probed at $0 and none is in the #791 state (SAPISID + a live `flow.google.com` session + no labs NextAuth token); four print its exact symptom string and none of them is it, because that string is also what a stale profile says.) · previous: [LIVE_VERIFICATION_v0.73.2](LIVE_VERIFICATION_v0.73.2.md) (**the blank incident bundle, reproduced and then cured by a committed e2e test, 2026-09-12 on `ffroliva` at ZERO cost — every arm fails before submit.** The decisive layer is an **A/B run through the same instrument**, not an inspection: on `develop` the new test FAILS in 46.3 s and prints the bundle it got — `tag_counts.div = 0`, `title.length = 0`, `host_category = "other"`, a 4,254-byte screenshot — which is field-for-field the signature [#792](https://github.com/ffroliva/gflow-cli/issues/792) reported from a different account, a different OS and a different command; on the fix it PASSES in 37.8 s with `div` **25**, `title.length` **81**, `host_category` **`flow_app`**, `route` `/404` and a 38.4 KB screenshot. Both arms exit **23** on the same `.settings-trigger-button` drift at `flow.google.com/404?reason=project`, so the arms differ only in what was *captured*, never in what failed. **The reporter's own bundle contained the refutation of their diagnosis**: viewport 1280x720 is `client.py`'s client-owned page, not the 1920x1080 a stray transport tab would have — it was always the driven page, photographed one navigation too late, and the portrait correlation had no mechanism behind it. The existing quality benchmark could not have caught this: it captures deliberately on a **healthy** page, so it graded only bundles that were never at risk. **Recorded as NOT verified rather than omitted:** #796's `PROFILE_MARKER_MISSING` is offline-only because its live trigger is a cookie-store **decryption** failure, which does not occur on Windows/DPAPI — it is the macOS Keychain path of [#768](https://github.com/ffroliva/gflow-cli/issues/768), a **named external blocker**, and the issue stays open; a synthetic attempt to force it failed informatively, since a corrupt store raises `sqlite3.DatabaseError`, bypasses the marker gate and still prints "check network connectivity" — the same misdirection from a different trigger, untouched here. #795's remediation string is unit-asserted only, and the *other* half of #795 — `credits` being labs-only on migrated accounts — is **not fixed**.) · previous: [LIVE_VERIFICATION_v0.73.1](LIVE_VERIFICATION_v0.73.1.md) (**Google's `glue` consent bar covering the migrated composer, measured and then cured, 2026-09-11 on `ci-probe` for one image generation and no Veo credits.** The premise was nearly rejected: both prior sightings of that selector in this repo are labs.google, and the 2026-09-10 spike had read 159 DOM samples on this exact host and project and watched the click land 3/3 — it simply never looked for a cookie bar. Seventeen hours later the bar was up: `position: fixed`, `z-index: 1000`, spanning y 547-655 of a 655 px viewport, with `elementFromPoint` over the settings trigger AND over the image submit both returning `span#glue-cookie-notification-bar-1-label` in **0 of 5** rendered samples hit-testable. **The control arm is what makes it a cure rather than a coincidence**: the same probe re-run through the driver's own `_dismiss_cookie_bar` restored both controls to **4/4**, one attempt, no error. A full `gflow image t2i` then ran end to end on the merged code — `status: ok`, a 807,761-byte `ffd8ffe0` JPEG that Pillow reads as 1376x768 RGB, matching the requested 16:9 and the reply's own dimensions, with `migrated.editor_ready` at 2.0 s and **no** `cookie_bar_not_dismissed`. A side finding the control arm produced by accident: the bar's buttons are `__accept` then `__reject` in DOM order, so anything taking "the first button" accepts cookies for the operator — the shipped fix anchors on reject. **Three items recorded as NOT verified rather than omitted:** the cure against a live bar outside the browser, because this release's own control arm consumed the consent on the only profile that had it; **prevalence**, because the second profile never reached an editor (both arms redirected to `/about`) so it is one account blocked and one already-consented, and KNOWN_ISSUES says so rather than generalising from n=1; and the video path live, which shares the identical function on the identical control but spends Veo credits.) · previous: [LIVE_VERIFICATION_v0.73.0](LIVE_VERIFICATION_v0.73.0.md) (**four error paths that stopped lying about what went wrong, verified 2026-09-10 on `ci-probe` and `denon82` at $0 — no run below reaches a Veo submit.** The decisive arm is an **A/B**, not an inspection: the pre-fix run printed an `accounts.google.com` challenge URL together with the OAuth `state` and `client_id`, and the identical command after the fix gave the same exit 38 and the same landing named with **five secret matches down to zero** — reading the post-fix output alone would only have shown that *these* secrets were absent. A second arm **falsified a claim already written into PR #775's body**: it said the change fixed the RED canary, and the live run showed `denon82` had moved to `accounts.google.com` mid-run, a landing the classifier excluded **by design**, with a unit test encoding the wrong reason — the offline suite was green throughout and could not have caught it. #776's happy path is verified by a real `gflow image t2i` (exit 0, 367 135-byte JPEG, `image_settings_applied` and `prompt_typed` both present, so **three of the four converted click sites** clicked successfully through the new helper); its **failure** path is real Chromium driving a page we wrote, which proves the mechanism and explicitly **not** that Flow still produces that state. **Three items recorded as NOT verified rather than omitted:** the `/about` redirect stopped reproducing (**0/5**, reported as *unmeasured* rather than argued into a retry flag), the `/fx/api/auth/*` landing moved before the run reached it, and #764's *success* path — an external contribution whose error boundary this release verifies but whose auto-selection needs an account currently behind a Google password challenge. The fourth converted click site is the **video** submit, unrun because it spends Veo credits, named rather than implied.) · previous: [LIVE_VERIFICATION_v0.72.0](LIVE_VERIFICATION_v0.72.0.md) (**auto-closing `gflow auth login`, verified 2026-09-09 on a migrated account at $0 across six runs.** Four paths: auto-close fires (`probe=in_context` at `elapsed_s` **84.8**, reproduced 3x), a manual close still verifies from disk (exit 0, never exit 12), `--browser auto` selects and completes, and a window closed **mid-2FA** is noticed in **2 s** (exit 8) where it previously ran the full 600 s deadline with the session endpoint touched **zero** times. **Two runs were discarded rather than counted**, and that is the useful part: the first returned exit 0 at `elapsed_s: 0.2` because a throwaway profile had survived the previous session and nobody signed in — a cached session reading as a clean pass — and three attempts at the manual-close path all returned exit 0 while being the *auto-close* path in disguise, separable only by an **absent** `probe=in_context`. The manual path was obtained by widening the poll interval for one run so the competing branch could not fire; the 2FA path by killing the profile's own Chrome, because hand-timing lost every race on a host that completes passkey sign-in in ~15 s. **Recorded as NOT verified rather than omitted:** the migrated-host image slice (#692) was **re-run this cycle** — CLI `t2i` PASSED first-hand on a moved account, while the queued-MCP `i2i` twin failed with a typed non-retryable exit 27 whose **cause is unknown** — some dialog opened after the file was chosen and no `maseQ` left the page, on an account that has ALREADY consented, so the message's "most likely the one-time upload-terms dialog" does not fit; no incident bundle was written, so nothing names the dialog, and it is filed for a spike rather than guessed; the subprocess fallback is unit-tested only; and the OAuth-callback *mechanism* behind the poll fix is **inferred, not proven** — a host-only gate does not exclude NextAuth's callback, since it runs on the app's own origin, yet all four sign-ins passed under it, so #769 carries the spike that would settle it.) · previous: [LIVE_VERIFICATION_v0.71.1](LIVE_VERIFICATION_v0.71.1.md) (**two migrated-host error paths stopped blaming the wrong thing, 2026-09-08, $0 across three profiles.** The agent-mode recovery landed earlier in this same release (#749) and worked, but its first cut collapsed three outcomes into one message — chip clicked / chip found but click blocked / chip clicked and mode still on — so a modal eating the click sent users to toggle a chip that was never the problem, and genuine selector drift *after* the mode was left was filed under an account setting the driver had already changed. Verified by an e2e that drives the account INTO agent mode and carries its own neutered-selector control; run **twice**, because review fixes landed after the first run and it no longer covered the code. **The larger find:** an account's FIRST upload on this host is blocked by Flow's one-time *"Rights to use this image"* dialog, which renders *after* the file chooser hands the file over — so `_dismiss_dialog` never sees it and the driver spent 60 s waiting for a request the page had already declined to make, then advised re-encoding the image. Six runs on `ci-probe` close the chain: guard fires while the dialog is up → owner-authorised accept → uploads (`media_id 8914400f`) → fresh session uploads clean with `dialog: None`. The order was forced, because the dialog is one-off and accepting it destroys the failing state — so the guard was written and verified BEFORE anything was clicked, and its firing branch is now unrepeatable here. `maseQ` is confirmed **not** renamed (six uploads, three profiles), killing #719's top hypothesis. Also: Flow's queue listing is on **`Zzl0ze`**, not the `jwpduf`/`as29s` progress polls, and two `abra_r2v_8s` records sat at status 6 for ~20 h unseen by any CLI command (#741). Three documents are corrected rather than patched — a KNOWN_ISSUES entry that told #719's reader this dialog did not affect gflow-cli, and a v0.71.0 host label calling `ci-probe` *labs* when it is migrated, which helped a credit-based theory survive four runs. Recorded as NOT verified: #719's second failure shape — an upload request that leaves the page and is never answered, ~1 run in 4 — which is unfixed and keeps #719 open.) · previous: [LIVE_VERIFICATION_v0.71.0](LIVE_VERIFICATION_v0.71.0.md) (**`gflow character create --voice` verified end to end for the first time, 2026-09-07 on profile `ffroliva`.** Before this release a repo-wide grep for `--voice` across `tests/e2e/` matched **nothing** — every voice test was a unit test of the hardcoded `VOICES` constant and the one that looked live parsed a fixture, so a voice that silently failed to attach was invisible to the whole suite while the command exited 0. A live create + `character show` read-back settles a contradiction between two of our own documents: `sent='Charon' stored='Charon' identical=True`, so the Capitalized canonical form round-trips and `CHARACTER_RECON.md`'s "preset id is the lowercased name" does not describe today's wire. Both docs also now record that `personalityNotes` is **Agent-scoped** per Flow's own editor copy, not a control on the audio engine. **A claim that was in the code is overturned:** the `_unported_form` entity guard said the submit "never produces a reply" and that the backend refuses — in fact `MZZa6b` replies with a null payload and the generation is **accepted and queued** (Flow types it `abra_r2v_8s` and renders it); the failure is the *observer*, which waits out `SUBMIT_REPLY_BUDGET_S` (60 s, calibrated on 4.0–4.6 s replies against an idle queue) and exits 9 while the video is still rendering. The guard stays until the observer is fixed, because a timeout reported on a healthy generation is worse than an explicit refusal. Also verified: credit shortfall reports exit **37** with `_raise_if_out_of_credits` called from **both** give-up paths — and it is *short for the selected model*, not empty (the measured account held **50** and asked for `veo-quality`, which costs **100**); the "+ New project" CTA anchored structurally on `add` (Tier-1 matches **1** where every previous entry matched **0**, control 47 ligature nodes) after this same release first asserted the control did not exist and a $0 run disproved it in one click; and the incident-bundle DOM dump de-blinded — it queried `i.google-symbols` only, so **every bundle a migrated user sent carried an empty ligature list**, which is why #727 and #731 stayed invisible. One item recorded as NOT verified rather than omitted: whether a bound character's **voice reaches rendered audio** (#738) — attachment is proven, application is not, because no entity-bound generation has yet returned a file to measure. The blocker is no longer credits or account access but retrieval, and the measurement is pre-calibrated: plate-bound takes of one character gave **88 / 103 / 118 Hz** against a **4.3 Hz** engine noise floor, so an applied Algenib should sit near its sample's **163.3 Hz**.) · previous: [LIVE_VERIFICATION_v0.70.0](LIVE_VERIFICATION_v0.70.0.md) (**reference-to-video on the migrated `flow.google.com` host, verified 2026-09-06 on profile `denon82`.** Three r2v runs bound their references every time; the decisive one is semantic rather than structural — two hand-drawn reference images went in and the clip came back carrying the same marker strokes, face and paper texture, which is what separates "Flow bound the reference" from "Flow accepted and ignored it". A four-beat, two-character piece was then produced end to end and joined with the concat filter: 19.000s, 1280x576, `clip_qa` **ok**, A/V **+0.000s**, both actors recognisable across every cut from one face plate plus the canon repeated verbatim. Also verified as exit-code corrections on the real CLI: `-o ` now exits **2 in 0.8s** instead of exit 1 after ~2 min **and a billed, orphaned clip**; `character create` on a moved account exits **36 immediately** instead of a bare `RuntimeError` exit 1 after a 20s wait. Four items recorded as NOT verified rather than omitted: #692's original failure could not be reproduced [the reporter's own re-run was on a build without the fix, so it shows the failure is intermittent, not that the fix works]; Flow CHARACTER entities were never exercised because `character create` cannot run on any account available here; `image`/`scene`/`movie`/`extend` all exit 36 on the migrated host and no unmoved account exists here; and the sign-in-interstitial fidelity question from #699 has no bundle evidence either way.) · previous: [LIVE_VERIFICATION_v0.68.0](LIVE_VERIFICATION_v0.68.0.md) (`gflow update` self-upgrading a real `uv tool` and a real `pipx` install from their own running `gflow.exe` to the PyPI release, Windows 11, zero credits — see its own row below) · previous: [LIVE_VERIFICATION_v0.67.0](LIVE_VERIFICATION_v0.67.0.md) (the migrated `flow.google.com` driver — see its own row below) · previous: [LIVE_VERIFICATION_v0.64.0](LIVE_VERIFICATION_v0.64.0.md) (**#626 `--model omni-flash --end-frame` — first+last interpolation on Omni 1.1 Flash, verified 2026-09-02.** The guard this release removes was evidence-gated, so it was retired on evidence: a route-aborted submit capture on **two distinct Google accounts** (`ffroliva`, `denon82`) fired `video:batchAsyncGenerateVideoStartAndEndImage` with `startImage` AND `endImage` non-null at **zero credits**, ruling out a single-account artifact. The decisive layer is semantic, not structural: the paid 4s generation's **last frame IS the supplied end image** and its first frame is the start image — two visibly different subjects — which is the only check that distinguishes "Flow bound the end frame" from "Flow accepted and ignored it", and which layers 1–4 would pass either way. Render: 4.01s / 720×1280 / h264+aac / 2.4 MB, `MEDIA_GENERATION_STATUS_SUCCESSFUL`. The static capability table is **deleted**, not corrected — it went stale silently once and would again; a post-submit route check (`_assert_i2v_route`) now fails a run whose end frame Flow dropped back to the `StartImage` route, catching a rollback on any account without anyone re-reading a support page. One item is recorded as NOT verified rather than omitted: `--duration 10` + end frame is **submit-verified only** (`duration_set seconds=10` on the correct route with both images bound), because the status poll returned HTTP 401 mid-run and a retry 401'd at `project.create` before submitting — [#561](https://github.com/ffroliva/gflow-cli/issues/561), pre-existing, so verification was stopped rather than spending further credits on an environmental fault.) · previous: [LIVE_VERIFICATION_v0.63.0](LIVE_VERIFICATION_v0.63.0.md) (**`gflow video extend` — continuing a clip past Flow's 8s ceiling, verified 2026-09-01 on profile `ffroliva`, 20 Veo credits.** The decisive observation is that segment 2's `source_media_id` **is** segment 1's `media_id` (`0c9364f3…` seeded from `b9458021…`, then `648f9291…` seeded from `0c9364f3…`) — tail-only chaining, which no mock can establish; had it re-seeded from the source, the output would have been two divergent continuations of the same moment instead of one continuous shot. `extend_model_resolved` logged `candidate_count=99` against `SERVICE_TIER_INTERMEDIATE`, proving the key came from the live capability listing rather than a constant, and plan cost matched actual spend (`20 credits total, balance 875` → `Extended — 2/2 segment(s), 20 credits`). Render: 23.02s / 1280×720 / 24fps / h264+aac. **The run found a defect the offline suite is structurally incapable of seeing:** an extend segment carries **7.000000s** of content, not the 8 Flow advertises and bills, so server-side concat pads every internal seam with a frozen frame and digital silence (15s at −75.1 dB against −29 neighbours, reproduced on a second independent render) — filed in KNOWN_ISSUES with the three questions that must be answered before any clamp, and the reason `--extend N` on t2v/i2v was deliberately NOT shipped. Seven items are recorded as NOT verified rather than omitted: portrait `9:16`, `--aspect` against a mismatched source, chains longer than 2 segments, `--resume-from` against a live partial scene, the insufficient-credits refusal, a live Ctrl+C, and `OperationKind.EXTEND` rows.) · previous: [LIVE_VERIFICATION_v0.62.1](LIVE_VERIFICATION_v0.62.1.md) (**#604 `--model omni-flash` selects again after Flow's `Omni Flash` → `Omni 1.1 Flash` rename, verified 2026-08-30 on profile `ffroliva` at ZERO Veo credits.** The production `_select_video_model` was driven against the real picker: the shipped selector resolves verbatim — `model_selected model=omni_flash via="[role='menuitem']:has-text('Omni'):has-text('Flash'):not(:has-text('[Lower Priority]'))"` — and all six cases came back as expected: the four offered tiers SELECT, a tier Flow does not offer REFUSES, and a deliberately ambiguous selector REFUSES rather than resolving `.first`. Selection happens before submit, so the whole matrix is free. The transport's own refusal diagnostic read the live menu back as `Omni 1.1 Flash / Veo 3.1 - Lite / Fast / Quality`, independently corroborating the fixture. Four items are recorded as NOT verified rather than omitted: the rename *direction* [the `Omni Flash` baseline is a different account, locale and date], an actual `Omni ... [Lower Priority]` entry [Flow has never offered one], a full credit-spending generation on omni-flash, and #539's absence question, which stays open.) · previous: [LIVE_VERIFICATION_v0.62.0](LIVE_VERIFICATION_v0.62.0.md) (**#595 `auto` ≡ `classic` for images, verified 2026-08-28 on profile `ci-probe` — an account Flow had moved to the agentic cohort the day before — at ZERO Veo credits.** With no flags and no env vars the run logged `ui_driver.ui_mode.attempt_exit_agent` → `ui_driver.bound mode=classic ui_mode=classic` and generated a real 768x1376 JPEG (exit 0); the day before, the identical command on the same account bound `auto`→`agentic` and failed. The same log independently closed **#183**: `mode_switch_trigger` and `image_mode_tab`, the selectors it reported as unfindable, both matched. #591 was proven against the real 500-row catalog — **0** occurrences of the string `"None"`, 31 proper JSON `null`s, over a catalog holding 119 NULL-bearing rows. #592 shows exactly one `client.account_locale_cached` per command. Three items are recorded as NOT verified rather than omitted: the exit-28 abort on a *pinned* agentic account, the #597 batch inter-prompt guard, and any fresh announcement modal — the last two because all three accounts have already acked the current changelog, so no modal can be raised until Google ships the next one.) · previous: [LIVE_VERIFICATION_v0.61.0](LIVE_VERIFICATION_v0.61.0.md) (**#539 video-model refusal + #586 image-model refusal and server-side attribution, verified 2026-08-26/27 on profile `denon82`, ONE Veo generation total** — the video fix was proven by a **zero-credit A/B against the stashed pre-fix source running on live Flow**: the old code returned SELECTED for `veo-lite-lp`, a model Flow does not offer to this account, and for a deliberately ambiguous selector matching 3 entries; the new code refuses both with exit 18 naming what Flow offered. Model selection happens before submit, so refusals cost nothing and the A/B was free. The happy path still generates: `--model omni-flash` → exit 0, a real 2.2 MB `ftypisom` mp4, catalog recording `omni_flash`. Two items are recorded as NOT verified rather than omitted: three of #584's four navigation-settle sites were never exercised [no bearer refresh occurred in these runs], and #582's canary self-re-exec has its first real exercise tonight. Also falsified: #539's recorded note that the video picker uses a different trigger — the two constants are byte-identical strings, and the repeated empty menu captures were the capture's own fault.) · previous: [LIVE_VERIFICATION_v0.60.0](LIVE_VERIFICATION_v0.60.0.md) +**What was actually live-verified for the latest release?** → latest: [LIVE_VERIFICATION_v0.78.0](LIVE_VERIFICATION_v0.78.0.md) (**generating with no project on `flow.google.com` — and video from a start frame — verified 2026-09-17.** The release exists because labs.google's `project.createProject` is retired — 404 *"Flow RPCs have been deprecated and disabled"* with a labs token, 401 without, 0 successes in 12 across four profiles — and gflow reported the 401 as an expired login. Creation and rename are driven on flow.google.com and read back from Flow's own `jHPbke` / `o8DA4` replies; the CLI and the MCP twin are each exercised separately, and both video paths reach a real submit that is aborted before Flow can bill it. Two failures are recorded and re-tested rather than explained away — one MCP image reply that did not reproduce under a four-run A/B, and a Chrome launch starved by a concurrent coverage run that passed in 36 s alone. The 3:4 image aspect is re-measured on the live radiogroup, five radios where 2026-09-08 saw four. Three items are NOT verified and each names its blocker: no labs-served account, no entitled `/about` account, no non-`en` locale. **#860 is the one thing here that cost credits, deliberately:** the only proof that a start frame binds is a real generation, so ten i2v clips were generated and downloaded on a live migrated account — `migrated.frame_uploaded` → `frame_bound` → `submit_observed` → `download`, the middle event being the one that could not fire before this release. **#859 is recorded as UNOBSERVED, not verified** — its dismissal ran on every one of those runs and no promo modal ever appeared, so the $0 route-intercepted e2e is what covers it. `gflow docs` (#861) is verified from the built wheel, which is its whole contract: 127 topics, `docs/FILE.md:LINE` search hits, no network and no checkout.) · previous: [LIVE_VERIFICATION_v0.77.1](LIVE_VERIFICATION_v0.77.1.md) (**a four-fix patch release, verified 2026-09-17 at ZERO cost.** The headline is #846: the published package could not run a single interactive command on a clean Windows install, because `ConsoleRenderer(colors=True)` needs `colorama` and nothing declared it. Verified as a controlled A/B on a fresh Python 3.13 venv — `SystemError` before, exit 33 after — plus an arm with stderr piped that shows why the AUTO/JSON split hid this from every CI job, and the MCP twin run separately. #848's import probe is exercised against a **deliberately broken venv** and an intact one, including the contract that a probe which cannot run reports nothing rather than reporting breakage. #852 is re-measured on the release head: 40 000 base64-shaped chars fall from 12.07 s to 0.020 ms with identical output on Workspace and custom domains. **#849 is a NAMED BLOCKER, not an omission** — its end-to-end behaviour needs a fresh interactive Google sign-in, which no agent can perform; its live A/B is recorded on PR #851 and its e2e file re-ran 4/4 here. **Seven live e2e failures are recorded rather than hidden**, and controlled: the release changes exactly four source files and none of them is a failing test's module.) · previous: [LIVE_VERIFICATION_v0.77.0](LIVE_VERIFICATION_v0.77.0.md) (**the migrated-host login lockout (#791), verified as a controlled A/B on a real Google **Workspace** account at ZERO cost — same profile, same cookies untouched between arms, so the only variable is the code: exit 8 with the Chrome marker rolled back and `channel_for_profile` collapsed to `None`, then exit 0 with the marker intact and the account recorded. The refusal path is measured too — of 10 local profiles, 5 reproduce #791 and exactly one carries both required cookies: 4 refusals, 1 upgrade, 0 false positives. v0.76.0 recorded this fix as **withdrawn**; the approach that shipped is a different one, server-attested on the landing URL rather than on client-rendered DOM. An earlier revision gated auth on an `@gmail.com`-only pattern that silently declined every custom domain — the account used here is one, which is why this is its verification. **#796 is reproduced and prevented**, not merely reported: it was filed as *'not yet reproduced end-to-end'*. **Two rows are named blockers, not omissions:** the MCP Registry publish (#841) cannot be rehearsed before a real tag push, so this release is its first exercise; and the migrated-host generation e2e cannot run here at all — every account bounces to `/about`, A/B-controlled against `develop` so it is not mistaken for a regression.) · previous: [LIVE_VERIFICATION_v0.76.0](LIVE_VERIFICATION_v0.76.0.md) (**a diagnostics-and-distribution release, verified 2026-09-16 at ZERO cost — no Veo credits, no image generation, no Flow submit.** The decisive arm is #833's **negative** control, run first-hand on two real accounts rather than relayed: `flow-pinhole-unavailable-screen` is **0** on both `ffroliva` and `denon82` while `aisandbox-root` is **1**, so the guard does not fire on accounts that HAVE Flow access and the zero is a real absence rather than a page that never booted — a guard with only a positive arm is indistinguishable from one that is permanently on. Both accounts settled at **`/about`**, which is the state that broke this release's *withdrawn* #791 oracle, and the exit-39 signal reads a component that only exists on the unavailable screen, so `/about` cannot mimic it. mcpservers.org is verified with a **404 negative control** because the listing 403s to `curl` and a naive check would have called the link dead. **#791 is NOT in this release and was withdrawn rather than deferred**: a fix passed eight council dimensions and was then refuted by a five-day-old spike nobody had routed to it — Flow's `/about` hop is decided client-side with zero requests to Flow, and *"the backend grants access while the frontend declines to open it"* (#756), so the probe would have reported authenticated users as logged out, reintroducing #791 as its own cure; the e2e caught it by going red on a live account whose state moved mid-session. **Two rows are blocked by the platform rather than by effort:** the Docker sign-in needs a WSL-integrated Docker host, and the MCP Registry publish cannot be observed until this very tag reaches `main`, since both triggers resolve the workflow from the default branch.) · previous: [LIVE_VERIFICATION_v0.75.0](LIVE_VERIFICATION_v0.75.0.md) (**a distribution-and-hardening release, verified 2026-09-15 on `ffroliva` at ZERO cost — no Veo credits, no image generation, no Flow submit.** The `gflow serve` token fix is an **A/B against the released 0.74.0 wheel from PyPI**, not a diff read: the token was *set* in both arms, so the probe measures whether it is checked — before, `no token 200/200` with 13 tools listed and `wrong 200/200`; after, `401/401` with zero tools and `WWW-Authenticate: Bearer realm="gflow"`, while the correct token still gets 200. Loopback DNS-rebinding was re-confirmed at **421**/**403** because the fix forwards the bind host and getting that wrong would silently disable protection that works today — and an earlier draft of the accompanying advisory claimed that protection was *disabled*, a false claim caught by measuring rather than re-reading. #813's guard is verified against a **real wheel in a real venv installed without extras**, the arm first proving it is in that state (`av present: False PIL present: False`); the decisive row is that a **nonexistent** manifest still exits **20** on the dependency error rather than file-not-found, which is what shows the guard precedes the manifest read and not merely the generation. The Rich escape fix is verified on the **console render separated from the structlog JSON** — matching the JSON line would have been a false pass, since `--json` was never affected — with a **neutered control** through the same Rich version reproducing the bug exactly: `UNESCAPED -> pip install 'gflow-cli'` against `ESCAPED -> pip install 'gflow-cli[chain]'`. Also: `gflow-cli.exe` exists in the built wheel so `uvx gflow-cli` resolves at all, and the wheel carries 138 entries with **zero** ZIP duplicates. **Three items recorded as NOT verified rather than omitted:** a successful multi-link `chain` run spends Veo credits and stays outstanding e2e debt on #813 — what changed is the refusal path, which *is* verified; `gflow credits` against a working balance is impossible here because the account still returns 401, pre-existing **#795** and unrelated to this release; and the SSE arm of the token fix was exercised on loopback only, a deliberate scope limit, since binding a LAN address to test it would put an authenticated daemon on a live network for the run. **Two rows are blocked by the platform rather than by effort:** the PyPI metadata and the MCP Registry listing are both unobservable until this release is uploaded — PyPI renders the last artefact's metadata, and `mcp-publisher` reads its ownership token out of the *published* README.) · previous: [LIVE_VERIFICATION_v0.74.0](LIVE_VERIFICATION_v0.74.0.md) (**the migrated driver's run-unique uploads and its Frames-picker confirm, plus the `credits` remediation, verified 2026-09-14 on `ffroliva` at ZERO cost — every arm is a read or an upload-and-stop.** The confirm branch is the interesting one: this cohort's picker closes on its own, so the branch would never execute against live Flow by accident, and the e2e drives `FRAME_COMMIT_GRACE_S` to **0.001** so the grace expires before Flow can auto-close and **every** run enters it — the difference between a named blocker and an unrun claim. A *genuinely stuck* picker still cannot be produced here and stays `Refs #792`, verifier the reporter. #795 was verified on the cohort it is for, because this account is now in it — `auth status` green, `credits user` exit **3** with `detail` `credits endpoint returned 401` and `route: credits`, where v0.73.2 printed the class-default *"SAPISID cookie missing… re-run `gflow auth login`"*, advice that is wrong in every word there and that can roll a profile's strategy marker back and start the #791 spiral; the `credits list --json` envelope carries the per-profile `remediation_hint` that used to be dropped. **Two items recorded as NOT verified rather than omitted:** #799's exit-25 raise site needs an account in the agent-only cohort — the maintainer's accounts are served `flow.google.com` but still have the recoverable agent chip, so the state is structurally unreachable on them, a named blocker and #799 stays open; and neither `r2v` nor `i2i` had its own attach path driven to completion — `r2v` was not attempted (its e2e spends Veo credits), while `i2i` **was** attempted twice and failed both times at exit 23, before reaching the code under test, because the `--project` UUID came from `gflow project list` (the aisandbox catalog) and those ids 404 on `flow.google.com` — a harness error, recorded rather than dropped, which left a side finding: gflow reports Flow's own `/404?reason=project` as `UiSelectorDriftError`, blaming our selectors for Flow's 404. **#791 is not fixed and was not attempted blind** — all nine local profiles were probed at $0 and none is in the #791 state (SAPISID + a live `flow.google.com` session + no labs NextAuth token); four print its exact symptom string and none of them is it, because that string is also what a stale profile says.) · previous: [LIVE_VERIFICATION_v0.73.2](LIVE_VERIFICATION_v0.73.2.md) (**the blank incident bundle, reproduced and then cured by a committed e2e test, 2026-09-12 on `ffroliva` at ZERO cost — every arm fails before submit.** The decisive layer is an **A/B run through the same instrument**, not an inspection: on `develop` the new test FAILS in 46.3 s and prints the bundle it got — `tag_counts.div = 0`, `title.length = 0`, `host_category = "other"`, a 4,254-byte screenshot — which is field-for-field the signature [#792](https://github.com/ffroliva/gflow-cli/issues/792) reported from a different account, a different OS and a different command; on the fix it PASSES in 37.8 s with `div` **25**, `title.length` **81**, `host_category` **`flow_app`**, `route` `/404` and a 38.4 KB screenshot. Both arms exit **23** on the same `.settings-trigger-button` drift at `flow.google.com/404?reason=project`, so the arms differ only in what was *captured*, never in what failed. **The reporter's own bundle contained the refutation of their diagnosis**: viewport 1280x720 is `client.py`'s client-owned page, not the 1920x1080 a stray transport tab would have — it was always the driven page, photographed one navigation too late, and the portrait correlation had no mechanism behind it. The existing quality benchmark could not have caught this: it captures deliberately on a **healthy** page, so it graded only bundles that were never at risk. **Recorded as NOT verified rather than omitted:** #796's `PROFILE_MARKER_MISSING` is offline-only because its live trigger is a cookie-store **decryption** failure, which does not occur on Windows/DPAPI — it is the macOS Keychain path of [#768](https://github.com/ffroliva/gflow-cli/issues/768), a **named external blocker**, and the issue stays open; a synthetic attempt to force it failed informatively, since a corrupt store raises `sqlite3.DatabaseError`, bypasses the marker gate and still prints "check network connectivity" — the same misdirection from a different trigger, untouched here. #795's remediation string is unit-asserted only, and the *other* half of #795 — `credits` being labs-only on migrated accounts — is **not fixed**.) · previous: [LIVE_VERIFICATION_v0.73.1](LIVE_VERIFICATION_v0.73.1.md) (**Google's `glue` consent bar covering the migrated composer, measured and then cured, 2026-09-11 on `ci-probe` for one image generation and no Veo credits.** The premise was nearly rejected: both prior sightings of that selector in this repo are labs.google, and the 2026-09-10 spike had read 159 DOM samples on this exact host and project and watched the click land 3/3 — it simply never looked for a cookie bar. Seventeen hours later the bar was up: `position: fixed`, `z-index: 1000`, spanning y 547-655 of a 655 px viewport, with `elementFromPoint` over the settings trigger AND over the image submit both returning `span#glue-cookie-notification-bar-1-label` in **0 of 5** rendered samples hit-testable. **The control arm is what makes it a cure rather than a coincidence**: the same probe re-run through the driver's own `_dismiss_cookie_bar` restored both controls to **4/4**, one attempt, no error. A full `gflow image t2i` then ran end to end on the merged code — `status: ok`, a 807,761-byte `ffd8ffe0` JPEG that Pillow reads as 1376x768 RGB, matching the requested 16:9 and the reply's own dimensions, with `migrated.editor_ready` at 2.0 s and **no** `cookie_bar_not_dismissed`. A side finding the control arm produced by accident: the bar's buttons are `__accept` then `__reject` in DOM order, so anything taking "the first button" accepts cookies for the operator — the shipped fix anchors on reject. **Three items recorded as NOT verified rather than omitted:** the cure against a live bar outside the browser, because this release's own control arm consumed the consent on the only profile that had it; **prevalence**, because the second profile never reached an editor (both arms redirected to `/about`) so it is one account blocked and one already-consented, and KNOWN_ISSUES says so rather than generalising from n=1; and the video path live, which shares the identical function on the identical control but spends Veo credits.) · previous: [LIVE_VERIFICATION_v0.73.0](LIVE_VERIFICATION_v0.73.0.md) (**four error paths that stopped lying about what went wrong, verified 2026-09-10 on `ci-probe` and `denon82` at $0 — no run below reaches a Veo submit.** The decisive arm is an **A/B**, not an inspection: the pre-fix run printed an `accounts.google.com` challenge URL together with the OAuth `state` and `client_id`, and the identical command after the fix gave the same exit 38 and the same landing named with **five secret matches down to zero** — reading the post-fix output alone would only have shown that *these* secrets were absent. A second arm **falsified a claim already written into PR #775's body**: it said the change fixed the RED canary, and the live run showed `denon82` had moved to `accounts.google.com` mid-run, a landing the classifier excluded **by design**, with a unit test encoding the wrong reason — the offline suite was green throughout and could not have caught it. #776's happy path is verified by a real `gflow image t2i` (exit 0, 367 135-byte JPEG, `image_settings_applied` and `prompt_typed` both present, so **three of the four converted click sites** clicked successfully through the new helper); its **failure** path is real Chromium driving a page we wrote, which proves the mechanism and explicitly **not** that Flow still produces that state. **Three items recorded as NOT verified rather than omitted:** the `/about` redirect stopped reproducing (**0/5**, reported as *unmeasured* rather than argued into a retry flag), the `/fx/api/auth/*` landing moved before the run reached it, and #764's *success* path — an external contribution whose error boundary this release verifies but whose auto-selection needs an account currently behind a Google password challenge. The fourth converted click site is the **video** submit, unrun because it spends Veo credits, named rather than implied.) · previous: [LIVE_VERIFICATION_v0.72.0](LIVE_VERIFICATION_v0.72.0.md) (**auto-closing `gflow auth login`, verified 2026-09-09 on a migrated account at $0 across six runs.** Four paths: auto-close fires (`probe=in_context` at `elapsed_s` **84.8**, reproduced 3x), a manual close still verifies from disk (exit 0, never exit 12), `--browser auto` selects and completes, and a window closed **mid-2FA** is noticed in **2 s** (exit 8) where it previously ran the full 600 s deadline with the session endpoint touched **zero** times. **Two runs were discarded rather than counted**, and that is the useful part: the first returned exit 0 at `elapsed_s: 0.2` because a throwaway profile had survived the previous session and nobody signed in — a cached session reading as a clean pass — and three attempts at the manual-close path all returned exit 0 while being the *auto-close* path in disguise, separable only by an **absent** `probe=in_context`. The manual path was obtained by widening the poll interval for one run so the competing branch could not fire; the 2FA path by killing the profile's own Chrome, because hand-timing lost every race on a host that completes passkey sign-in in ~15 s. **Recorded as NOT verified rather than omitted:** the migrated-host image slice (#692) was **re-run this cycle** — CLI `t2i` PASSED first-hand on a moved account, while the queued-MCP `i2i` twin failed with a typed non-retryable exit 27 whose **cause is unknown** — some dialog opened after the file was chosen and no `maseQ` left the page, on an account that has ALREADY consented, so the message's "most likely the one-time upload-terms dialog" does not fit; no incident bundle was written, so nothing names the dialog, and it is filed for a spike rather than guessed; the subprocess fallback is unit-tested only; and the OAuth-callback *mechanism* behind the poll fix is **inferred, not proven** — a host-only gate does not exclude NextAuth's callback, since it runs on the app's own origin, yet all four sign-ins passed under it, so #769 carries the spike that would settle it.) · previous: [LIVE_VERIFICATION_v0.71.1](LIVE_VERIFICATION_v0.71.1.md) (**two migrated-host error paths stopped blaming the wrong thing, 2026-09-08, $0 across three profiles.** The agent-mode recovery landed earlier in this same release (#749) and worked, but its first cut collapsed three outcomes into one message — chip clicked / chip found but click blocked / chip clicked and mode still on — so a modal eating the click sent users to toggle a chip that was never the problem, and genuine selector drift *after* the mode was left was filed under an account setting the driver had already changed. Verified by an e2e that drives the account INTO agent mode and carries its own neutered-selector control; run **twice**, because review fixes landed after the first run and it no longer covered the code. **The larger find:** an account's FIRST upload on this host is blocked by Flow's one-time *"Rights to use this image"* dialog, which renders *after* the file chooser hands the file over — so `_dismiss_dialog` never sees it and the driver spent 60 s waiting for a request the page had already declined to make, then advised re-encoding the image. Six runs on `ci-probe` close the chain: guard fires while the dialog is up → owner-authorised accept → uploads (`media_id 8914400f`) → fresh session uploads clean with `dialog: None`. The order was forced, because the dialog is one-off and accepting it destroys the failing state — so the guard was written and verified BEFORE anything was clicked, and its firing branch is now unrepeatable here. `maseQ` is confirmed **not** renamed (six uploads, three profiles), killing #719's top hypothesis. Also: Flow's queue listing is on **`Zzl0ze`**, not the `jwpduf`/`as29s` progress polls, and two `abra_r2v_8s` records sat at status 6 for ~20 h unseen by any CLI command (#741). Three documents are corrected rather than patched — a KNOWN_ISSUES entry that told #719's reader this dialog did not affect gflow-cli, and a v0.71.0 host label calling `ci-probe` *labs* when it is migrated, which helped a credit-based theory survive four runs. Recorded as NOT verified: #719's second failure shape — an upload request that leaves the page and is never answered, ~1 run in 4 — which is unfixed and keeps #719 open.) · previous: [LIVE_VERIFICATION_v0.71.0](LIVE_VERIFICATION_v0.71.0.md) (**`gflow character create --voice` verified end to end for the first time, 2026-09-07 on profile `ffroliva`.** Before this release a repo-wide grep for `--voice` across `tests/e2e/` matched **nothing** — every voice test was a unit test of the hardcoded `VOICES` constant and the one that looked live parsed a fixture, so a voice that silently failed to attach was invisible to the whole suite while the command exited 0. A live create + `character show` read-back settles a contradiction between two of our own documents: `sent='Charon' stored='Charon' identical=True`, so the Capitalized canonical form round-trips and `CHARACTER_RECON.md`'s "preset id is the lowercased name" does not describe today's wire. Both docs also now record that `personalityNotes` is **Agent-scoped** per Flow's own editor copy, not a control on the audio engine. **A claim that was in the code is overturned:** the `_unported_form` entity guard said the submit "never produces a reply" and that the backend refuses — in fact `MZZa6b` replies with a null payload and the generation is **accepted and queued** (Flow types it `abra_r2v_8s` and renders it); the failure is the *observer*, which waits out `SUBMIT_REPLY_BUDGET_S` (60 s, calibrated on 4.0–4.6 s replies against an idle queue) and exits 9 while the video is still rendering. The guard stays until the observer is fixed, because a timeout reported on a healthy generation is worse than an explicit refusal. Also verified: credit shortfall reports exit **37** with `_raise_if_out_of_credits` called from **both** give-up paths — and it is *short for the selected model*, not empty (the measured account held **50** and asked for `veo-quality`, which costs **100**); the "+ New project" CTA anchored structurally on `add` (Tier-1 matches **1** where every previous entry matched **0**, control 47 ligature nodes) after this same release first asserted the control did not exist and a $0 run disproved it in one click; and the incident-bundle DOM dump de-blinded — it queried `i.google-symbols` only, so **every bundle a migrated user sent carried an empty ligature list**, which is why #727 and #731 stayed invisible. One item recorded as NOT verified rather than omitted: whether a bound character's **voice reaches rendered audio** (#738) — attachment is proven, application is not, because no entity-bound generation has yet returned a file to measure. The blocker is no longer credits or account access but retrieval, and the measurement is pre-calibrated: plate-bound takes of one character gave **88 / 103 / 118 Hz** against a **4.3 Hz** engine noise floor, so an applied Algenib should sit near its sample's **163.3 Hz**.) · previous: [LIVE_VERIFICATION_v0.70.0](LIVE_VERIFICATION_v0.70.0.md) (**reference-to-video on the migrated `flow.google.com` host, verified 2026-09-06 on profile `denon82`.** Three r2v runs bound their references every time; the decisive one is semantic rather than structural — two hand-drawn reference images went in and the clip came back carrying the same marker strokes, face and paper texture, which is what separates "Flow bound the reference" from "Flow accepted and ignored it". A four-beat, two-character piece was then produced end to end and joined with the concat filter: 19.000s, 1280x576, `clip_qa` **ok**, A/V **+0.000s**, both actors recognisable across every cut from one face plate plus the canon repeated verbatim. Also verified as exit-code corrections on the real CLI: `-o ` now exits **2 in 0.8s** instead of exit 1 after ~2 min **and a billed, orphaned clip**; `character create` on a moved account exits **36 immediately** instead of a bare `RuntimeError` exit 1 after a 20s wait. Four items recorded as NOT verified rather than omitted: #692's original failure could not be reproduced [the reporter's own re-run was on a build without the fix, so it shows the failure is intermittent, not that the fix works]; Flow CHARACTER entities were never exercised because `character create` cannot run on any account available here; `image`/`scene`/`movie`/`extend` all exit 36 on the migrated host and no unmoved account exists here; and the sign-in-interstitial fidelity question from #699 has no bundle evidence either way.) · previous: [LIVE_VERIFICATION_v0.68.0](LIVE_VERIFICATION_v0.68.0.md) (`gflow update` self-upgrading a real `uv tool` and a real `pipx` install from their own running `gflow.exe` to the PyPI release, Windows 11, zero credits — see its own row below) · previous: [LIVE_VERIFICATION_v0.67.0](LIVE_VERIFICATION_v0.67.0.md) (the migrated `flow.google.com` driver — see its own row below) · previous: [LIVE_VERIFICATION_v0.64.0](LIVE_VERIFICATION_v0.64.0.md) (**#626 `--model omni-flash --end-frame` — first+last interpolation on Omni 1.1 Flash, verified 2026-09-02.** The guard this release removes was evidence-gated, so it was retired on evidence: a route-aborted submit capture on **two distinct Google accounts** (`ffroliva`, `denon82`) fired `video:batchAsyncGenerateVideoStartAndEndImage` with `startImage` AND `endImage` non-null at **zero credits**, ruling out a single-account artifact. The decisive layer is semantic, not structural: the paid 4s generation's **last frame IS the supplied end image** and its first frame is the start image — two visibly different subjects — which is the only check that distinguishes "Flow bound the end frame" from "Flow accepted and ignored it", and which layers 1–4 would pass either way. Render: 4.01s / 720×1280 / h264+aac / 2.4 MB, `MEDIA_GENERATION_STATUS_SUCCESSFUL`. The static capability table is **deleted**, not corrected — it went stale silently once and would again; a post-submit route check (`_assert_i2v_route`) now fails a run whose end frame Flow dropped back to the `StartImage` route, catching a rollback on any account without anyone re-reading a support page. One item is recorded as NOT verified rather than omitted: `--duration 10` + end frame is **submit-verified only** (`duration_set seconds=10` on the correct route with both images bound), because the status poll returned HTTP 401 mid-run and a retry 401'd at `project.create` before submitting — [#561](https://github.com/ffroliva/gflow-cli/issues/561), pre-existing, so verification was stopped rather than spending further credits on an environmental fault.) · previous: [LIVE_VERIFICATION_v0.63.0](LIVE_VERIFICATION_v0.63.0.md) (**`gflow video extend` — continuing a clip past Flow's 8s ceiling, verified 2026-09-01 on profile `ffroliva`, 20 Veo credits.** The decisive observation is that segment 2's `source_media_id` **is** segment 1's `media_id` (`0c9364f3…` seeded from `b9458021…`, then `648f9291…` seeded from `0c9364f3…`) — tail-only chaining, which no mock can establish; had it re-seeded from the source, the output would have been two divergent continuations of the same moment instead of one continuous shot. `extend_model_resolved` logged `candidate_count=99` against `SERVICE_TIER_INTERMEDIATE`, proving the key came from the live capability listing rather than a constant, and plan cost matched actual spend (`20 credits total, balance 875` → `Extended — 2/2 segment(s), 20 credits`). Render: 23.02s / 1280×720 / 24fps / h264+aac. **The run found a defect the offline suite is structurally incapable of seeing:** an extend segment carries **7.000000s** of content, not the 8 Flow advertises and bills, so server-side concat pads every internal seam with a frozen frame and digital silence (15s at −75.1 dB against −29 neighbours, reproduced on a second independent render) — filed in KNOWN_ISSUES with the three questions that must be answered before any clamp, and the reason `--extend N` on t2v/i2v was deliberately NOT shipped. Seven items are recorded as NOT verified rather than omitted: portrait `9:16`, `--aspect` against a mismatched source, chains longer than 2 segments, `--resume-from` against a live partial scene, the insufficient-credits refusal, a live Ctrl+C, and `OperationKind.EXTEND` rows.) · previous: [LIVE_VERIFICATION_v0.62.1](LIVE_VERIFICATION_v0.62.1.md) (**#604 `--model omni-flash` selects again after Flow's `Omni Flash` → `Omni 1.1 Flash` rename, verified 2026-08-30 on profile `ffroliva` at ZERO Veo credits.** The production `_select_video_model` was driven against the real picker: the shipped selector resolves verbatim — `model_selected model=omni_flash via="[role='menuitem']:has-text('Omni'):has-text('Flash'):not(:has-text('[Lower Priority]'))"` — and all six cases came back as expected: the four offered tiers SELECT, a tier Flow does not offer REFUSES, and a deliberately ambiguous selector REFUSES rather than resolving `.first`. Selection happens before submit, so the whole matrix is free. The transport's own refusal diagnostic read the live menu back as `Omni 1.1 Flash / Veo 3.1 - Lite / Fast / Quality`, independently corroborating the fixture. Four items are recorded as NOT verified rather than omitted: the rename *direction* [the `Omni Flash` baseline is a different account, locale and date], an actual `Omni ... [Lower Priority]` entry [Flow has never offered one], a full credit-spending generation on omni-flash, and #539's absence question, which stays open.) · previous: [LIVE_VERIFICATION_v0.62.0](LIVE_VERIFICATION_v0.62.0.md) (**#595 `auto` ≡ `classic` for images, verified 2026-08-28 on profile `ci-probe` — an account Flow had moved to the agentic cohort the day before — at ZERO Veo credits.** With no flags and no env vars the run logged `ui_driver.ui_mode.attempt_exit_agent` → `ui_driver.bound mode=classic ui_mode=classic` and generated a real 768x1376 JPEG (exit 0); the day before, the identical command on the same account bound `auto`→`agentic` and failed. The same log independently closed **#183**: `mode_switch_trigger` and `image_mode_tab`, the selectors it reported as unfindable, both matched. #591 was proven against the real 500-row catalog — **0** occurrences of the string `"None"`, 31 proper JSON `null`s, over a catalog holding 119 NULL-bearing rows. #592 shows exactly one `client.account_locale_cached` per command. Three items are recorded as NOT verified rather than omitted: the exit-28 abort on a *pinned* agentic account, the #597 batch inter-prompt guard, and any fresh announcement modal — the last two because all three accounts have already acked the current changelog, so no modal can be raised until Google ships the next one.) · previous: [LIVE_VERIFICATION_v0.61.0](LIVE_VERIFICATION_v0.61.0.md) (**#539 video-model refusal + #586 image-model refusal and server-side attribution, verified 2026-08-26/27 on profile `denon82`, ONE Veo generation total** — the video fix was proven by a **zero-credit A/B against the stashed pre-fix source running on live Flow**: the old code returned SELECTED for `veo-lite-lp`, a model Flow does not offer to this account, and for a deliberately ambiguous selector matching 3 entries; the new code refuses both with exit 18 naming what Flow offered. Model selection happens before submit, so refusals cost nothing and the A/B was free. The happy path still generates: `--model omni-flash` → exit 0, a real 2.2 MB `ftypisom` mp4, catalog recording `omni_flash`. Two items are recorded as NOT verified rather than omitted: three of #584's four navigation-settle sites were never exercised [no bearer refresh occurred in these runs], and #582's canary self-re-exec has its first real exercise tonight. Also falsified: #539's recorded note that the video picker uses a different trigger — the two constants are byte-identical strings, and the repeated empty menu captures were the capture's own fault.) · previous: [LIVE_VERIFICATION_v0.60.0](LIVE_VERIFICATION_v0.60.0.md) **"Was the playwright upper bound raised, and on what evidence?"** → [LIVE_VERIFICATION_playwright_1.61](LIVE_VERIFICATION_playwright_1.61.md) (**`>=1.59.0,<1.60.0` → `>=1.61.0,<1.62.0` verified 2026-08-05, 1 Imagen + 1 Veo credit** — the 2026-08-03 regression that motivated the bound does NOT reproduce on 1.61.0: a live `i2v` drove the full chain with `image_uploaded status=200` → `frame_attached` → `generate_captured status=200` with `startImage` parsed, where **1.62.0 hung silently at exactly that upload step**; live `i2i` local-ref attach passed outright [96.7s]. **1.62.0 stays excluded** — never root-caused. Also found, A/B-proven pre-existing on BOTH 1.59.0 and 1.61.0: Flow dropped the duration-tab UI, so **`--duration` is currently broken for i2v** — `UiSelectorDriftError` fires correctly [refusing rather than silently accepting Flow's default, #288] but the selector needs re-deriving; that is why the i2v proof was driven via the CLI without `--duration`, and Flow's own server-side `PUBLIC_ERROR_VIDEO_GENERATION_TIMED_OUT` means no finished mp4 is claimed). **"Where is the reverse-engineered wire protocol for a feature?"** → the `*_RECON.md` design docs: [CHARACTER_RECON](CHARACTER_RECON.md) (Flow character entity protocol), [IMAGE_UPSCALE_RECON](IMAGE_UPSCALE_RECON.md) (`/v1/flow/upsampleImage` wire). Naming convention: one `_RECON.md` per reverse-engineered surface, kept as the durable spec after the feature ships. Pre-capture research recons: [ASSET_TAGGING_RECON](ASSET_TAGGING_RECON.md) (`@`-mention asset tagging — shipped in v0.40.0, see [LIVE_VERIFICATION_v0.40.0](LIVE_VERIFICATION_v0.40.0.md)). **"What was live-verified for v0.66.1?"** → [LIVE_VERIFICATION_v0.66.1](LIVE_VERIFICATION_v0.66.1.md) — **the two migrated-origin fixes, verified 2026-09-03 on profile `ffroliva` at ZERO credits, on BOTH sides of what was then read as a flapping rollout** (superseded 2026-09-04: the handoff is a one-way per-account flag and exit 36 is non-retryable since). On a real `flow.google.com` load `get_ui_driver` raised `FlowHostMigratedError` in **0 ms** (was ~36 s: ~8 s detect window + ~24 s crop cascade + 4 s URL settle), exit 36, `retryable: true` (then; `false` since 2026-09-04), `ui_driver.migrated_host_bail` logged; `await_url_settled` returned `null` in **0 ms** (measured 4018 ms before); and the locale was recovered as `en` from `html lang=en-GB` where the URL gave `null`. The no-regression half is the stronger evidence: minutes later the same command landed on the OLD host and completed **exit 0** with a real 768x1376 JPEG in 42.2 s, proving the host guard is scoped and does not short-circuit the working path. Three items recorded as NOT verified rather than omitted: driving the migrated frontend (still impossible, #639 stays open), the pt-BR recovery live (measured `html lang=pt` on `denon82` but exercised live only on `en-GB`), and the `en-GB`→`en` region reduction against a locale where region is load-bearing (`zh-Hans`/`zh-Hant`) — only two locales observed. diff --git a/docs/LIVE_VERIFICATION_v0.78.0.md b/docs/LIVE_VERIFICATION_v0.78.0.md new file mode 100644 index 000000000..7499593bf --- /dev/null +++ b/docs/LIVE_VERIFICATION_v0.78.0.md @@ -0,0 +1,177 @@ +# Live verification — v0.78.0 + +**Date:** 2026-09-17 · **Host:** Windows 11 Pro 10.0.26200 · **Flow host served:** `flow.google.com` +**Cost:** $0 — no Veo credits spent. Image runs used Flow's daily image quota; every video +submit was intercepted with `route.abort()` before it left the browser. + +Step 4b of [`/gflow:release`](../skills/release/SKILL.md). Every user-facing change in this +release is exercised against the real thing, or its blocker is named. Nothing is listed as +"unverified" that could have been verified — see AGENTS.md § The Iron Law. + +Profiles: `ci-probe` (served `flow.google.com`, app mounts, labs session token present) and +`ffroliva` (aspect enumeration). Issue: [#864](https://github.com/ffroliva/gflow-cli/issues/864), +closing [#561](https://github.com/ffroliva/gflow-cli/issues/561). + +--- + +## 1. Why this release exists — labs' project route is retired + +`scripts/dev/spike_create_project_401.py`, 3 attempts per profile, 4 profiles, through +`FlowApiClient.create_project` (the production path): + +| profile | labs session token | host served | `createProject` | +|---|---|---|---| +| `ci-probe` | yes | flow.google.com `/` | **404** ×3 | +| `denon82` | yes | flow.google.com `/about` | **404** ×3 | +| `flavio.oliva` | no | flow.google.com `/` | **401** ×3 | +| `promo-denon82` | no | flow.google.com `/about` | **401** ×3 | + +0 successes in 12. The 404 body is Google's own words: + +``` +Flow RPCs have been deprecated and disabled. Flow has migrated to https://flow.google.com. +``` + +The 401 is the same retired route reached by a session with no labs API token. gflow reported +it as `AuthExpiredError` — *"Run `gflow auth login`"* — which no login could fix. + +## 2. `gflow project create` + `gflow project rename` — VERIFIED + +`tests/e2e/test_project_create_e2e.py::test_cli_project_create_and_rename_land_on_flow`, +driving the real commands as a subprocess. + +| Layer | Evidence | +|---|---| +| Artifact | a new project on flow.google.com, id `57c099ee-…`-shaped UUID from `project create --json` | +| Field value | `project rename` → Flow's header `flow-editable-text` input read back **the new title** after a fresh navigation | +| Wire | create = rpc `jHPbke` → `[project_id, [title]]`; rename = rpc `o8DA4` → `[title]` | +| Structlog | `project.labs_route_refused status=404` → `migrated.project_created project_id=` | +| User-confirmable | the projects appear in Flow's grid, titled `gflow-e2e-864-*` | + +## 3. `gflow image t2i` with no `--project`, at `--aspect 3:4` — VERIFIED + +One run covers both halves of the release: creation, and the newly ported 3:4 radio. + +| Layer | Evidence | +|---|---| +| File count | 1 PNG written by the CLI | +| Magic bytes / size | > 10 000 bytes on disk, opened from `--json` `images[].local_path` | +| Dimensions | width/height within 0.02 of 3/4 — a real 3:4 image, not a silently substituted aspect | +| Structlog | `migrated.project_created` → `migrated.image_settings_applied` | +| Wire | `ogiZ0b` submit owned by the page, completed record parsed | + +## 4. MCP `gflow_generate_image` with no `project` — VERIFIED (separate surface) + +The MCP twin runs different code (tool → queue payload → `worker/codec.py` → daemon), so it +is verified on its own: `status: "completed"`, files on disk > 10 KB, project created on +flow.google.com in the same run. + +**One failure, re-tested rather than explained away.** The first run created the project and +then failed parsing the `ogiZ0b` reply (813 bytes, no completed record). It did not reproduce: +an A/B on the same profile — existing project 9:16, fresh project 9:16, existing project 1:1 +twice — all succeeded, and the same e2e passed on unchanged code. Recorded as an external +flake, not a known issue. + +## 5. Video with no project — VERIFIED credit-free, both surfaces + +`FlowApiClient.generate_video` (the path every CLI video command takes) and the MCP +`gflow_generate_video` twin, each with the submit intercepted by `route.abort()`: + +| Layer | Evidence | +|---|---| +| Structlog | `migrated.project_created` before any submit | +| Wire | a batchexecute submit rpc was attempted and aborted before leaving Chrome | +| Field value | MCP run titled its project from `project_name` — `gflow-e2e-864-mcp-video-*` in Flow | +| User-confirmable | Flow's own page shows *"Failed — You have not been charged for this generation"* for the aborted submit | + +A billed end-to-end clip was **not** run: the contract under test is which project the run +lands in and that the submit is reached, both readable before Flow acts. Stated rather than +implied. + +## 6. Aspect radios — MEASURED + +`scripts/dev/spike_migrated_aspect_radios.py --profile ffroliva`, settings pane only: + +- **Image:** `crop_16_9` 16:9 · `crop_landscape` 4:3 · `crop_square` 1:1 · **`crop_portrait` 3:4** · `crop_9_16` 9:16 +- **Video:** `crop_16_9` 16:9 · `crop_9_16` 9:16 (plus 360p/720p, 4/6/8/10 s, x1–x4) + +The 2026-09-08 enumeration had four image radios, which is why 3:4 was refused with exit 36. +All five are driven now. Video offers exactly the two aspects gflow already supported. + +## 7. MCP profile contention — VERIFIED offline, not live + +Measured through the real `gflow mcp run` entry point: with the variable unset, the loaded +setting was `0.0` while the environment said `180` — the defect this release fixes. After the +fix the loaded setting is `180`, and a value set in the environment or a `.env` still wins. +Serialisation of two concurrent calls on one profile is covered by +`tests/mcp/test_mcp_profile_contention.py`. + +**Not live-verified:** driving two MCP clients against one profile simultaneously needs two +real clients and a second profile holder; the behaviour is contention timing, not a Flow +surface. Named here rather than left blank. + +## 8. `video i2v --initial-frame ` on the migrated host — VERIFIED + +The defect this release fixes ([#860](https://github.com/ffroliva/gflow-cli/issues/860)) +made every i2v run on a `flow.google.com` account exit 32 *before* submit: the Frames +picker renders a `mat-icon` ligature next to the file name, a locator reads both nodes as +one string, and the anchored match could never hold. Verified by running the real command +against live Flow, not by replaying the fix's own e2e. + +Project `cg-s025-bracket-sage` (`339f65ee-…`), profile `ffroliva`, model `omni-flash`, +`--duration 10`, one clip per scene of a real story. + +| Layer | Evidence | +|---|---| +| File count | **ten mp4 files** written by the CLI, one per scene, none orphaned | +| Magic bytes / shape | every clip `h264 + 720x1280`, read back with `ffprobe` | +| Field value | duration **10.005 s** — `--duration 10` was honoured, not silently dropped | +| Structlog | `migrated.frame_uploaded` → **`migrated.frame_bound`** → `migrated.submit_observed` → `migrated.download`, **ten/ten runs**. `frame_bound` is the event that could not fire before this release | +| User-confirmable | the clips play, and each one animates the approved still it was given | + +**The negative is the point.** The identical command on v0.77.1 exits 32 with *"the frame +picker lists no asset named 's1-cbbc098b.jpg'"* while its own diagnostic lists +`'images1-cbbc098b.jpg'`. That is what blocked the story this release unblocks. + +## 9. Flow's promo modal — NOT OBSERVED this cycle, stated rather than implied + +[#859](https://github.com/ffroliva/gflow-cli/issues/859) moves the dismissal ahead of the +first click. The dismissal code path ran on **every** run in section 8, but **no promo +modal appeared on any of them**, so no dismissal was observed and nothing here proves the +ordering fix. The modal is served on a freshly loaded editor at Flow's discretion; it +cannot be summoned on demand. + +It is covered instead by the $0 route-intercepted e2e shipped with the fix +(`tests/features/blocking_dialog_dismissal.feature`), which mounts the modal 250 ms after +load and places the trigger beneath the backdrop, so the *backdrop* is the occluder — the +arrangement in which a mocked dialog passes against the bug. Recorded as unobserved rather +than folded into section 8's green, which is what the ledger is for. + +## 10. `gflow docs` — VERIFIED offline, which is the whole contract + +[#861](https://github.com/ffroliva/gflow-cli/issues/861) is a read-only, no-network, +no-account command, so "live" here means *from the built artefact, with no checkout*. + +| Layer | Evidence | +|---|---| +| Artifact | `gflow docs` lists **127 topics** from the pages shipped inside the wheel | +| Field value | `gflow docs --search "end-frame interpolation"` → 3 matches, each as `docs/FILE.md:LINE` **with the line**, not a file name | +| Field value | `gflow docs configuration` prints the page as raw Markdown (it pipes) | +| Structlog | an unknown topic raises `ConfigurationError` (exit 11) carrying a **docs** remediation hint, not the transport-registry default | +| User-confirmable | `gflow docs usge` → *"no documentation topic named 'usge' — did you mean usage?"* | +| Wheel | `tests/integration/test_docs_ships_in_wheel.py` builds a wheel, installs it into a scratch venv with no checkout, and runs the command there | + +## 11. Not verified, with reasons + +- **An account served `labs.google`.** **Blocker: no account.** All four profiles here are + served flow.google.com (`labs.google/fx/tools/flow` answers HTTP 308 on every one), so no + profile exists that could exercise the labs arm. It is unchanged and keeps its own error + path. Anyone holding such an account can settle it — see [#639](https://github.com/ffroliva/gflow-cli/issues/639). +- **An `/about`-served account (`denon82`).** **Blocker: the account has no Flow access** — + its app never mounts, which is [#756](https://github.com/ffroliva/gflow-cli/issues/756), not + something this release can drive. Project creation there is expected to fail with the + existing landing diagnosis. +- **A non-`en` locale.** **Blocker: no account** in a non-`en` locale is available here. The + anchors are structural by construction (custom element + `mat-icon` ligature, no display + text), which is what the locale-invariance rule in AGENTS.md requires; tracked with the rest + of the migrated-host matrix in [#639](https://github.com/ffroliva/gflow-cli/issues/639). diff --git a/docs/MCP.md b/docs/MCP.md index 227c9b80c..e71020807 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -83,8 +83,8 @@ The server registers three protocol surfaces: ### Tools (Executable actions) * `gflow_generate_image(prompt, model, aspect, count, seed, reference_images, reference_entities, reference_entity_names, tools, profile, project, project_name, instructions, ui_mode, output, wait)`: Triggers text-to-image / image-to-image (Imagen / Nano Banana). `instructions` is an optional list of ephemeral agent-instruction strings (agentic cohort only). `reference_images` switches to i2i and accepts **either a local file path or a generated image's Flow media UUID**. A UUID reference is attached by **selecting the already-existing asset in Flow's reference picker — no duplicate copy is uploaded** (locating the tile by the media id in its thumbnail URL, and searching the recorded display name to surface it when needed); gflow falls back to uploading the asset's on-disk local file only when it can't be located in place (e.g. it lives in a different project's picker). `project` generates into an existing Flow project id (mirrors CLI `--project`) — pass the reference's project to keep it selectable in place. `ui_mode` selects the Flow UI arm (`auto`/`classic`/`agentic`, mirroring CLI `--ui-mode`, matched case-insensitively). Since [#595](https://github.com/ffroliva/gflow-cli/issues/595) `auto` means "no arm was asked for" and **resolves to `classic`** — the arm that can satisfy an image request — so an account in Flow's agentic cohort aborts pre-submit (exit-28 equivalent envelope, zero credits) instead of failing mid-run with selector drift or video bytes; the agentic arm is bound only when named. Passing `instructions` forces `agentic` automatically, so `ui_mode="classic"` + `instructions` is a hard conflict rather than a silent drop. An unknown value returns a 400 problem-details envelope. See [CONFIGURATION § GFLOW_CLI_UI_MODE](CONFIGURATION.md#gflow_cli_ui_mode). - On migrated `flow.google.com` accounts, T2I and local-file I2I are supported with Nano Banana 2 / Pro, the four measured aspects (16:9, 4:3, 1:1, 9:16) and count 1–4; the page owns reCAPTCHA and the `ogiZ0b` submit. UUID/entity references, instructions and Imagen 4 remain pre-submit refusals there. The queued (`wait=false`) and blocking (`wait=true`) calls use the same typed payload and worker path. -* `gflow_generate_video(prompt, mode, aspect, initial_frame, end_frame, reference_images, reference_entities, reference_entity_names, model, duration, count, tools, profile, project, project_name, ui_mode, output, wait)`: Triggers vertical or landscape video generation (Veo). `mode` is `t2v`/`i2v`/`r2v`; `model` (`veo_lite`/`veo_fast`/`veo_quality`/`omni_flash`, aliases accepted), `duration` (seconds — 4/6/8 for Veo 3.1 and 4/6/8/10 for omni_flash; 10 is omni_flash-only). **Whether Flow renders a duration control at all is account/cohort-dependent**: on an account that renders none, the job is accepted here and fails in the worker pre-submit (exit 23 equivalent on the labs driver, exit 11 equivalent on the migrated `flow.google.com` host — now the default t2v route, #650 — no credits spent either way) rather than being rejected up front — see [KNOWN_ISSUES](https://github.com/ffroliva/gflow-cli/blob/main/KNOWN_ISSUES.md) (#451/#288/#630), and `count` mirror the CLI `gflow video` flags — an omitted `model` lets the transport apply its i2v veo-lite default (issue #125), and every model — `omni_flash` included — accepts i2v with a start frame and with an end frame (wire-verified 2026-09-02, issue #626); `i2v` requires `initial_frame`, `r2v` requires `reference_images` **or** `reference_entities`; `project` generates into an existing Flow project id (mirrors CLI `--project`); on an account Google has moved to `flow.google.com` (`GFLOW_CLI_FLOW_HOST`, read from the server/daemon environment, not per call — see [CONFIGURATION § GFLOW_CLI_FLOW_HOST](CONFIGURATION.md#gflow_cli_flow_host)) `project` is **required** and omitting it returns the exit-11-equivalent envelope; there the ported modes are text-to-video; image-to-video with a **local** `initial_frame` and no `end_frame` (the file is uploaded through the editor and bound on the Start chip by display name — it stays in the Flow project like any upload, under a **run-unique** name: `hero.png` is listed as `hero-a1b2c3d4.png`, so a re-run cannot bind an earlier upload — [#792](https://github.com/ffroliva/gflow-cli/issues/792)); and reference-to-video with local `reference_images` (each file is uploaded the same way — run-unique name included — and attached as an `@` mention of *that* name in the prompt; `duration` there accepts only `8` and is pinned when omitted — Flow offers r2v at its base tier alone, and at 4 or 6 it silently drops the references and bills a text-to-video clip, so any other value returns the exit-11-equivalent envelope). A Flow media UUID as `initial_frame`, an `end_frame`, and `r2v` by `reference_entity_names` or `reference_entities` return the exit-36-equivalent envelope. `initial_frame`, `end_frame`, and `reference_images` each accept **either a local file path or the Flow image UUID of a generated asset** — pass a generated image's id straight in to chain image→video, and gflow attaches it for you. Since v0.58.0 (#529) the CLI and MCP surfaces are unified for i2v frames: the UUID keeps its identity and is enriched with the catalog's recorded display name plus an integrity-verified local fallback, so the transport prefers **selecting the exact asset in the project's media picker** (no duplicate upload) and re-uploads the recorded local file only when the tile is unreachable — and only if its byte count/SHA-256 still match. A UUID that isn't in your local asset catalog is rejected up front with a clear "Reference Not Found" error; a catalogued asset with neither a display name nor a verified local file gives a "Reference Not Usable" error (re-generate it or pass a local path). `r2v` UUID refs are resolved to the recorded local file for upload. `ui_mode` selects the Flow UI arm (#299 PR-A, mirroring CLI `--ui-mode`) and **applies to every mode of this tool, including `r2v`** — unlike the CLI, where `video r2v`/`chain` have no flag and follow the env-only path. Video generation has **only a classic driver**, so `auto` ≡ `classic`: both verify the classic editor pre-submit and abort before spending credits if it is unreachable. `ui_mode="agentic"` is rejected with a 400 problem-details envelope, because no agentic video driver exists yet. Values are matched case-insensitively. (MCP tools return envelopes, never process exit codes — the CLI equivalents of these aborts are exit 28 and exit 2 respectively.) See [CONFIGURATION § GFLOW_CLI_UI_MODE](CONFIGURATION.md#gflow_cli_ui_mode). + On migrated `flow.google.com` accounts, T2I and local-file I2I are supported with Nano Banana 2 / Pro, all five aspects (16:9, 4:3, 1:1, 3:4, 9:16) and count 1–4; the page owns reCAPTCHA and the `ogiZ0b` submit. UUID/entity references, instructions and Imagen 4 remain pre-submit refusals there. The queued (`wait=false`) and blocking (`wait=true`) calls use the same typed payload and worker path. +* `gflow_generate_video(prompt, mode, aspect, initial_frame, end_frame, reference_images, reference_entities, reference_entity_names, model, duration, count, tools, profile, project, project_name, ui_mode, output, wait)`: Triggers vertical or landscape video generation (Veo). `mode` is `t2v`/`i2v`/`r2v`; `model` (`veo_lite`/`veo_fast`/`veo_quality`/`omni_flash`, aliases accepted), `duration` (seconds — 4/6/8 for Veo 3.1 and 4/6/8/10 for omni_flash; 10 is omni_flash-only). **Whether Flow renders a duration control at all is account/cohort-dependent**: on an account that renders none, the job is accepted here and fails in the worker pre-submit (exit 23 equivalent on the labs driver, exit 11 equivalent on the migrated `flow.google.com` host — now the default t2v route, #650 — no credits spent either way) rather than being rejected up front — see [KNOWN_ISSUES](https://github.com/ffroliva/gflow-cli/blob/main/KNOWN_ISSUES.md) (#451/#288/#630), and `count` mirror the CLI `gflow video` flags — an omitted `model` lets the transport apply its i2v veo-lite default (issue #125), and every model — `omni_flash` included — accepts i2v with a start frame and with an end frame (wire-verified 2026-09-02, issue #626); `i2v` requires `initial_frame`, `r2v` requires `reference_images` **or** `reference_entities`; `project` generates into an existing Flow project id (mirrors CLI `--project`); on an account Google has moved to `flow.google.com` (`GFLOW_CLI_FLOW_HOST`, read from the server/daemon environment, not per call — see [CONFIGURATION § GFLOW_CLI_FLOW_HOST](CONFIGURATION.md#gflow_cli_flow_host)) omitting `project` creates one there first (#864); there the ported modes are text-to-video; image-to-video with a **local** `initial_frame`, optionally with a **local** `end_frame` (start+end interpolation, #639) (the file is uploaded through the editor and bound on the Start chip by display name — it stays in the Flow project like any upload, under a **run-unique** name: `hero.png` is listed as `hero-a1b2c3d4.png`, so a re-run cannot bind an earlier upload — [#792](https://github.com/ffroliva/gflow-cli/issues/792)); and reference-to-video with local `reference_images` (each file is uploaded the same way — run-unique name included — and attached as an `@` mention of *that* name in the prompt; `duration` there accepts only `8` and is pinned when omitted — Flow offers r2v at its base tier alone, and at 4 or 6 it silently drops the references and bills a text-to-video clip, so any other value returns the exit-11-equivalent envelope). A Flow media UUID as `initial_frame` or `end_frame`, and `r2v` by `reference_entity_names` or `reference_entities`, return the exit-36-equivalent envelope; a **local-file** `end_frame` is ported (#639). `initial_frame`, `end_frame`, and `reference_images` each accept **either a local file path or the Flow image UUID of a generated asset** — pass a generated image's id straight in to chain image→video, and gflow attaches it for you. Since v0.58.0 (#529) the CLI and MCP surfaces are unified for i2v frames: the UUID keeps its identity and is enriched with the catalog's recorded display name plus an integrity-verified local fallback, so the transport prefers **selecting the exact asset in the project's media picker** (no duplicate upload) and re-uploads the recorded local file only when the tile is unreachable — and only if its byte count/SHA-256 still match. A UUID that isn't in your local asset catalog is rejected up front with a clear "Reference Not Found" error; a catalogued asset with neither a display name nor a verified local file gives a "Reference Not Usable" error (re-generate it or pass a local path). `r2v` UUID refs are resolved to the recorded local file for upload. `ui_mode` selects the Flow UI arm (#299 PR-A, mirroring CLI `--ui-mode`) and **applies to every mode of this tool, including `r2v`** — unlike the CLI, where `video r2v`/`chain` have no flag and follow the env-only path. Video generation has **only a classic driver**, so `auto` ≡ `classic`: both verify the classic editor pre-submit and abort before spending credits if it is unreachable. `ui_mode="agentic"` is rejected with a 400 problem-details envelope, because no agentic video driver exists yet. Values are matched case-insensitively. (MCP tools return envelopes, never process exit codes — the CLI equivalents of these aborts are exit 28 and exit 2 respectively.) See [CONFIGURATION § GFLOW_CLI_UI_MODE](CONFIGURATION.md#gflow_cli_ui_mode). > **Attaching a saved character (the identity axis).** Three routes reach Flow's > `referenceEntities` wire and they dedupe against each other: an `@Name` mention inside @@ -129,7 +129,7 @@ We analyzed whether a terminal-driven CLI guided by a text skill (e.g., `skills/ | :--- | :--- | :--- | | **Output Fragility** | Ephemeral `stdout`/`stderr` log outputs are fragile to format adjustments, progress bars, and ANSI colors. | Strictly structured JSON payloads containing explicit metadata and absolute output file URIs. | | **Process Lifecycle**| High process startup overhead (Python import latency) on every individual execution. | Warm daemon process. Retains active caching of database metadata. | -| **Concurrency** | A second concurrent process against the same profile is rejected immediately by the cross-process `ProfileLease` with `ProfileLockedError` (exit code 11) — a clean fail-fast, never a crash, never a wait. Different profiles run fully in parallel. | Same `ProfileLease` enforcement applies to the daemon's requests — a same-profile call made while another is in flight (from the CLI or the MCP daemon) is rejected immediately with `ProfileLockedError` rather than queued or crashed; different profiles still run fully in parallel. | +| **Concurrency** | A second concurrent process against the same profile is rejected immediately by the cross-process `ProfileLease` with `ProfileLockedError` (exit code 11) — a clean fail-fast, never a crash, never a wait. Different profiles run fully in parallel. | Calls on one profile inside one server run one at a time — a second call waits for the first instead of failing. A profile held by a *different* process (a CLI run, another server) is waited out for up to 180 s (`GFLOW_CLI_LEASE_WAIT_SECONDS`, which a value you set yourself overrides) before `ProfileLockedError`; different profiles still run fully in parallel. | | **Error Handling** | Agent must scan logs for strings or parse exit codes to check status. | Strongly-typed JSON-RPC errors with mapped codes and clear remediation. | | **Transport Safety** | Volatile console printing. | Stdout is strictly isolated for JSON-RPC; all logs and warnings route to stderr. | @@ -356,6 +356,12 @@ unauthenticated, which is the local single-user default. > `gflow serve` — it currently runs the MCP/SSE server only. See the > [CHANGELOG](../CHANGELOG.md) for the roadmap. +> **However you register it, that server then gates your upgrades.** Every client +> above starts a long-lived `gflow mcp run` — one per session — and on Windows it +> holds the install open, so a package manager can abort part-way through +> `gflow update` and leave the environment broken. Stop the servers first: +> [USAGE § `gflow update`](USAGE.md#gflow-update). + --- ## 5. Security & Anti-Bot Mitigations @@ -447,7 +453,6 @@ web UI still works. | selector drift, *"… and carries no confirm"* | A cohort whose picker neither commits nor offers a confirm | As above — this is a new shape, not [#792](https://github.com/ffroliva/gflow-cli/issues/792) | | *"the Start chip did not bind"* | The search and the click both landed; the chip still did not take the thumbnail | **Not retryable** (`retryable: false`) — gflow refuses to submit rather than let it go out as text-to-video. File it with the verbatim detail. (Late server-side indexing is a *different* failure — "lists no asset named …" — and that path already retries internally) | | exit-27-equivalent, upload rejected, a dialog opened and no request left the page | Flow's **one-time** "rights to use this image" confirmation | Open the project on flow.google.com, upload any image **by hand**, accept it. gflow will not click it for you: it affirms that *you* hold the rights. Once per account, then uploads run unattended | -| exit-11-equivalent, missing `project` | Migrated host requires an existing project | Pass `project`; project creation is not ported | | exit-39-equivalent, *"This Google account cannot reach Flow"* | The account has no Flow access — it needs a Google AI Plus/Pro/Ultra or qualifying Workspace plan, an age-verified account and a supported region | **Stop. Do not retry, and do not call `gflow_auth_status` or suggest a re-login** — this is the one envelope where a retry is guaranteed futile, because the editor does not exist for this account. Relay the `remediation_hint` and its link verbatim; only the human can fix it. Note `auth status` will separately claim the Flow sign-in was not completed, which is [the same sentence with a second cause](#61-first-question-what-state-is-this-accounts-flow-in) | | exit-36-equivalent | The capability is not ported to the migrated host | See [CONFIGURATION § GFLOW_CLI_FLOW_HOST](CONFIGURATION.md#gflow_cli_flow_host) for exactly what is served there | | *"Profile is missing its browser-strategy marker"* (409, `retryable: false`, from `gflow_auth_status`) | The profile's browser-strategy marker is gone — typically after a failed first login | `gflow auth login --browser chrome`. It is **not** a network problem, whatever an older build told you | diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md index 65f937989..837d580bb 100644 --- a/docs/PROJECT_STATUS.md +++ b/docs/PROJECT_STATUS.md @@ -4,6 +4,70 @@ ## Current release +**v0.78.0 — alpha.** Generating without a project works again on accounts Flow serves from +`flow.google.com`, and the error that used to appear there is gone. + +**labs.google's project route is retired, and gflow was blaming the user's login (#864, +closes #561).** Every path that omits a project — `gflow image t2i`/`i2i`, `video +t2v`/`i2v`/`r2v`, `gflow project create`/`rename`, and the MCP twins — began by creating a +scratch project through `labs.google/fx/api/trpc/project.createProject`. Google has disabled +that route: it answers **404 "Flow RPCs have been deprecated and disabled. Flow has migrated +to https://flow.google.com."** for a session holding a labs token, and **401** for one +without. Measured 12/12 across four profiles. gflow rendered the 401 as *"Authentication +expired — run `gflow auth login`"*, advice no login could act on, and #561 sat open for a +month with a contributor's 401-then-200 measurement that no host-based theory could explain. + +gflow now creates the project **on flow.google.com** when the labs route refuses — keyed on +that observed 401/404, never on which host an account is served, so the labs arm and +`GFLOW_CLI_FLOW_HOST=labs.google` are untouched. Creation drives the projects page's `add` +button and reads the new id and title out of Flow's own `jHPbke` reply; rename drives the +project header and confirms the `o8DA4` reply echoes the title back. `generate_video` now +creates the project up front exactly as `generate_image` already did, so every video route is +decided with one in hand, and MCP `gflow_generate_video` honours `project_name`, which it had +accepted and silently dropped. + +**An MCP server waits out profile contention instead of failing it.** Two calls on one profile +inside one server now queue rather than the second dying with `ProfileLockedError`, and a +profile held by another process is waited out for up to 180 s. Both halves came from +reviewing a contributor PR that had the right goal and two defects: the default was applied +after `gflow`'s root command had already cached settings (measured: env `180`, setting +`0.0`), and same-process contention fails fast by design, so no wait value could have helped +the case it was written for. + +**Video from a start frame works again on migrated accounts (#860).** The Frames picker +renders a `mat-icon` ligature beside the file name, and a locator reads both nodes as one +string -- so gflow's anchored match could never hold and every `video i2v --initial-frame` +run on a `flow.google.com` account ended in exit 32, *"the frame picker lists no asset +named ..."*, while the error's own diagnostic listed that asset with `image` glued to the +front. Matching is now containment on the display name, which #792 already made +run-unique. Flow's promo modal is also dismissed before the first click rather than only +on load (#859). + +**`gflow video i2v --end-frame` runs on flow.google.com (#639).** Start+end interpolation +was the last i2v form the migrated composer refused. Both frames are uploaded and bound, +and the submit body is asserted before Flow acts on it -- a frame that failed to bind is +now a refusal instead of a wrong generation you paid for. The interpolation model key is +cohort-dependent and matched by shape. + +**`gflow docs` puts the documentation in the terminal (#861).** 126 pages lived in `docs/` +and nothing in the CLI pointed at any of them. `gflow docs` lists the topics, `gflow docs +` prints one as Markdown, and `gflow docs --search ` answers with +`docs/FILE.md:LINE` **and the line**. The pages ship inside the wheel, so it works with no +checkout and no network -- which is the whole point, since the reader who filed it +installed from PyPI. The wheel grows 0.74 MB -> 1.32 MB. + +**`--aspect 3:4` images run on flow.google.com.** Flow's image settings render a fifth aspect +radio that the 2026-09-08 enumeration did not have, so gflow had been refusing 3:4 with exit +36. Re-measured; all five image aspects are driven. Video offers 16:9 and 9:16 only, which was +already covered. + +Verified in `docs/LIVE_VERIFICATION_v0.78.0.md`. The project/aspect/MCP work was proven at +**zero credits** -- five e2e tests drive both surfaces with every video submit intercepted +before it reached Flow. **#860 was not**, and could not be: the only way to show that a +start frame now binds is to let a real generation run, so ten clips were generated and +downloaded on a live account. #859's dismissal path ran on every one of those runs, but no +promo modal appeared, so it is recorded as unobserved rather than verified. + **v0.77.1 — alpha.** A patch release: four fixes, and one of them made the package unusable on a clean Windows install. diff --git a/docs/USAGE.md b/docs/USAGE.md index c41f2569d..8e7be2d5c 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -10,6 +10,10 @@ CLI command reference. For environment variables see [CONFIGURATION](CONFIGURATI gflow [OPTIONS] COMMAND [ARGS]... Commands: + docs Browse gflow's own documentation (offline, read-only). + (no args) List every topic and what it covers. + TOPIC Print one page as raw Markdown (pipes cleanly). + --search TERM Matching lines, with docs/FILE.md:LINE positions. auth Manage Google sessions for Flow. (no args) Show profile list, or trigger first login. login One-time interactive sign-in. @@ -72,6 +76,47 @@ emit a single parseable object on stdout instead of Rich tables. See Note: `--profile NAME` is **per-subcommand**, not global — pass it after the subcommand name (e.g. `gflow image t2i "..." --profile experiments`, not `gflow --profile experiments image t2i ...`). +## `gflow docs` + +The documentation, from the terminal. Read-only, offline, no account, no credits — the +pages ship inside the package, so this works on a machine that has never seen this +repository. + +```bash +gflow docs # every topic, with what it covers +gflow docs usage # print one page (raw Markdown — pipe it) +gflow docs --search "r2v duration" # find the line that answers a question +``` + +| Flag | Effect | +|---|---| +| `--search TERM` | Every line mentioning TERM, with `docs/FILE.md:LINE` next to it. Whitespace splits the query into terms that must **all** appear on the line | +| `--json` | Machine-readable, on all three forms | + +**A topic name is whatever you would type**: the slug (`user-guide`), the file name +(`USER_GUIDE.md`), either case, or any unambiguous prefix (`conf` → `configuration`). An +ambiguous prefix lists the candidates; an unknown name is matched against the topic +list with `difflib`, so a typo (`usge`) is still offered `usage`. + +**Use two words when one is common.** `--search duration` matches well over a hundred +lines; the table header carries the total, shows the first 20 and says how many it held +back. `--search "r2v duration"` narrows that to a handful with the host-specific rule +at the top. + +Curated answers rank first: the `**"How do I …?"** →` rows in +[INDEX](INDEX.md#topic-shortcuts) were each written to *be* an answer, so they come above +raw body-text matches for the same term. + +Piping works — the page is emitted as its own Markdown, not as rendered boxes: + +```bash +gflow docs configuration | grep GFLOW_CLI_STORAGE_URI +gflow docs --search "exit code" --json | jq -r '.matches[].path' +``` + +There is deliberately **no MCP twin** — see the note at the top of +`src/gflow_cli/cli_docs.py`. + ## `gflow auth` See [AUTHENTICATION § Commands](AUTHENTICATION.md#commands). @@ -190,11 +235,11 @@ Generate 1–4 images from one text prompt, or run a shell-friendly batch of 1 prompts through one Flow session/project. > **Migrated `flow.google.com` accounts (#639):** T2I is supported with Nano Banana 2 -> (`nano2`) and Nano Banana Pro (`nano-pro`), the four aspects measured there (`16:9`, -> `4:3`, `1:1`, `9:16`), and count 1–4. **`--project ` is required** — a fresh project -> can only be created through the labs gallery, so without it the run exits 11. The +> (`nano2`) and Nano Banana Pro (`nano-pro`), all five aspects (`16:9`, +> `4:3`, `1:1`, `3:4`, `9:16`), and count 1–4. Without `--project`, gflow creates a fresh project +> there first ([#864](https://github.com/ffroliva/gflow-cli/issues/864)). The > migrated page owns its reCAPTCHA + `ogiZ0b` submit. Imagen 4, Agent instructions, -> character/entity references, `3:4` and `image batch` remain unavailable on that host and +> character/entity references and `image batch` remain unavailable on that host and > fail before submit. ```text @@ -337,9 +382,9 @@ A 4-image run with `--out ./logos/` produces: Generate 1–4 images by blending a text prompt with one or more reference images. Same flag set as `t2i`, plus a required `--ref` (repeatable). > **Migrated `flow.google.com` accounts (#639):** local-file `--ref` values are supported -> and each uploaded media id is verified in the outgoing `ogiZ0b` body. **`--project ` -> is required here** (exit 11 without it). UUID refs, `@Name` / `--reference-entity`, Agent -> instructions, Imagen 4 and the `3:4` aspect remain unavailable on that host and fail +> and each uploaded media id is verified in the outgoing `ogiZ0b` body. Without `--project`, +> gflow creates one there first (#864). UUID refs, `@Name` / `--reference-entity`, Agent +> instructions and Imagen 4 remain unavailable on that host and fail > before submit rather than silently degrading to T2I. ```text @@ -605,12 +650,14 @@ Options: > **Two Flow frontends (#639).** Under the default `GFLOW_CLI_FLOW_HOST=auto`, `t2v` with > `--project ` runs on Flow's migrated `flow.google.com` host on every account; without -> `--project` an unmoved account falls back to the labs driver, and a moved account exits 11 -> (`--project` is required there — project creation is not ported). `i2v` with a local -> `--initial-frame` and no `--end-frame` runs there too (see [`gflow video i2v`](#gflow-video-i2v)), -> as does `r2v` from local `--ref` files (see [`gflow video r2v`](#gflow-video-r2v)); -> an end frame, a frame given by UUID or `@Name`, references given by `@Name` or -> `--reference-entity`. `image t2i` and local-file `image i2i` also run on a moved +> `--project`, gflow first creates a project — through labs.google where that route answers, +> otherwise on flow.google.com ([#864](https://github.com/ffroliva/gflow-cli/issues/864)) — +> and the run then routes exactly as if you had passed it. `i2v` with a local +> `--initial-frame` runs there too — **including with a local `--end-frame`** +> (start+end interpolation, #639; see [`gflow video i2v`](#gflow-video-i2v)) — as does +> `r2v` from local `--ref` files (see [`gflow video r2v`](#gflow-video-r2v)). An end frame +> or start frame given by UUID or `@Name`, and references given by `@Name` or +> `--reference-entity`, are not ported to the migrated composer yet. `image t2i` and local-file `image i2i` also run on a moved > account; UUID/entity/instruction/Imagen-4 image forms still exit 36. `flow.google.com` forces the migrated composer, > `labs.google` switches it off — see [CONFIGURATION § GFLOW_CLI_FLOW_HOST](CONFIGURATION.md#gflow_cli_flow_host). @@ -644,8 +691,8 @@ the editor's frame slot via the media dialog, then Flow fires > to submit unless the app's own > submit body carries that upload's media id with an image-to-video model key (exit 7 > otherwise: the labs #125 shape, where an unbound frame silently goes out as text-to-video). -> `--end-frame`, a UUID or `@Name` frame exit 36 there; an unmoved account keeps the labs -> driver for those. +> A UUID or `@Name` frame exits 36 there; a local `--end-frame` is ported (#639). An +> account served labs.google keeps the labs driver for the unported forms. > > What lands in the library is a **run-unique copy** — `hero.png` uploads as > `hero-a1b2c3d4.png` (#792). The picker is searched by display name, so identical names @@ -1455,6 +1502,7 @@ that privacy setting, so missing names are expected, not a defect. ## `gflow update` Upgrade gflow-cli in place, through the package manager that installed it. +**Stop every other `gflow` process first** — see below. ```text gflow update [--check] [--json] @@ -1519,9 +1567,30 @@ points at the venv's python — so `gflow update` reports it as upgraded with a note quoting the manager's exit code; the next update from another shell refreshes the launcher. -Restart any running `gflow serve` / MCP server afterwards; a long-lived process -keeps the old code until it restarts. There is deliberately no MCP twin of this -command: a server must not replace its own code underneath itself. +**Stop every other `gflow` process BEFORE you upgrade — not after.** The launcher +lock just above is the benign case: it is the `gflow.exe` *you* are running, and +the upgrade lands anyway. A **different** gflow, holding the install's contents +open, is not — there the manager can abort part-way and leave the environment +broken. The holder reported so far is **`gflow mcp run`**: an editor with gflow +registered as an MCP server starts one per session, and they outlive the command +that started them. Reported with a reproduction in +[#848](https://github.com/ffroliva/gflow-cli/issues/848#issuecomment-5704903340): +`uv tool install --force` failed with *"Access is denied"* on +`…\uv\tools\gflow-cli\Scripts` and left the install answering +`No module named 'gflow_cli'` until those processes were stopped and the install +re-run. + +Close the editor sessions (or stop the servers) first. On Windows, +`tasklist | findstr gflow` lists image names only — a server registered as +`uv run gflow …` shows up as `uv.exe`, not `gflow.exe`; elsewhere +`pgrep -fa gflow` prints the command line it matched, so you can see what you +are about to stop. `gflow update` cannot run this check for you: it is itself +one of those processes. If an upgrade does abort anyway, the exit-11 **forced +reinstall** above is the repair — run it from a shell with no gflow running. + +Restart any running `gflow serve` / MCP server afterwards too; a long-lived +process keeps the old code until it restarts. There is deliberately no MCP twin +of this command: a server must not replace its own code underneath itself. Every command also prints an **update banner** on stderr (a one-line notice when stderr is not a terminal) when a newer release is known — see @@ -1557,7 +1626,7 @@ gflow models --json ## JSON output (`--json`) The generation commands (`image t2i` / `image i2i`, `video t2v` / `i2v` / -`r2v`), `auth list`, and `gflow models` accept `--json` for machine-to-machine +`r2v`), `auth list`, `gflow models`, and `gflow docs` accept `--json` for machine-to-machine use. When set: - The command emits **one** parseable JSON object (or array, for `auth list`) @@ -1819,7 +1888,7 @@ shell scripts can branch on the failure mode without parsing stderr. | `33` | — (`gflow doctor` verdict) | Doctor found warn/fail findings — a successful diagnosis, not an error class | Review the report; see [`gflow doctor`](#gflow-doctor) | | `34` | `SyncPartialError` | `gflow data sync` failed on some projects but succeeded on others — completed writes stay committed | Retryable: re-run the same command; it resumes with what is still nameless (see [`gflow data sync`](#gflow-data-sync)) | | `35` | `ExtendUnavailableError` | No Veo extend model is orderable for this account and aspect — the extend family is tier-gated and there is no square variant. **Never auto-retry**: a tier gate does not clear on its own. | -| `36` | `FlowHostMigratedError` | Flow served the project from `flow.google.com` and the request could not be represented by the migrated composer, or `GFLOW_CLI_FLOW_HOST=labs.google` disabled it. Supported today: `video t2v`; local-file video i2v/r2v; `image t2i`; and local-file `image i2i`. Image UUID/entity/instruction/Imagen-4 forms, `image batch`, and the `3:4` image aspect remain unsupported. Not selector drift (23) | **Not retryable.** Use one of the supported forms — `--project` is required for images as well as video — or the REST surface (`gflow project list`, `gflow data …`); follow #639 for the remaining matrix | +| `36` | `FlowHostMigratedError` | Flow served the project from `flow.google.com` and the request could not be represented by the migrated composer, or `GFLOW_CLI_FLOW_HOST=labs.google` disabled it. Supported today: `video t2v`; local-file video i2v/r2v; `image t2i`; and local-file `image i2i`. Image UUID/entity/instruction/Imagen-4 forms and `image batch` remain unsupported. Not selector drift (23) | **Not retryable.** Use one of the supported forms, or the REST surface (`gflow project list`, `gflow data …`); follow #639 for the remaining matrix | | `37` | `InsufficientCreditsError` | The account's balance is short **for the model it asked for**, so Flow **replaced** the submit control with its `Insufficient credits warning` instead of disabling it. Short, not necessarily empty: measured 2026-09-07, an account holding **50** credits requesting `--model veo-quality` (**100**) rendered the warning. Explicitly **not** selector drift (23): reporting it as drift told users to file a frontend bug over a credit shortfall | Check the balance with `gflow credits user`, then pick a cheaper `--model` (`veo-lite` costs 10), top up, or wait for the allowance to reset. Nothing was submitted, so no credit was spent. `gflow image` draws on a separate daily quota and may still work | | `38` | `FlowAccountChooserError` | The post-migration hop landed on Google's account chooser and the profile's recorded account (`.gflow_account`) could not be selected automatically (row absent, click-through did not return to the editor, or `--account` mismatch) | **Not retryable**: run `gflow auth login --profile ` and complete the chooser manually, while signed in as the recorded account (re-run `gflow auth login` if the chooser offers a different session) | | `39` | `FlowAccessUnavailableError` | Flow loaded and routed to its own "you don't have access" screen (``): this Google account has no Flow entitlement. Detected by component, not by URL — the hop is client-side (`flow.google.com/` answers 200) and the path varies (`/unavailable`, `/u/8/unavailable`). Explicitly **not** auth expiry (3/8) and **not** selector drift (23): nothing expired and nothing drifted | **Not retryable, and signing in again cannot change it.** Flow needs an age-verified account in a supported region on a Google AI Plus/Pro/Ultra or qualifying Workspace plan — check which applies at [Google's eligibility page](https://support.google.com/flow/answer/16353333) and open https://flow.google.com in a browser on this account to confirm | diff --git a/docs/superpowers/memory/flow-recon-must-run-on-denon82-ffroliva-migrated.md b/docs/superpowers/memory/flow-recon-must-run-on-denon82-ffroliva-migrated.md index c0a1704c5..406f3f1ea 100644 --- a/docs/superpowers/memory/flow-recon-must-run-on-denon82-ffroliva-migrated.md +++ b/docs/superpowers/memory/flow-recon-must-run-on-denon82-ffroliva-migrated.md @@ -45,9 +45,10 @@ having a migrated driver at all, and read as "the new host cannot be driven"): slice landed. A migrated user reading a stale line concludes a working feature is impossible, so re-derive it from the guards rather than from this bullet: `_unported_form` and `_unported_image_form` in `migrated_composer.py` ARE the - matrix. As of #639's image slice the ported set is t2v; i2v/r2v from local files; - `character create`/`list`; and image t2i/i2i from local files — each with - `--project`. Unported: end frames, frames/references by UUID or `@Name`, character + matrix. As of #639's end-frame slice the ported set is t2v; i2v from a local start + frame AND from a local start+end pair (interpolation, 2026-09-17); r2v from local + files; `character create`/`list`; and image t2i/i2i from local files — each with + `--project`. Unported: frames/references by UUID or `@Name`, character entities on a generation, Agent instructions, Imagen 4, `image batch`, the 3:4 image aspect, scenes, extend and tools. Of those only i2v-by-UUID rests on a positive observation of absence; the rest are *unported by gflow*, never proven diff --git a/docs/superpowers/plans/2026-09-17-gflow-docs-command/PLAN.md b/docs/superpowers/plans/2026-09-17-gflow-docs-command/PLAN.md new file mode 100644 index 000000000..994b0cd7d --- /dev/null +++ b/docs/superpowers/plans/2026-09-17-gflow-docs-command/PLAN.md @@ -0,0 +1,150 @@ +# `gflow docs` Implementation Plan + +> **For agentic workers:** Run `/gflow:status --feature gflow-docs-command` to find the +> next unchecked task. Implement one task at a time. Run `/gflow:check` before every commit. + +**Goal:** `gflow docs` makes the 126 pages in `docs/` reachable from the terminal — list +them, print one, or search for the line that answers a question — with no network, no +account, no credits, and **no checkout required**. + +**Architecture:** One new pure module (`docs_catalog.py`) holds every decision — what a +topic is, how a name resolves, how search ranks — and does no I/O beyond reading package +data through `importlib.resources`. One thin Click module (`cli_docs.py`) renders it, in +the shape `cli_models.py` established. A hatchling build hook puts `docs/*.md` into the +wheel as `gflow_cli/_docs/`. Nothing existing changes except one import and one +`add_command` in `cli.py`. + +**Predict verdict:** not required — `/gflow:predict` gates transport, auth, selector and +schema changes ([AGENTS.md](../../../../AGENTS.md) routing table). This feature touches +none of the four: it adds a read-only command that reads files shipped inside its own +package. Said out loud rather than skipped silently. + +**Risk register:** + +| Severity | Risk | Mitigation | +|---|---|---| +| Critical | A topic name is used to build a path → arbitrary file read | Names are matched against the **enumerated** set of shipped pages. No user string ever reaches a path join. Scenarios #1/#2 | +| Critical | Ships only for people with a git checkout, i.e. not the user in #861 | Build hook + an integration test that builds a wheel, installs it into a scratch venv and runs the command there. Scenario #3 | +| Critical | `UnicodeEncodeError` on a cp1252 Windows console — the #846 shape, one release old | Every byte printed goes through one `_console_safe()` round-trip. Scenario #4 | +| High | A hand-maintained topic list drifts the first time a page is added | There is no list. The glob is the list, at build time and at read time. Scenario #11 | +| High | Search answers `MCP.md` and leaves the reader in a 100 KB file | Matches are returned as `file:line` **with the line**, and `INDEX.md`'s curated one-liners rank above raw body hits. Scenarios #8/#9 | +| Medium | `INDEX.md` § Topic shortcuts is parsed; its format could change | The parser is additive — if it yields nothing, full-text search still answers. Never load-bearing. Scenario #10 | + +--- + +## File structure + +### New files + +``` +hatch_build.py + Build hook: force-include docs/*.md into the wheel as gflow_cli/_docs/. +src/gflow_cli/docs_catalog.py + Pure catalog + resolution + search. No Click, no console, no network. +src/gflow_cli/cli_docs.py + `gflow docs` / `docs ` / `docs --search` — rendering only. +tests/cli/test_cli_docs.py + Scenarios 1,2,4,6,7,9,10,12,16 — resolution, refusal, ranking, encoding, --json. +tests/cli/test_docs_catalog_real_docs.py + Scenarios 8,11 — asserted against the REAL shipped pages, not a fixture. +tests/integration/test_docs_ships_in_wheel.py + Scenario 3 — build a wheel, install it in a scratch venv, run `gflow docs` there. +docs/superpowers/plans/2026-09-17-gflow-docs-command/{SCENARIO,PLAN}.md + This analysis. +``` + +### Modified files + +``` +pyproject.toml + Register the build hook. +src/gflow_cli/cli.py + One import, one add_command. +docs/USAGE.md + A `gflow docs` section. +docs/INDEX.md + One Topic-shortcut row, so the command is discoverable the way everything else is. +CHANGELOG.md + [Unreleased] → Added. +``` + +--- + +## Task 1 — Ship the pages (test scaffold + build hook) + +**What:** `docs/*.md` lands in the wheel as `gflow_cli/_docs/`, and nothing else does. + +**Steps:** +- [ ] `hatch_build.py` with a `custom` build hook writing `build_data["force_include"]` +- [ ] Register `[tool.hatch.build.targets.wheel.hooks.custom]` in `pyproject.toml` +- [ ] `tests/integration/test_docs_ships_in_wheel.py` — build, install, run + +**Tests created (red):** +- [ ] `test_the_wheel_carries_the_top_level_pages_and_nothing_else` — asserts + `assets/` and `superpowers/` are absent (the measured failure of the naive + `force-include` + `exclude`, SCENARIO § M1) +- [ ] `test_docs_command_works_from_an_installed_wheel_with_no_checkout` — scenario #3 + +## Task 2 — The catalog (pure) + +**What:** `docs_catalog.py`: enumerate topics, resolve a name, search. + +**Steps:** +- [ ] `topics()` — slug, title (first `# `), summary (first prose line), repo path +- [ ] `resolve(name)` — exact slug, file name, case-insensitive, unique prefix; returns + `None` for everything else. **Never** builds a path from the input +- [ ] `search(term)` — curated `INDEX.md` shortcuts first, then `file:line` body hits +- [ ] `read(topic)` — the page's text + +**Tests created (red):** +- [ ] traversal refused (`../`, `..\`, absolute, drive letter, NUL) — #1, #2 +- [ ] prefix + `.md` + case forms all resolve — #16 +- [ ] ambiguous prefix is refused, and names the candidates +- [ ] `search("duration")` returns the migrated-r2v-8s line, with its file and line — #8 +- [ ] a curated INDEX hit outranks a body hit for the same term — #9 +- [ ] an INDEX with no parseable shortcuts still searches — #10 +- [ ] every `docs/*.md` appears in `topics()` — #11 + +## Task 3 — The command + +**What:** `cli_docs.py` + registration. + +**Steps:** +- [ ] `gflow docs` — table of topics; `gflow docs ` — the page; `--search` +- [ ] `--json` on all three +- [ ] `_console_safe()` on every printed string — #4 +- [ ] Unknown topic → `ConfigurationError` (exit 11, already in `EXIT_CODE_MAP`), + message naming the nearest topics — #6 +- [ ] `--search` with no matches → exit 0 — #7 + +**Tests created (red):** +- [ ] cp1252 stdout prints a page full of `—`/`→`/`✅` without raising — #4 +- [ ] unknown topic exits 11 and suggests — #6 +- [ ] empty search exits 0 — #7 +- [ ] `--json` shapes — #12 + +## Task 4 — Decide the MCP twin, in writing + +**What:** Scenario #14. The stated consumer is an agent, so "no twin" is a claim. + +**Steps:** +- [ ] Decide and record the reasoning in the module docstring and CHANGELOG + +## Task 5 — Docs + changelog + +**Steps:** +- [ ] `docs/USAGE.md` section, `docs/INDEX.md` shortcut row, `CHANGELOG.md` + +--- + +## Definition of done + +- [ ] All task steps checked off +- [ ] `/gflow:check` green (ruff / format / pyright / pytest) +- [ ] `CHANGELOG.md` `[Unreleased]` updated +- [ ] `docs/USAGE.md` + `docs/INDEX.md` updated +- [ ] Every Critical + High scenario from `SCENARIO.md` has a test, **including** the + installed-wheel one — offline-green in the repo is exactly the false positive + this feature exists to correct +- [ ] No `# TODO` without a tracked issue link +- [ ] `/gflow:branch-review` run and findings applied or declined in writing diff --git a/docs/superpowers/plans/2026-09-17-gflow-docs-command/SCENARIO.md b/docs/superpowers/plans/2026-09-17-gflow-docs-command/SCENARIO.md new file mode 100644 index 000000000..414e3e44f --- /dev/null +++ b/docs/superpowers/plans/2026-09-17-gflow-docs-command/SCENARIO.md @@ -0,0 +1,169 @@ +# Scenario: `gflow docs` — documentation reachable from the CLI (#861) + +**Feature.** A read-only command group that makes the 126 files in `docs/` reachable at +the moment of use: `gflow docs` (list topics), `gflow docs ` (print one), +`gflow docs --search ` (find the page that answers a question). + +**Why it exists** is a real failure, recorded in #861: a rule stating that the migrated +host offers r2v at 8 s only *existed in writing* and was never found, so a recipe was run +with `--duration 10`. gflow refused pre-submit and nothing was billed, but only because a +guard happened to exist. + +--- + +## Two measurements taken before planning + +Both would have made a plan written on assumption wrong. + +### M1 — `exclude` does NOT apply to hatchling's `force-include` + +`docs/` is not in the wheel today (`packages = ["src/gflow_cli"]`). The obvious one-line +fix is `force-include = { "docs" = "gflow_cli/_docs" }` with `exclude` for the parts that +should not ship. Built and inspected: + +| | | +|---|---| +| `_docs` entries in the wheel | **275** | +| `docs/superpowers/**` leaked despite `exclude` | **145** | +| `docs/assets/**` leaked despite `exclude` | **3** | +| uncompressed docs bytes | **4 323 911** | +| wheel size | **3.6 MB** | + +So that shape is out. Per-file control is required, and 126 hand-written `force-include` +entries would drift on the first new page. **A hatch build hook** (`hatch_build.py`, +writing `build_data["force_include"]` from `docs/*.md`) is the only form that ships +exactly the top-level pages — 1.3 MB, no `assets/`, no `superpowers/` — and cannot drift, +because the glob *is* the list. + +### M2 — the rule that motivated the issue is documented, and still unfindable + +`--duration` on migrated r2v **is** written down. It is in `docs/MCP.md`, inside a single +~4 000-character bullet describing the `gflow_generate_video` tool: + +> `duration` there accepts only `8` and is pinned when omitted — Flow offers r2v at its +> base tier alone, and at 4 or 6 it silently drops the references and bills a +> text-to-video clip + +This kills the simplest `--search`. A search that answers `MCP.md` is not an answer: the +reader is then facing a 4 KB paragraph in a 100 KB file, which is the state the issue is +complaining about. **Search must return the matching line with its position**, and must +rank the curated one-liners in `INDEX.md` § Topic shortcuts above raw full-text hits. + +### M3 — ranking cannot rescue a one-word query, and two attempts proved it + +Scenario #8 was first written as *"`--search duration` surfaces the rule"*. Built, then +measured three times against the real corpus (133 matching lines): + +| ordering | what came back first | +|---|---| +| alphabetical (no ranking) | three lines of `AGENT_UI_RECON.md`; the rule past the cap | +| + heading bonus | eight `LIVE_VERIFICATION_*` release records | +| + "page is routed from `INDEX.md`" | four release records, rule 5th | + +The third was the idea with the best story — reuse the repository's own statement of what +is reference material rather than invent one. It is false: **`INDEX.md` links 93 of the +126 pages**, every release record included, so it separates nothing. It was removed rather +than kept as a knob that does nothing, and `_score`'s docstring records why, so it is not +reinvented. + +**Corrected after the council review.** The `LIVE_VERIFICATION_*` rows in row 2 above were +not simply "release records outranking reference pages" — several were `#` **shell comments +inside fenced code blocks**, scored as Markdown headings. `# 5. One cheapest stable T2V +generation, no explicit --duration` ranked 2nd of 136. The fence tracking that fixes it is +four lines, and with it a bare `--search duration` now opens with curated `INDEX.md` rows +rather than release archaeology. The separate curated-shortcut parser was deleted in the +same pass: its hits were measured as a strict *subset* of what the body scan already +returned (0 of 7 for `r2v duration`), so 28 lines of regex were only re-ordering rows search +already had — and stripping Markdown from those rows mangled the file name they pointed at +(`REFERENCE_STRATEGIES.md` → `REFERENCESTRATEGIES.md`, printed as the answer). It is now a +score bonus. + +What works is the query carrying a second word: `r2v duration` returns **5** hits with +`docs/MCP.md:87` first. So the acceptance test is #8 as amended, and #8b holds the command +to answering a vague question honestly — the count and a way to narrow — rather than +guessing. + +--- + +## Coverage map + +| Dimension | Active? | Why | +|---|---|---| +| D1 Auth & session | ❌ | No account, no session, no network. | +| D2 WAF / reCAPTCHA | ❌ | Nothing is fetched. | +| D3 Selector drift & locale | ❌ | No browser. Docs are English-only artefacts of this repo; there is no locale axis to get wrong. | +| D4 Batch & resume | ❌ | No ledger, nothing resumable. | +| D5 Concurrency & Page pool | ❌ | No pool, no async. | +| D6 Data layer | ❌ | Reads no DB and records nothing. A read-only command has no `OperationRecorder` callsite. | +| **D7 Error propagation & exit codes** | ✅ | A new failure mode ("no such topic") needs a class already in `EXIT_CODE_MAP`, not a new one. | +| **D8 Cross-platform paths & packaging** | ✅ | The whole feature is package-data resolution, plus the console-encoding trap that shipped as #846 one release ago. | +| D9 Transport edge cases | ❌ | No HTTP. | +| D10 Headless vs headed | ❌ | No browser. | +| **D11 Input validation** | ✅ | `` is user input used to select a file. That is a path-traversal surface. | +| **D12 Observability** | ✅ | Small: does a read-only command emit events at all, and is `--json` shaped like every other command's. | +| **D13 MCP parity** | ✅ | The issue's stated consumer is *an agent*. Whether this gets an MCP twin is a decision, and `gflow update` is the precedent for deliberately refusing one. | + +--- + +## Scenario table + +| # | Dimension | Scenario | Severity | Expected behaviour | Test category | +|---|---|---|---|---|---| +| 1 | D11 | `gflow docs ../../../../etc/passwd` — or `..\..\` on Windows | **Critical** | Refused as an unknown topic. The topic name is matched against the *enumerated* set of shipped pages; it is never concatenated into a path. Nothing outside `_docs` is ever read | Unit | +| 2 | D11 | `gflow docs` with a topic containing a path separator, a NUL, or a drive letter (`C:\x`) | **Critical** | Same refusal, same code path — the guard is "is it in the enumerated set", so these need no special cases and must not acquire any | Unit | +| 3 | D8 | Installed from a wheel (`uv tool install`), no checkout anywhere on the machine | **Critical** | Every subcommand works. This is the *only* scenario that matters for the issue's stated user; a command that needs a git checkout solves nothing | Integration (build a wheel, install into a scratch venv, run it) | +| 4 | D8 | Windows console, `PYTHONUTF8` unset, cp1252 stdout, printing a page full of `—`, `→`, `✅` | **Critical** | No `UnicodeEncodeError`. This exact class shipped as #846 in the *previous* release — a decoration that only ever ran on a dev machine | Unit + Integration | +| 5 | D8 | The package is imported from a zipimport / frozen environment where `_docs` has no real filesystem path | High | Read through `importlib.resources`, never `Path(__file__).parent`. A `Traversable` that is not a real path must still read | Unit | +| 6 | D7 | `gflow docs no-such-page` | High | A typed error already in `EXIT_CODE_MAP` (**no new exception class**, no new exit code), whose message lists the nearest matching topics rather than only saying no | Unit | +| 7 | D7 | `gflow docs --search ` matching nothing | Medium | Exit 0 with "no matches", **not** an error. An empty result is a true answer to a valid question | Unit | +| 8 | M2 | `gflow docs --search "r2v duration"` on the real corpus | **Critical** | The migrated-r2v-8s rule comes back **first**, as a windowed line with `docs/MCP.md:87` next to it. Amended after measurement — see § M3; the bare word `duration` cannot be made to rank it | Unit (asserted against the real shipped docs) | +| 8b | M2 | `gflow docs --search duration` — 133 hits | High | Shows the cap, says how many were held back, and suggests narrowing. An honest count beats a confident wrong first result | Unit (real docs) | +| 9 | M2 | A term that appears in both a curated `INDEX.md` question and raw body text | High | The curated one-liner ranks first. It was written to be an answer; a body-text hit is only a location | Unit | +| 10 | D8 | `docs/INDEX.md` § Topic shortcuts changes shape (a line stops matching the `**"Q"** → [label](path)` form) | High | Degrade to full-text search, never crash and never silently return nothing. The parser must not be load-bearing for the command's basic function | Unit | +| 11 | D8 | A new page is added to `docs/` and nobody updates anything | High | It appears in `gflow docs` automatically. If the topic list is a hand-maintained manifest, it will drift — this scenario is the argument against one | Unit | +| 12 | D12 | `gflow docs --json` | Medium | The same envelope shape every other command emits, so an agent can parse it. Topic list as data, not as a rendered table | Unit | +| 13 | D12 | Structured-log events for a read-only command | Low | None needed. Emitting `error_raised` for "unknown topic" is fine; a success event for printing a file is noise | — (decision, not a test) | +| 14 | D13 | The MCP surface | High | **Decide explicitly.** An agent over MCP is the stated consumer, so "no twin" needs the `gflow update` style justification, in writing | — (decision) | +| 15 | D8 | Page count/size drifts upward over time (e.g. `docs/` doubles) | Low | The build hook globs, so it tracks automatically; wheel growth is the only cost and it is text | — | +| 16 | D11 | `gflow docs ` given a unique *prefix* or the file name with `.md` | Medium | Accept both — `gflow docs usage`, `gflow docs USAGE.md`. An exact-only match makes the command feel broken for the most obvious input | Unit | + +--- + +## Must-cover before merge (Critical + High) + +1. **#1, #2 — path traversal.** Enumerate-then-match; never build a path from input. +2. **#3 — works from a wheel with no checkout.** Built, installed, and run in a scratch + venv, because "it works in the repo" is exactly the failure this feature exists to fix. +3. **#4 — Windows cp1252 console.** #846 is one release old; this is the same shape. +4. **#8/#8b — `--search "r2v duration"` finds the migrated r2v rule, first.** The + feature's reason for existing, asserted against the real docs rather than a fixture, + plus the honest answer to the vague query (§ M3). +5. **#6, #9, #10, #11, #16** — refusal quality, ranking, parser resilience, no manifest. +6. **#5** — `importlib.resources`, not `__file__`. +7. **#14** — MCP decision written down either way. + +## Deferred (Medium + Low) + +1. **#13** — no success event. Revisit only if someone wants docs-usage telemetry. +2. **#15** — wheel growth. Measure at release; nothing to build. +3. Category grouping in the TOC (the mock-up in #861 shows `GETTING STARTED` / + `GENERATION` headings). **Deliberately not built**: a hand-assigned category per page + is a second manifest to drift, and scenario #11 is the argument against the first one. + Alphabetical topics plus search covers the use case; revisit if 126 becomes 300. +4. A per-host capability matrix page (#861 calls it the highest-value page behind the + command). That is a *documentation* task, not this command, and it is #639's to own. + +--- + +## Suggested BDD scenarios + +Deliberately **not** Gherkin-bound e2e. Every scenario above is decidable offline in our +own code — there is no browser, no Flow surface and no network anywhere in this feature — +so the Bug Lane's step 5 does not apply, and `tests/features/test_e2e_binding_guard.py` +would fail an `@e2e`-tagged feature with no live binder. The tests are ordinary +`tests/cli/test_docs*.py`, plus one integration test that builds and installs a wheel, +which is the only way to prove scenario #3. + +Stated out loud because "write it as an e2e" is this repo's default answer, and here the +default is wrong: an e2e that mocks nothing but also touches nothing live is just a slow +unit test wearing a tag that makes hosted CI try to launch Chrome. diff --git a/docs/superpowers/plans/2026-09-17-project-create-bundle/PLAN.md b/docs/superpowers/plans/2026-09-17-project-create-bundle/PLAN.md new file mode 100644 index 000000000..25c640729 --- /dev/null +++ b/docs/superpowers/plans/2026-09-17-project-create-bundle/PLAN.md @@ -0,0 +1,68 @@ +# Project creation on flow.google.com + MCP profile contention (bundle #864) + +> **For agentic workers:** Run `/gflow:status --feature project-create-bundle` to find the next +> unchecked task. Run `/gflow:check` before every commit. + +**Goal:** generating without a project works again — CLI and MCP — on accounts Flow serves from +flow.google.com, `gflow project create` / `rename` work there, and an MCP server waits out profile +contention instead of failing it. + +**Issues:** #864 (bundle) · closes #561 · supersedes PRs #863 and #862 (@iceblue03) · refs #639 + +**Evidence:** [`2026-09-17-project-create-on-flow-google-com.md`](../../spikes/2026-09-17-project-create-on-flow-google-com.md) +— labs `project.createProject` 404 "Flow RPCs have been deprecated and disabled" (token sessions) +or 401 (tokenless), 12/12 on four profiles; flow.google.com creates by `jHPbke` (FAB `add` in +`flow-projects-page`) and renames by `o8DA4` (`flow-editable-text`), both measured. + +**Predict:** CAUTION 7/10 (five personas, inline, 2026-09-17). Conditions carried into the tasks: +1. Fall back on the **observed** labs refusal (401/404 at the project route), never on host + membership; `GFLOW_CLI_FLOW_HOST=labs.google` never falls back; `flow.google.com` goes direct. +2. Read success from the rpc reply (id + title), not from the URL. +3. Anchors structural only (`flow-projects-page`, `mat-icon` ligature, `flow-editable-text`). +4. Every "a fresh project cannot be created on flow.google.com / `--project` is required" claim in + code messages, MCP docstrings and docs is corrected in the same PR. +5. No new setting. #863's default project is the WS3 sticky-defaults workstream + (`2026-09-14-community-feedback-uplift`), which owns the source/trust design it needs. + +## Scenarios + +Create (`migrated_composer.create_project`) +- S1 FAB click → `jHPbke` reply `[id, [title]]` → `ProjectInfo`; requested title differs → rename. +- S2 reply title already equals the requested title → no rename. +- S3 FAB never visible → known-landing diagnosis first, else `UiSelectorDriftError` naming the anchor. +- S4 clicked, no `jHPbke` reply in time → `TransportTimeoutError`. +- S5 reply carries no project UUID → `WireFormatError`. + +Rename (`migrated_composer.rename_project`) +- R1 fill + Enter → `o8DA4` reply echoes the title. +- R2 reply echoes a different title → `WireFormatError`. +- R3 no `flow-editable-text` input → known-landing diagnosis, else `UiSelectorDriftError`. + +Routing (`FlowApiClient.create_project` / `rename_project`) +- C1 `flow_host=flow.google.com` → migrated directly; no labs request. +- C2 `auto`, labs 200 → labs result; migrated never touched. +- C3/C4 `auto`, labs 404 / 401 → migrated. +- C5 `labs.google`, labs 404 → original error. +- C6 `auto`, labs 403 / 400 → original error, no fallback. + +MCP profile contention (#862) +- M1 `gflow mcp run` / `gflow serve`, `GFLOW_CLI_LEASE_WAIT_SECONDS` unset → loaded setting is 180. +- M2 explicit value (incl. `0`) wins. +- M3 two concurrent tool calls on one profile inside one server → serialized, not `ProfileLockedError`. + +Surfaces: CLI `image t2i/i2i`, `video t2v/i2v/r2v`, `project create/rename`, `image upload`, image +batch; MCP `gflow_generate_image` / `gflow_generate_video` (worker path). No new options, so the +parity gate is unchanged; docstrings are the MCP axis that moves. + +## Tasks + +- [ ] 1. RED+GREEN S1–S5, R1–R3: `create_project` / `rename_project` in `migrated_composer.py` +- [ ] 2. RED+GREEN C1–C6 in `api/client.py` for create and rename +- [ ] 3. Correct stale claims: `run_video`/`run_images` messages + docstrings, `migrated_can_serve` + docstring, MCP tool docstrings, `docs/USAGE.md`, `docs/MCP.md`, `KNOWN_ISSUES.md`, CHANGELOG +- [ ] 4. RED+GREEN M1–M3 (`mcp/server.py`, `mcp/tools.py`); docs for the MCP wait default +- [ ] 5. `/gflow:check` +- [ ] 6. e2e (zero credits): `project create` + `rename`, `image t2i` without `--project` (CLI), + `gflow_generate_image` without `project` (MCP) +- [ ] 7. Council review, live-verify ledger, PR, Sonar +- [ ] 8. Release; close #561/#863/#862 with thanks; notify reporters on #561, #639, #863, #862 diff --git a/docs/superpowers/spikes/2026-09-17-migrated-end-frame-submit-contract.md b/docs/superpowers/spikes/2026-09-17-migrated-end-frame-submit-contract.md new file mode 100644 index 000000000..388b516c8 --- /dev/null +++ b/docs/superpowers/spikes/2026-09-17-migrated-end-frame-submit-contract.md @@ -0,0 +1,71 @@ +# The migrated host's start+end submit contract + +**Date:** 2026-09-17 +**Account:** `ffroliva` (served `flow.google.com`), project `c5550ed7-…` +**Cost:** $0 — both frames really uploaded, the submit intercepted with `route.abort()` +**Harness:** `scripts/dev/spike_migrated_end_frame.py` (`--mode chips`, `--mode submit`) +**Issue:** [#639](https://github.com/ffroliva/gflow-cli/issues/639) · PR #831 + +## Why this spike exists + +PR #831 ported `--end-frame` to the migrated composer on the strength of one paid run on a +**contributor's** account, reported in the PR description. Two things made that +insufficient: the evidence was not re-runnable (no capture, no findings doc), and +`flow-capabilities-are-cohort-dependent` says a contract measured on one account does not +transfer. The committed spike script could not have produced it either — it raised +`NameError` on its first statement (`ruff F821`, two hits) and contained no network +capture at all. + +## Method + +Uploads are free; generating is not. So the probe attaches both frames through the +production path (`attach_start_frame` → `attach_end_frame`), types a prompt, then routes +`**/batchexecute*` and calls `route.abort()` on the first request whose rpcid is in +`SUBMIT_RPCS`. Playwright surfaces the request body *before* it leaves the browser, so the +contract is readable while Flow never sees the submit. Memory: +`credit-free-route-abort-verification`. + +## Findings + +| # | Question | Answer (measured) | +|---|---|---| +| 1 | Does an End chip exist on this cohort? | **Yes.** Frames sub-mode renders exactly two `button.empty-chip`, `Start` and `End`. | +| 2 | Does clicking End open the picker? | **Yes** — `flow-add-menu-popover-content` with its search input. | +| 3 | What rpc carries a start+end submit? | **`nprQif`** — confirms the PR. | +| 4 | Where does the rpc name travel? | **The URL `rpcids` query param.** `_body_rpcid(body)` did **not** find it. This **refutes** the PR's headline claim that "the rpc name travels in `f.req`, not the URL". | +| 5 | What model key? | **`omni_flash_i2v_8s_first_last`** — *not* the `veo_3_1_interpolation_lite` the PR reports. | +| 6 | Are both media ids in the body? | **Yes**, start and end, in a 3 910-byte body. | + +## What this settles + +- **The port is real and the rpc id is right.** `nprQif` reproduces on a second account. +- **The key is cohort-dependent**, so the driver must match on key *shape* + (`*_interpolation_*` or `*_first_last`) rather than a literal. Pinning either literal + would refuse a valid run on the other cohort. This is the `flow-capabilities-are-cohort-dependent` + pattern again, now with two data points instead of one. +- **The rpcid carrier is cohort-dependent too, or the original reading was wrong.** Here it + is in the URL. `_body_rpcid` is kept as a *second* carrier — harmless, and it covers the + contributor's report — but it is no longer described as the rule. +- **Adoption-by-content was removed.** Its predicate required `_i2v_` in the model key, + which `veo_3_1_interpolation_lite` does not contain, so it could never fire for the veo + case it was written for; and for the omni key it matched broadly enough to adopt any + future rpc echoing composer state, where a parse failure is fatal rather than skipped. + Both measured submit rpcids are in `SUBMIT_RPCS`, so nothing needs adopting. + +## Re-run it + +```bash +uv run python scripts/dev/spike_migrated_end_frame.py \ + --profile --project --mode chips + +uv run python scripts/dev/spike_migrated_end_frame.py \ + --profile --project --mode submit \ + --start-frame --end-frame +``` + +Both are $0. The same contract is asserted as a re-runnable regression by +`tests/e2e/test_migrated_host_e2e.py::test_e2e_end_frame_binds_the_second_chip_and_submits_interpolation` +(`-m e2e_auth`, zero credits). + +Raw output stays in the gitignored `scripts/dev/_spike_out/end_frame/`; it carries media +ids and a project id and is not committed. diff --git a/docs/superpowers/spikes/2026-09-17-project-create-on-flow-google-com.md b/docs/superpowers/spikes/2026-09-17-project-create-on-flow-google-com.md new file mode 100644 index 000000000..0fcef0d98 --- /dev/null +++ b/docs/superpowers/spikes/2026-09-17-project-create-on-flow-google-com.md @@ -0,0 +1,100 @@ +# Project creation: labs tRPC is retired, flow.google.com creates and renames by rpc + +**Date:** 2026-09-17 +**Accounts:** `ci-probe`, `denon82`, `flavio.oliva`, `promo-denon82` (`ffroliva` unmeasured — its +profile was held by another browser, and the lease was respected) +**Cost:** $0 — two empty projects created on `ci-probe`, no generation +**Harness:** `scripts/dev/spike_create_project_401.py`, `scripts/dev/spike_migrated_create_project.py` +**Issues:** [#864](https://github.com/ffroliva/gflow-cli/issues/864) (bundle) · #561 · #863 · #639 + +## Why this spike exists + +Generating with no `--project` (CLI) or no `project` (MCP) starts with +`labs.google/fx/api/trpc/project.createProject`. Reports disagreed about it: #561 saw 401 on +a flow.google.com account on v0.66.0 and 200 on v0.67.0 a day later; the 2026-09-15 +unentitled-account spike saw 401; #863 saw 401 over MCP. A fix keyed on "this host cannot +create projects" would have been an unmeasured capability claim, so the outcome +distribution came first. + +## Finding 1 — labs `createProject` never succeeded, and Google says why + +Three attempts per profile through `FlowApiClient.create_project` (the production path): + +| profile | labs session token | host served | `createProject` | +|---|---|---|---| +| `ci-probe` | yes | flow.google.com `/` | **404** ×3 | +| `denon82` | yes | flow.google.com `/about` | **404** ×3 | +| `flavio.oliva` | no | flow.google.com `/` | **401** ×3 | +| `promo-denon82` | no | flow.google.com `/about` | **401** ×3 | + +Stable per profile, 0 successes in 12. The 404 body is Google's own statement: + +> `Flow RPCs have been deprecated and disabled. Flow has migrated to https://flow.google.com.` + +The 401 is the same retired route reached by a session that carries no labs API token — +it is refused before the deprecation answer. Neither is an expired login, which is why +`gflow auth login` never helped anyone who followed that advice. + +**What this does not show.** Every profile here is served flow.google.com, so nothing +here observes an account served labs.google. The labs arm stays; the fix keys on the +observed refusal, not on the host. + +## Finding 2 — flow.google.com creates a project with one rpc, and takes the title + +On `flow.google.com/`, the only creation control is the floating action button inside +`flow-projects-page` carrying the `add` ligature (`button.mat-mdc-fab:has(mat-icon)`). +Structural, locale-invariant. Clicking it fired, before navigation: + +``` +rpcids=jHPbke +f.req [["jHPbke","[\"projects/*\",[null,[\"\"]],[null,22]]",null,"generic"]] +reply ["<project uuid>",["<title>"]] +``` + +and the page then landed on `flow.google.com/project/<uuid>`. The UI sends a timestamp +title (`Sep 17 - 17:02`); the rpc echoes it back with the id. + +## Finding 3 — rename is `o8DA4`, through the header's `flow-editable-text` + +On the project page, `flow-editable-text` holds one `input`. Filling it and pressing Enter +fired: + +``` +rpcids=o8DA4 +f.req [["o8DA4","[\"projects/<uuid>\",[\"<title>\"],[[\"project_title\"]],[null,22]]",null,"generic"]] +reply ["<title>"] +``` + +After a reload the input carried the new title: the rename persisted. + +## What this settles + +- No-project generation, `gflow project create` and `gflow project rename` are broken on + every profile we can measure, and the cause is the retired labs route — not auth. +- flow.google.com offers both operations through controls gflow can anchor structurally, + and each has an rpc reply that states the outcome (id + title), so success can be read + from the wire rather than inferred from a URL. + +## Not measured + +- An account served labs.google (none available). +- `ffroliva` (profile busy), and whether an `/about`-served account (`denon82`) can create + at all on flow.google.com — its app never mounts, so creation there is expected to fail + with the existing landing diagnosis. +- Localised UIs: anchors are structural, but no non-`en` profile was driven. + +## Addendum — aspect radios on the same host (2026-09-17) + +`scripts/dev/spike_migrated_aspect_radios.py --profile ffroliva`, settings pane only, nothing +submitted: + +| Mode | Aspect radios (ligature) | Other groups | +|---|---|---| +| Image | `crop_16_9` 16:9 · `crop_landscape` 4:3 · `crop_square` 1:1 · **`crop_portrait` 3:4** · `crop_9_16` 9:16 | count x1–x4 | +| Video | `crop_16_9` 16:9 · `crop_9_16` 9:16 | Frames / Ingredients · 360p / 720p · 4s/6s/8s/10s · count x1–x4 | + +The 2026-09-08 enumeration had four image radios, so gflow refused 3:4 there with exit 36; +the fifth (`crop_portrait`) is present now and is driven. Video offers exactly the two +aspects gflow already supports. The video resolution group (360p/720p) is what PR #787's +`--resolution` targets; it is not part of this bundle. `flavio.oliva` (no projects) could +not be enumerated — the spike needs a project to open — and is unmeasured. diff --git a/hatch_build.py b/hatch_build.py new file mode 100644 index 000000000..53bd94377 --- /dev/null +++ b/hatch_build.py @@ -0,0 +1,59 @@ +"""Put the documentation inside the wheel, so `gflow docs` can read it (#861). + +``docs/`` is not a Python package and must not become one, but the pages have to travel +with the distribution: the user in #861 is an agent that ran ``uv tool install gflow-cli`` +and has no checkout to fall back on. A command that can only print GitHub URLs to that +user does not solve the problem it was filed for. + +**Why a hook and not two lines of config.** The obvious form is +``force-include = { "docs" = "gflow_cli/_docs" }`` plus ``exclude`` for the parts that +should not ship. That was built and inspected on 2026-09-17, and **hatchling's exclude +patterns do not apply to forced inclusions**: 145 files under ``docs/superpowers/`` and 3 +under ``docs/assets/`` landed in the wheel anyway, 4.3 MB of them. The alternative -- +listing 126 pages by hand in ``pyproject.toml`` -- drifts the first time someone adds one. + +A glob resolved at build time is the only shape that ships exactly the top-level pages and +cannot go stale, because the glob IS the list. Measurement: +``docs/superpowers/plans/2026-09-17-gflow-docs-command/SCENARIO.md`` § M1. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from hatchling.builders.hooks.plugin.interface import BuildHookInterface + +#: Where the pages land inside the installed package. `gflow_cli.docs_catalog` reads this +#: name through `importlib.resources`; the two must agree. +DOCS_PACKAGE_DIR = "gflow_cli/_docs" + + +class DocsBuildHook(BuildHookInterface): # type: ignore[type-arg] + """Force-include every top-level ``docs/*.md`` as package data.""" + + PLUGIN_NAME = "custom" + + def initialize(self, version: str, build_data: dict[str, Any]) -> None: + # An editable install resolves `gflow_cli` to `src/`, so `_docs` there is never + # read -- `docs_catalog` finds the checkout's own `docs/` instead. Copying 126 + # pages into site-packages anyway left a directory named after the package with + # no `__init__.py` in it, recopied on every `uv sync` and stale by the next edit. + if version == "editable": + return + # Non-recursive on purpose: `docs/assets/` is 2 MB of images and + # `docs/superpowers/` is plans and spikes. Neither is a page the command lists, + # and shipping them was measured at 4.3 MB for no reader. + pages = sorted(Path(self.root, "docs").glob("*.md")) + if not pages: + # `Path.glob` on a missing directory returns empty, so without this the hook + # emits a structurally valid wheel in which `gflow docs` is empty for every + # user -- and `release.yml` is `uv build` then publish, with nothing in + # between that would notice. Measured: a sdist with `docs/` removed built a + # 767 KB wheel carrying zero pages and exit 0. Fail the build instead. + raise ValueError( + f"no documentation pages found under {Path(self.root, 'docs')} -- " + "`gflow docs` would ship empty; refusing to build (#861)" + ) + for page in pages: + build_data["force_include"][str(page)] = f"{DOCS_PACKAGE_DIR}/{page.name}" diff --git a/llms.txt b/llms.txt index 902dc3346..2f44ceaca 100644 --- a/llms.txt +++ b/llms.txt @@ -1,6 +1,6 @@ # gflow-cli -> Python CLI and MCP server for Google Flow — drives Veo (image-to-video, text-to-video, reference-to-video) and Imagen (text-to-image) generations from the terminal by reverse-engineering Flow's private REST API at aisandbox-pa.googleapis.com — and, for accounts Google has moved to flow.google.com (#639), that frontend's batchexecute wire: text-to-video, image-to-video from a local `--initial-frame` (no end frame, no UUID frame), reference-to-video from local `--ref` files, text-to-image, and image-to-image from local files today, `--project` required there; references by UUID or `@Name`, character entities, Agent instructions, Imagen 4, image batch and the 3:4 aspect ratio exit 36 (non-retryable); `GFLOW_CLI_FLOW_HOST` = `auto` (default: flow.google.com for every request it can serve) | `flow.google.com` | `labs.google` (kill switch). Works with any Google account that has Flow access (a paid AI Pro/Ultra plan only affects credit allowances and tier-gated features such as 4K upscale). Uses Playwright Chromium under the hood (one-time real-Chrome auth, then a persistent Chrome session driven via ui_automation). Production-stable for image generation and video T2V/I2V/R2V with `--model`/`--duration`/`--count` pickers and a `--ui-mode` UI-arm gate (video has only a classic driver, so `auto` ≡ `classic`; an explicit `--ui-mode agentic` is refused with exit 2 before any browser work, and an unreachable classic editor aborts pre-submit with exit 28 and zero credits); there is no `batch` video subcommand — the nonfunctional stub was removed in v0.41.0; loop `gflow video t2v`/`i2v` from the shell for multi-clip runs. Also ships `gflow character` (create/list/show/rm/voices — reusable, project-scoped Flow Character entities for consistent subjects across generations), `gflow scene` (create/show — compose ordered clips into a scene; `create --output` renders a credit-free server-side extended video), `gflow video chain` (last-frame I2V chaining from a JSONL manifest), and `gflow video extend` (continues an existing clip by chained 8s Veo extensions, seeded server-side from the source clip rather than an extracted still, landing as a Scene that `--output` renders to one file; note a segment carries ~7s of content though Flow bills 8s, so multi-segment renders hold a frozen second at each internal seam — see KNOWN_ISSUES). Keeps a local SQLite catalog of every generation, queryable via `gflow data list {projects,images,videos,profiles}`. Ships an MCP server (`gflow mcp run` stdio, `gflow serve` Streamable HTTP) with generate/list/instructions tools, a credit-free non-interactive `gflow_auth_status` session probe to call before spending credits, and a `--no-spend` mode (or `GFLOW_MCP_NO_SPEND=1`) that never registers the credit-spending tools. Operational guards (v0.56.0): refuses to open a profile with an older bundled Chromium major than last wrote it (exit 11, protects the session store); `GFLOW_CLI_LEASE_WAIT_SECONDS=N` lets a second same-profile invocation wait for the lease instead of failing fast; a once-a-day stderr banner reports newer PyPI versions (`GFLOW_CLI_UPDATE_CHECK=0` silences it — do so when piping stderr into `jq`), and `gflow update [--check] [--json]` upgrades in place through the installer that put it here (uv tool / pipx / pip; source installs refused, exit 11); `gflow doctor` is the read-only pre-flight diagnostic (exit 33 = findings present). MIT licensed, not affiliated with Google. +> Python CLI and MCP server for Google Flow — drives Veo (image-to-video, text-to-video, reference-to-video) and Imagen (text-to-image) generations from the terminal by reverse-engineering Flow's private REST API at aisandbox-pa.googleapis.com — and, for accounts Google has moved to flow.google.com (#639), that frontend's batchexecute wire: text-to-video, image-to-video from a local `--initial-frame` (no end frame, no UUID frame), reference-to-video from local `--ref` files, text-to-image, and image-to-image from local files today, `--project` required there; references by UUID or `@Name`, character entities, Agent instructions, Imagen 4, image batch and the 3:4 aspect ratio exit 36 (non-retryable); `GFLOW_CLI_FLOW_HOST` = `auto` (default: flow.google.com for every request it can serve) | `flow.google.com` | `labs.google` (kill switch). Works with any Google account that has Flow access (a paid AI Pro/Ultra plan only affects credit allowances and tier-gated features such as 4K upscale). Uses Playwright Chromium under the hood (one-time real-Chrome auth, then a persistent Chrome session driven via ui_automation). Production-stable for image generation and video T2V/I2V/R2V with `--model`/`--duration`/`--count` pickers and a `--ui-mode` UI-arm gate (video has only a classic driver, so `auto` ≡ `classic`; an explicit `--ui-mode agentic` is refused with exit 2 before any browser work, and an unreachable classic editor aborts pre-submit with exit 28 and zero credits); there is no `batch` video subcommand — the nonfunctional stub was removed in v0.41.0; loop `gflow video t2v`/`i2v` from the shell for multi-clip runs. Also ships `gflow character` (create/list/show/rm/voices — reusable, project-scoped Flow Character entities for consistent subjects across generations), `gflow scene` (create/show — compose ordered clips into a scene; `create --output` renders a credit-free server-side extended video), `gflow video chain` (last-frame I2V chaining from a JSONL manifest), and `gflow video extend` (continues an existing clip by chained 8s Veo extensions, seeded server-side from the source clip rather than an extracted still, landing as a Scene that `--output` renders to one file; note a segment carries ~7s of content though Flow bills 8s, so multi-segment renders hold a frozen second at each internal seam — see KNOWN_ISSUES). Keeps a local SQLite catalog of every generation, queryable via `gflow data list {projects,images,videos,profiles}`. Ships an MCP server (`gflow mcp run` stdio, `gflow serve` Streamable HTTP) with generate/list/instructions tools, a credit-free non-interactive `gflow_auth_status` session probe to call before spending credits, and a `--no-spend` mode (or `GFLOW_MCP_NO_SPEND=1`) that never registers the credit-spending tools. Operational guards (v0.56.0): refuses to open a profile with an older bundled Chromium major than last wrote it (exit 11, protects the session store); `GFLOW_CLI_LEASE_WAIT_SECONDS=N` lets a second same-profile invocation wait for the lease instead of failing fast; a once-a-day stderr banner reports newer PyPI versions (`GFLOW_CLI_UPDATE_CHECK=0` silences it — do so when piping stderr into `jq`), and `gflow update [--check] [--json]` upgrades in place through the installer that put it here (uv tool / pipx / pip; source installs refused, exit 11); `gflow doctor` is the read-only pre-flight diagnostic (exit 33 = findings present). `gflow docs` makes the bundled documentation reachable from the terminal — `gflow docs` lists the topics, `gflow docs <topic>` prints one as raw Markdown, and `gflow docs --search <term>` returns the matching lines with their `docs/FILE.md:LINE` positions; the pages ship inside the wheel, so it needs no checkout, no network and no account. MIT licensed, not affiliated with Google. Credit inspection is available through `gflow credits user|list` and the `gflow_get_credits` MCP tool, with a browser-free HTTP fast path and browser fallback. The reported balance funds Veo diff --git a/plugins/gflow/.claude-plugin/plugin.json b/plugins/gflow/.claude-plugin/plugin.json index 491818f04..45ef42fe1 100644 --- a/plugins/gflow/.claude-plugin/plugin.json +++ b/plugins/gflow/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "gflow", "displayName": "gflow-cli", - "version": "0.77.1", + "version": "0.78.0", "description": "Drive Google Flow from your agent: Veo text-to-video, image-to-video and clip extension, Imagen images, reusable Characters and scene composition.", "author": { "name": "Flavio Oliva", diff --git a/plugins/gflow/skills/gflow-cli/SKILL.md b/plugins/gflow/skills/gflow-cli/SKILL.md index b06a99d80..53187b636 100644 --- a/plugins/gflow/skills/gflow-cli/SKILL.md +++ b/plugins/gflow/skills/gflow-cli/SKILL.md @@ -70,13 +70,13 @@ gflow image t2i "<prompt>" [--model {nano2|nano-pro|image4}] \ [-n 1..4] [--out DIR] gflow image i2i "<prompt>" --ref PATH_OR_UUID [--ref ...] [...same as t2i] gflow image batch <manifest.tsv|manifest.json> [-n 1..4] [--aspect ...] [--out DIR] # shared project, up to 5 prompts -# On migrated flow.google.com accounts (#639), t2i and i2i are ported for an -# existing --project (required — exit 11 without it): i2i accepts local --ref -# files only, aspects 16:9/4:3/1:1/9:16. UUIDs, @Name/entity references, Imagen 4 -# (image4), --aspect 3:4, and image batch are refused there with exit 36. +# On migrated flow.google.com accounts (#639), t2i and i2i are ported (a project is +# created when --project is omitted, #864): i2i accepts local --ref files only, all +# five aspects. UUIDs, @Name/entity references, Imagen 4 (image4), and image batch +# are refused there with exit 36. # Video generation (Veo 3.1) -gflow video t2v "<prompt>" [--project ID] [--model ...] [--duration 4|6|8|10] [--out-dir DIR] [--aspect ...] # --project required on the migrated flow.google.com host (#639); 10s is omni-flash-only +gflow video t2v "<prompt>" [--project ID] [--model ...] [--duration 4|6|8|10] [--out-dir DIR] [--aspect ...] # without --project a project is created first, on either host (#864); 10s is omni-flash-only gflow video i2v --initial-frame <image|media-UUID> "<prompt>" [--out-dir DIR] [...same as t2v] # UUID = in-project asset, no re-upload (#287; pair with --project) # `gflow video` has no `batch` subcommand — that stub never worked and was # removed. For multi-clip runs, loop `gflow video t2v`/`i2v` from the shell. @@ -107,7 +107,8 @@ gflow instructions apply FILE --project ID # declarative full-syn gflow instructions toggle-mode (--on | --off) --project ID # toggle master agent switch # Keeping gflow-cli current (every command shows a banner when a newer release exists) -gflow update [--check] [--json] # upgrades via uv tool / pipx / pip; source installs refused (exit 11) +gflow update [--check] [--json] +gflow docs [TOPIC] [--search TERM] [--json] # upgrades via uv tool / pipx / pip; source installs refused (exit 11) ``` Every subcommand accepts `--profile <name>` (per-subcommand, not global) to drive multiple Google accounts side-by-side. @@ -279,7 +280,7 @@ Documented errors agents commonly make — negative examples for the SkillOpt tr | `--model imagen` / `--model quality` / `--model high` | `--model image4` (Imagen 3.5), `--model nano-pro` (Gem Pix 2), `--model nano2` (Narwhal) | | Python: `client = FlowApiClient(...)` then method calls | Must use `async with FlowApiClient(...) as client:` — it's an async context manager | | Python: `from gflow_cli import FlowApiClient` | `from gflow_cli.api.client import FlowApiClient` | -| `gflow video t2v`/`i2v`/`r2v` without `--project` on an account Google moved to `flow.google.com` (exit 11), or an unported UUID/entity reference or model (exit 36) | Pass `--project <id>` — migrated hosts support video t2v, local-file i2v/r2v, and image t2i/i2i (local refs) only; exit 36 is non-retryable, `GFLOW_CLI_FLOW_HOST=labs.google` is the kill switch (see USAGE § gflow video t2v / i2v / r2v and image sections) | +| `gflow video t2v`/`i2v`/`r2v` with an unported UUID/entity reference or model on an account served `flow.google.com` (exit 36) | Pass local files instead — migrated hosts support video t2v, local-file i2v/r2v, and image t2i/i2i (local refs) only; exit 36 is non-retryable, `GFLOW_CLI_FLOW_HOST=labs.google` is the kill switch (see USAGE § gflow video t2v / i2v / r2v and image sections) | | Telling a user on `flow.google.com` whose run exits **25** to retry, switch profile or pass `--ui-mode classic` | Their account's composer is **agent-only** — no classic arm exists, so aspect/model/count are Agent-settings defaults and gflow has no driver for it (`retryable: false`, $0, pre-submit). Nothing in gflow reaches it; the Flow web UI still works ([#799](https://github.com/ffroliva/gflow-cli/issues/799)). Exit 25 on `labs.google` IS the retryable A/B cohort — check the host before advising | | Suggesting a native `batch` subcommand under `gflow video` | It doesn't exist — that stub never worked and was removed. Loop `gflow video t2v`/`i2v` from the shell for multi-clip runs (`gflow image batch manifest.tsv\|json` is the real, working batch command, but it's image-only) | diff --git a/plugins/gflow/skills/video-production/SKILL.md b/plugins/gflow/skills/video-production/SKILL.md index ef1efa51c..21aa8cdfb 100644 --- a/plugins/gflow/skills/video-production/SKILL.md +++ b/plugins/gflow/skills/video-production/SKILL.md @@ -85,7 +85,7 @@ Load `https://labs.google/fx/tools/flow/project/<id>` in the profile's Chrome an | Lands on | Lane | What runs there | |---|---|---| | `labs.google/fx/…` | **A, full** | everything | -| `flow.google.com/project/…` | **B, partial** | `video t2v --project`, `video i2v --initial-frame <local file> --project`, **`video r2v --ref <local file> --project`** (ported in v0.70.0, #683), `character create` (v0.70.0), and **`image t2i` / `image i2i` with local `--ref` files (#639)**. Everything else — `image batch`, `extend`, `scene create`, `movie run`, r2v by `@Name` or `--reference-entity`, i2v by media UUID / `@Name` or with `--end-frame`, Imagen 4, and the 3:4 image aspect — exits 36 **[CONSTRAINT]** | +| `flow.google.com/project/…` | **B, partial** | `video t2v --project`, `video i2v --initial-frame <local file> --project`, **`video r2v --ref <local file> --project`** (ported in v0.70.0, #683), `character create` (v0.70.0), and **`image t2i` / `image i2i` with local `--ref` files (#639)**. Everything else — `image batch`, `extend`, `scene create`, `movie run`, r2v by `@Name` or `--reference-entity`, i2v by media UUID / `@Name`, and Imagen 4 — exits 36 **[CONSTRAINT]**. A **local** `--end-frame` IS ported (start+end interpolation, #639) | Lane B grows as forms are ported, so **confirm the row rather than trusting it**: the maintained list is [CONFIGURATION § `GFLOW_CLI_FLOW_HOST`](https://github.com/ffroliva/gflow-cli/blob/main/docs/CONFIGURATION.md#gflow_cli_flow_host) diff --git a/pyproject.toml b/pyproject.toml index 018e2f444..84779f2d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "gflow-cli" -version = "0.77.1" +version = "0.78.0" description = "CLI and MCP server for Google Flow — drive Veo text-to-video, image-to-video and Imagen image generation from the terminal." readme = "README.md" license = { file = "LICENSE" } @@ -174,6 +174,13 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/gflow_cli"] +# `gflow docs` reads the pages at runtime, so they have to be IN the wheel (#861). +# `force-include` + `exclude` does NOT work here -- hatchling ignores exclude patterns for +# forced inclusions, measured 2026-09-17 at 145 leaked `superpowers/` files. The hook globs +# `docs/*.md` instead, which ships exactly the pages and cannot drift. +[tool.hatch.build.targets.wheel.hooks.custom] +path = "hatch_build.py" + [tool.ruff] line-length = 100 target-version = "py311" diff --git a/scripts/ci/check_repo_hygiene.py b/scripts/ci/check_repo_hygiene.py index 9db0cbdc0..b0c128038 100644 --- a/scripts/ci/check_repo_hygiene.py +++ b/scripts/ci/check_repo_hygiene.py @@ -101,6 +101,11 @@ "RELEASE.md", "ROADMAP.md", "conftest.py", # root pytest conftest (basetemp + directory-based marker tagging) + # hatchling resolves a build hook's `path` from the project root and defaults to + # this exact name, so it cannot live under scripts/ without the config pointing + # back out to the root anyway. Same category as conftest.py: a root Python file + # the tooling requires there, not a stray artefact (#861). + "hatch_build.py", } ) diff --git a/scripts/dev/spike_create_project_401.py b/scripts/dev/spike_create_project_401.py new file mode 100644 index 000000000..a454abcba --- /dev/null +++ b/scripts/dev/spike_create_project_401.py @@ -0,0 +1,127 @@ +r"""Does ``project.createProject`` 401 deterministically on accounts served flow.google.com? ($0) + +Question (#864, #561, #863): generating without ``--project`` first calls +``labs.google/fx/api/trpc/project.createProject``. On accounts Flow serves from +flow.google.com that call has been seen returning 401 (surfaced as "run gflow auth +login"), and on one such account it later returned 200. Before any behaviour change +we need the outcome distribution, per profile, alongside the host each profile is +served — never inferring one from the other. + +Per profile, through gflow's own ``FlowApiClient`` (the production path): + +1. the labs session probe: HTTP status and whether it carries an ``access_token`` +2. the host served: navigate to the labs Flow root and classify the landed URL +3. ``create_project`` N times: outcome, exception class, status, route + +Cost: $0. Creating an empty project spends no credit and no quota. Each success +leaves one empty project titled ``gflow-spike-864`` behind (there is no delete route +in the client); remove them by hand in Flow. + +Pre-registered reading (written before the run): + +| createProject on a profile | Reading | +|--------------------------------|------------------------------------------------------------| +| N/N 401 | stable refusal for that account today | +| N/N 200 | works for that account today | +| mixed | flaps — a pre-emptive block would break working calls | +| profile fails before the call | unmeasured for that profile (say why) | + +A 401 on every flow.google.com profile and 200 on none still does NOT license +"flow.google.com accounts cannot create projects": host membership is uniform here +while capability has differed before (#561's 401 -> 200). Any fix must key on the +observed 401, not on the host. + + python scripts/dev/spike_create_project_401.py --profile ffroliva --profile ci-probe -n 3 +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import sys +from pathlib import Path +from typing import Any + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _spike_common import ( # noqa: E402, isort: skip + build_client, + default_out_path, + resolve_profile_dir, + step, +) + +from gflow_cli.api.transports._common import flow_host_kind # noqa: E402 +from gflow_cli.auth.verification import fetch_flow_session_httpx # noqa: E402 + +LABS_ROOT = "https://labs.google/fx/tools/flow" + + +async def _session_probe(profile_dir: Path) -> dict[str, Any]: + try: + status, body, _ = await fetch_flow_session_httpx(profile_dir) + except Exception as exc: # noqa: BLE001 - a spike records, never raises + return {"error": type(exc).__name__} + try: + parsed = json.loads(body) + except ValueError: + parsed = None + has_token = isinstance(parsed, dict) and bool(parsed.get("access_token")) + return {"status": status, "has_access_token": has_token} + + +async def _probe_profile(profile: str, attempts: int) -> dict[str, Any]: + profile_dir = resolve_profile_dir(profile) + result: dict[str, Any] = {"profile": profile, "session": await _session_probe(profile_dir)} + step("session", f"{profile}: {result['session']}") + try: + async with build_client(profile_dir) as client: + page = await client._checkout_page() # noqa: SLF001 - spike reads the real page + try: + await page.goto(LABS_ROOT, wait_until="domcontentloaded", timeout=45_000) + await page.wait_for_timeout(4_000) + result["landed_host_kind"] = flow_host_kind(page.url) + result["landed_path"] = page.url.split("?")[0].split("/", 3)[-1][:60] + finally: + client._checkin_page(page) # noqa: SLF001 + step("host", f"{profile}: {result['landed_host_kind']} /{result['landed_path']}") + + outcomes: list[dict[str, Any]] = [] + for i in range(attempts): + try: + info = await client.create_project(title="gflow-spike-864") + outcomes.append({"ok": True, "project_id_len": len(info.project_id)}) + except Exception as exc: # noqa: BLE001 + outcomes.append( + { + "ok": False, + "error": type(exc).__name__, + "status": getattr(exc, "status", None), + "route": getattr(exc, "route", None), + } + ) + step("create", f"{profile} #{i + 1}: {outcomes[-1]}") + await asyncio.sleep(2) + result["create_project"] = outcomes + except Exception as exc: # noqa: BLE001 + result["unmeasured"] = f"{type(exc).__name__}: {str(exc)[:200]}" + step("unmeasured", f"{profile}: {result['unmeasured']}") + return result + + +async def main() -> None: + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--profile", action="append", required=True) + ap.add_argument("-n", type=int, default=3) + args = ap.parse_args() + + results = [await _probe_profile(p, args.n) for p in args.profile] + out = default_out_path("spike_create_project_401") + out.write_text(json.dumps(results, indent=2), encoding="utf-8") + step("done", f"wrote {out}") + print(json.dumps(results, indent=2)) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/dev/spike_migrated_aspect_radios.py b/scripts/dev/spike_migrated_aspect_radios.py new file mode 100644 index 000000000..724c6979a --- /dev/null +++ b/scripts/dev/spike_migrated_aspect_radios.py @@ -0,0 +1,92 @@ +r"""Which aspect radios does flow.google.com's composer render, per mode? ($0) + +Question: the image driver refuses 3:4 because a 2026-09-08 enumeration on one account +found four aspect radios (`crop_16_9`, `crop_landscape`, `crop_square`, `crop_9_16`). A +2026-09-17 screenshot shows five on the image tab (16:9, 4:3, 1:1, 3:4, 9:16). This spike +re-enumerates every radiogroup in the settings pane for the Image and the Video mode and +records each radio's ligature and checked state — nothing is submitted. + +Pre-registered reading: + +| Outcome | Reading | +|-------------------------------------------|------------------------------------------------------| +| a fifth image radio with a new ligature | 3:4 is offered; that ligature is the anchor to add | +| four image radios | this account still gets four; cohort-dependent | +| video group differs from 16:9/9:16 | record it; video aspects need their own port | +| pane never opens | unmeasured for this profile | + + python scripts/dev/spike_migrated_aspect_radios.py --profile flavio.oliva +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import sys +from pathlib import Path +from typing import Any + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _spike_common import ( # noqa: E402, isort: skip + build_client, + default_out_path, + resolve_profile_dir, + step, +) + +from gflow_cli.api.transports.migrated_composer import ( # noqa: E402 + RADIOGROUP, + MigratedComposer, +) + +_GROUPS_JS = """ +(pane) => [...pane.querySelectorAll("[role='radiogroup']")].map((g) => [...g.querySelectorAll("[role='radio']")].map((r) => ({ + lig: [...r.querySelectorAll('mat-icon, i.google-symbols')].map(i => i.textContent.trim()).join('|'), + text: (r.innerText || '').replace(/\\s+/g, ' ').trim().slice(0, 24), + checked: r.getAttribute('aria-checked'), +}))) +""" + + +async def main() -> None: + ap = argparse.ArgumentParser(description=(__doc__ or "").splitlines()[0]) + ap.add_argument("--profile", required=True) + ap.add_argument("--project", default=None, help="project id; default: first on the grid") + args = ap.parse_args() + + out: dict[str, Any] = {"profile": args.profile} + async with build_client(resolve_profile_dir(args.profile)) as client: + page = client._page # noqa: SLF001 - spike reads the live page + assert page is not None + project = args.project + if project is None: + await page.goto("https://flow.google.com/", wait_until="domcontentloaded") + link = page.locator("a[href*='/project/']").first + await link.wait_for(state="visible", timeout=45_000) + project = (await link.get_attribute("href") or "").rsplit("/", 1)[-1] + composer = MigratedComposer() + await composer.ensure_editor(page, project, timeout_s=45.0) + # One pane for both modes: switching the mode radio re-renders the groups in + # place, while closing and reopening raced the overlay's teardown. + pane = await composer._open_pane(page) # noqa: SLF001 - production path + for mode in ("image", "videocam"): + await composer._select(page, pane, axis="mode", lig=mode) # noqa: SLF001 + await page.wait_for_timeout(1500) + handle = await page.locator(".cdk-overlay-pane").filter( + has=page.locator(RADIOGROUP) + ).last.element_handle() + out[mode] = await page.evaluate(_GROUPS_JS, handle) + step(mode, f"{len(out[mode])} radiogroups ({RADIOGROUP})") + for i, group in enumerate(out[mode]): + step(mode, f" group {i}: " + ", ".join(f"{r['lig']}[{r['text']}]" for r in group)) + await composer._close_pane(page, strict=False) # noqa: SLF001 + + path = default_out_path(f"spike_migrated_aspect_radios_{args.profile}") + path.write_text(json.dumps(out, indent=2), encoding="utf-8") + step("done", f"wrote {path}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/dev/spike_migrated_create_project.py b/scripts/dev/spike_migrated_create_project.py new file mode 100644 index 000000000..ca72d44d3 --- /dev/null +++ b/scripts/dev/spike_migrated_create_project.py @@ -0,0 +1,217 @@ +r"""How does flow.google.com create a project? ($0 — creates one empty project per --click run) + +Context (#864): labs.google's ``project.createProject`` now answers +``404 "Flow RPCs have been deprecated and disabled. Flow has migrated to +https://flow.google.com."`` (or 401 before that, for sessions without a labs token). +Every no-``--project`` generation path starts there, so project creation has to be +driven on flow.google.com. This spike records what that surface actually is. + +Mode 1 (default, read-only): land on ``flow.google.com/`` and inventory every +clickable control — tag, ARIA role/label presence, ``mat-icon`` ligature, href — +plus the custom elements present. Nothing is clicked. + +Mode 2 (``--click "<css>"``): same inventory, then click that selector and record + * every ``batchexecute`` request (rpcids + raw ``f.req``) and its response head + * any other non-static request to a Flow/Google API host + * the URL before and after, and the ``/project/<id>`` landed on, if any + * the page title and a title-ish input inventory after landing (for rename) + +Pre-registered reading: + +| Outcome | Reading | +|------------------------------------------------------|--------------------------------------------| +| click lands on /project/<uuid> and one rpcid fires | creation = that rpc; the UI path is viable | +| lands on /project/<uuid>, no rpc before navigation | project is created lazily (on first submit)| +| no navigation | wrong control, or a dialog intervenes | +| profile lands on /about | unmeasured for that profile | + +Raw captures go to ``scripts/dev/_spike_out/`` (gitignored): they carry cookies-adjacent +request bodies and must not be pasted anywhere unredacted. + + python scripts/dev/spike_migrated_create_project.py --profile ci-probe + python scripts/dev/spike_migrated_create_project.py --profile ci-probe --click "<css>" +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import sys +from pathlib import Path +from typing import Any +from urllib.parse import parse_qs, urlsplit + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _spike_common import ( # noqa: E402, isort: skip + build_client, + default_out_path, + resolve_profile_dir, + step, +) + +MIGRATED_ROOT = "https://flow.google.com/" + +_INVENTORY_JS = """ +() => { + const vis = (e) => { const r = e.getBoundingClientRect(); return r.width > 0 && r.height > 0; }; + const items = [...document.querySelectorAll( + 'button, a[href], [role="button"], [role="menuitem"], [tabindex="0"]')] + .filter(vis) + .slice(0, 120) + .map((e) => ({ + tag: e.tagName.toLowerCase(), + role: e.getAttribute('role'), + has_aria_label: e.hasAttribute('aria-label'), + href: e.getAttribute('href'), + icons: [...e.querySelectorAll('mat-icon, i.google-symbols')].map(i => i.textContent.trim()), + classes: (e.className && e.className.baseVal === undefined ? e.className : '').slice(0, 120), + parent_ce: (() => { let p = e.parentElement; while (p) { if (p.tagName.includes('-')) return p.tagName.toLowerCase(); p = p.parentElement; } return null; })(), + text_len: (e.innerText || '').trim().length, + })); + const ces = [...new Set([...document.querySelectorAll('*')].map(e => e.tagName.toLowerCase()).filter(t => t.includes('-')))]; + return { + url: location.href, + project_links: document.querySelectorAll('a[href*="/project/"]').length, + inputs: [...document.querySelectorAll('input, textarea, [contenteditable="true"]')].filter(vis).map(e => ({tag: e.tagName.toLowerCase(), type: e.getAttribute('type'), has_aria_label: e.hasAttribute('aria-label'), value_len: (e.value || e.innerText || '').length})), + title: document.title, + custom_elements: ces, + clickables: items, + }; +} +""" + + +async def _rename(args: argparse.Namespace) -> None: + """Mode 3: rename through the project header's ``flow-editable-text`` and record + which rpc carries it. Structural anchor only; the typed title is ours.""" + rpcs: list[dict[str, Any]] = [] + async with build_client(resolve_profile_dir(args.profile)) as client: + page = await client._context.new_page() # noqa: SLF001 + await page.goto( + f"https://flow.google.com/project/{args.rename_project}", + wait_until="domcontentloaded", + timeout=60_000, + ) + await page.wait_for_timeout(int(args.settle * 1000)) + box = page.locator("flow-editable-text").first + step( + "rename", + f"editable-text count={await page.locator('flow-editable-text').count()} " + f"inner_inputs={await box.locator('input').count()}", + ) + + async def on_response(resp: Any) -> None: + parts = urlsplit(resp.request.url) + if "batchexecute" in parts.path: + rpcs.append( + { + "rpcids": parse_qs(parts.query).get("rpcids"), + "status": resp.status, + "f_req": parse_qs(resp.request.post_data or "").get("f.req"), + "head": (await resp.text())[:400], + } + ) + + page.on("response", lambda r: asyncio.ensure_future(on_response(r))) + target = box.locator("input").first if await box.locator("input").count() else box + await target.click() + await page.wait_for_timeout(500) + inp = box.locator("input").first + step("rename", f"inputs after click={await box.locator('input').count()}") + await inp.fill(args.rename_to) + await inp.press("Enter") + await page.wait_for_timeout(5_000) + for r in rpcs: + step( + "rpc", + f"{r['rpcids']} {r['status']} f.req={(r['f_req'] or [''])[0][:300]} " + f"head={r['head'][:250]!r}", + ) + await page.reload(wait_until="domcontentloaded") + await page.wait_for_timeout(int(args.settle * 1000)) + value = await page.locator("flow-editable-text input").first.input_value() + step("rename", f"after reload value_matches={value == args.rename_to}") + + +async def main() -> None: + ap = argparse.ArgumentParser(description=(__doc__ or "").splitlines()[0]) + ap.add_argument("--profile", required=True) + ap.add_argument("--click", default=None, help="CSS selector to click (mode 2)") + ap.add_argument("--settle", type=float, default=8.0) + ap.add_argument("--rename-project", default=None, help="mode 3: project id to rename") + ap.add_argument("--rename-to", default="gflow-spike-864-renamed") + args = ap.parse_args() + if args.rename_project: + await _rename(args) + return + + profile_dir = resolve_profile_dir(args.profile) + out: dict[str, Any] = {"profile": args.profile, "click": args.click, "requests": []} + + async with build_client(profile_dir) as client: + page = await client._context.new_page() # noqa: SLF001 - spike reads the live context + capturing = False + + async def on_response(resp: Any) -> None: + if not capturing: + return + req = resp.request + if req.resource_type in {"image", "font", "stylesheet", "media", "script"}: + return + parts = urlsplit(req.url) + entry: dict[str, Any] = { + "method": req.method, + "host": parts.hostname, + "path": parts.path, + "status": resp.status, + } + if "batchexecute" in parts.path: + entry["rpcids"] = parse_qs(parts.query).get("rpcids") + entry["f_req"] = parse_qs(req.post_data or "").get("f.req") + try: + entry["response_head"] = (await resp.text())[:1500] + except Exception as exc: # noqa: BLE001 + entry["response_head"] = f"<{type(exc).__name__}>" + out["requests"].append(entry) + + page.on("response", lambda r: asyncio.ensure_future(on_response(r))) + + await page.goto(MIGRATED_ROOT, wait_until="domcontentloaded", timeout=60_000) + await page.wait_for_timeout(int(args.settle * 1000)) + out["before"] = await page.evaluate(_INVENTORY_JS) + step("before", f"url={out['before']['url']} projects={out['before']['project_links']}") + for i, c in enumerate(out["before"]["clickables"]): + step( + "ctl", + f"{i:02d} {c['tag']} role={c['role']} icons={c['icons']} href={c['href']} " + f"ce={c['parent_ce']} cls={c['classes'][:60]}", + ) + + if args.click: + capturing = True + await page.locator(args.click).first.click(timeout=15_000) + for _ in range(30): + await page.wait_for_timeout(1_000) + if "/project/" in page.url: + break + await page.wait_for_timeout(int(args.settle * 1000)) + capturing = False + out["after"] = await page.evaluate(_INVENTORY_JS) + step("after", f"url={out['after']['url']}") + step("after", f"inputs={out['after']['inputs']}") + for r in out["requests"]: + if r.get("rpcids") or r["host"] not in {"flow.google.com"}: + step( + "net", + f"{r['method']} {r['host']}{r['path']} {r['status']} rpcids={r.get('rpcids')}", + ) + + path = default_out_path(f"spike_migrated_create_project_{args.profile}") + path.write_text(json.dumps(out, indent=2), encoding="utf-8") + step("done", f"wrote {path}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/dev/spike_migrated_end_frame.py b/scripts/dev/spike_migrated_end_frame.py new file mode 100644 index 000000000..32344ff63 --- /dev/null +++ b/scripts/dev/spike_migrated_end_frame.py @@ -0,0 +1,288 @@ +# ruff: noqa: E501 +"""Measure the migrated host's End frame chip (issue #639 slice 2). + +The migrated composer refuses any request carrying an end frame because, per +``_unported_form``, "the End chip is unmeasured" (2026-09-05 spike). This script +measures it: it opens a migrated project, selects the Frames sub-mode, and dumps +the chip/picker DOM plus screenshots — read-only, zero credits, no uploads, no +submit. Output tells whether an End chip exists to drive and what anchors it. + +Usage (from the repo root, with the gflow venv python for Playwright): + <gflow-venv>/Scripts/python.exe scripts/dev/spike_migrated_end_frame.py \ + --profile presentation-reels-google \ + --project dcc9bde8-57c2-4694-a123-eeffc1b7e93a \ + --out-dir scripts/dev/_spike_out/end_frame +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src")) + +from playwright.async_api import Page # noqa: E402 + +from gflow_cli.api.client import FlowApiClient # noqa: E402 +from gflow_cli.api.transports.migrated_composer import ( # noqa: E402 + BOUND_CHIP, + EMPTY_CHIP, + FRAMES_LIGATURE, + INTERPOLATION_SUBMIT_RPC, + MODEL_KEY, + SUBMIT_RPCS, + MigratedComposer, + _body_rpcid, + _ligature, + _rpcid, +) +from gflow_cli.paths import default_home, profile_subdir # noqa: E402 + + +async def dump_chips(page, tag: str) -> dict: + empty = page.locator(EMPTY_CHIP) + bound = page.locator(BOUND_CHIP) + n_empty, n_bound = await empty.count(), await bound.count() + empties = [] + if n_empty: + empties = await empty.evaluate_all( + "els => els.map(el => ({html: el.outerHTML.slice(0, 800), " + "text: (el.innerText || '').trim().slice(0, 120)}))" + ) + print(f"[{tag}] empty chips: {n_empty}, bound chips: {n_bound}") + for e in empties: + print(f"[{tag}] empty text={e['text']!r}") + return {"empty_count": n_empty, "bound_count": n_bound, "empties": empties} + + +async def capture(profile_name: str, project_id: str, out_dir: Path) -> int: + profile_dir = profile_subdir(default_home(), profile_name) + if not profile_dir.exists(): + sys.exit(f"Profile dir does not exist: {profile_dir}") + out_dir.mkdir(parents=True, exist_ok=True) + print(f"Profile: {profile_name} ({profile_dir})") + + async with FlowApiClient(profile_dir=profile_dir, headless=False) as client: + page = await client._checkout_page() + try: + return await _capture_on_page(client, page, profile_name, project_id, out_dir) + finally: + client._checkin_page(page) + + +async def _capture_on_page( + client: FlowApiClient, page: Page, profile_name: str, project_id: str, out_dir: Path +) -> int: + composer = MigratedComposer() + # Production navigation, not a hand-built URL: `ensure_editor` is what the driver + # itself calls, so the spike lands the same way a real run does (and waits for the + # same READY_ANCHOR instead of a fixed sleep). + await composer.ensure_editor(page, project_id) + print("final url:", page.url) + dom = await page.evaluate( + "() => ({i_total: document.querySelectorAll('i').length, " + "mat_icons: document.querySelectorAll('mat-icon').length})" + ) + print("DOM:", dom) + await page.screenshot(path=str(out_dir / "1_project.png"), full_page=True) + + before = await dump_chips(page, "before-frames") + # Production path: settings pane -> video mode -> Frames sub-mode. + pane = await composer._open_pane(page) + await composer._select(page, pane, axis="mode", lig="videocam") + await composer._select(page, pane, axis="submode", lig=FRAMES_LIGATURE) + await composer._close_pane(page, strict=True) + clicked = "production _select path (mode=videocam, submode=frames)" + print("Frames sub-mode click:", clicked) + await page.wait_for_timeout(2500) + after = await dump_chips(page, "after-frames") + await page.screenshot(path=str(out_dir / "2_frames_mode.png"), full_page=True) + + # Probe the LAST empty chip (End candidate): does a click open the + # library picker (search box = PICKER_SEARCH)? When Start is already + # bound only one empty chip remains — selecting the last one cannot + # mis-target Start, since the attach flow requires Start before End. + end_probe: dict = {"attempted": False} + if after["empty_count"]: + end_probe["attempted"] = True + chips = page.locator(EMPTY_CHIP) + await chips.nth(after["empty_count"] - 1).click(timeout=4000) + await page.wait_for_timeout(2000) + search = page.locator("flow-add-menu-popover-content input[type='text']").first + try: + await search.wait_for(state="visible", timeout=5000) + end_probe["picker_opened"] = True + except Exception: + end_probe["picker_opened"] = False + await page.screenshot(path=str(out_dir / "3_end_chip_click.png"), full_page=True) + await page.keyboard.press("Escape") + print("End-chip probe:", end_probe) + + evidence = { + "profile_name": profile_name, + "project_id": project_id, + "final_url": page.url, + "dom": dom, + "frames_click": clicked, + "chips_before": before, + "chips_after": after, + "end_probe": end_probe, + } + (out_dir / "end_frame_evidence.json").write_text( + json.dumps(evidence, indent=2, ensure_ascii=False), encoding="utf-8" + ) + print(f"Evidence: {out_dir / 'end_frame_evidence.json'}") + verdict = ( + "PRESENT" + if end_probe.get("picker_opened") + else ("CHIPS_VISIBLE" if after["empty_count"] >= 2 else "ABSENT") + ) + print(f"END CHIP VERDICT: {verdict}") + return 0 if verdict != "ABSENT" else 2 + + +# --------------------------------------------------------------------------- +# Slice 3 (2026-09-17): the credit-free submit probe. +# +# The chip probe above proves an End chip EXISTS. It cannot prove what a bound +# End chip puts on the wire, and that contract -- rpc `nprQif`, model key +# `veo_3_1_interpolation_lite`, both frame ids -- is what the driver now asserts +# against before it lets Flow bill anything. Verifying it by generating costs a +# Veo credit per run and is not repeatable in review. +# +# So: attach both frames for real (uploads are free -- `maseQ`, image quota only), +# then ROUTE-ABORT the submit. Playwright hands us the request body before the +# request leaves the browser; aborting it means Google never sees the submit and +# nothing is billed. Memory: credit-free-route-abort-verification. +# --------------------------------------------------------------------------- + + +async def submit_probe( + profile_name: str, project_id: str, start_png: Path, end_png: Path, out_dir: Path +) -> int: + profile_dir = profile_subdir(default_home(), profile_name) + if not profile_dir.exists(): + sys.exit(f"Profile dir does not exist: {profile_dir}") + out_dir.mkdir(parents=True, exist_ok=True) + + captured: list[dict] = [] + async with FlowApiClient(profile_dir=profile_dir, headless=False) as client: + page = await client._checkout_page() + try: + composer = MigratedComposer() + await composer.ensure_editor(page, project_id) + + # Frames sub-mode, then both chips -- the production attach path. + pane = await composer._open_pane(page) + await composer._select(page, pane, axis="mode", lig="videocam") + await composer._select(page, pane, axis="submode", lig=FRAMES_LIGATURE) + await composer._close_pane(page, strict=True) + + start_id = await composer.attach_start_frame(page, project_id, start_png) + end_id = await composer.attach_end_frame(page, project_id, end_png) + print(f"start media id: {start_id}") + print(f"end media id: {end_id}") + + await composer.send_prompt(page, "a slow dolly between the two frames") + + async def block_submit(route, request) -> None: + body = request.post_data or "" + rpcid = _body_rpcid(body) or _rpcid(request.url) + if rpcid in SUBMIT_RPCS: + key = MODEL_KEY.search(body) + captured.append( + { + "rpcid": rpcid, + "rpcid_source": "f.req body" + if _body_rpcid(body) + else "url rpcids param", + "model_key": key.group(0) if key else None, + "carries_start_id": start_id in body, + "carries_end_id": end_id in body, + "body_bytes": len(body), + } + ) + await route.abort() # nothing reaches Google; nothing is billed + return + await route.continue_() + + await page.route("**/batchexecute*", block_submit) + submit = page.locator("button").filter(has=_ligature(page, "arrow_forward")).first + await submit.click(timeout=15000) + # The abort resolves fast; give the app a beat to actually issue it. + for _ in range(40): + if captured: + break + await page.wait_for_timeout(250) + await page.screenshot(path=str(out_dir / "4_submit_aborted.png"), full_page=True) + finally: + # Evidence before teardown: a raise past this point must not discard + # what we already measured. + (out_dir / "submit_probe_evidence.json").write_text( + json.dumps( + { + "profile_name": profile_name, + "project_id": project_id, + "captured_submits": captured, + "expected": { + "rpcid": INTERPOLATION_SUBMIT_RPC, + "model_key_shape": "*_interpolation_* or *_first_last", + }, + }, + indent=2, + ensure_ascii=False, + ), + encoding="utf-8", + ) + client._checkin_page(page) + + print(json.dumps(captured, indent=2)) + if not captured: + print("VERDICT: NO SUBMIT OBSERVED (the click did not produce a batchexecute)") + return 1 + hit = captured[0] + ok = ( + hit["rpcid"] == INTERPOLATION_SUBMIT_RPC + and hit["carries_start_id"] + and hit["carries_end_id"] + and hit["model_key"] + and ("interpolation" in hit["model_key"] or "first_last" in hit["model_key"]) + ) + print(f"VERDICT: {'CONFIRMED' if ok else 'MISMATCH'} -- {hit}") + return 0 if ok else 2 + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + # Required, not defaulted: a default profile/project points a maintainer's + # authenticated Chrome at someone else's account. + parser.add_argument("--profile", required=True) + parser.add_argument("--project", required=True) + parser.add_argument("--out-dir", default="scripts/dev/_spike_out/end_frame") + parser.add_argument( + "--mode", + choices=("chips", "submit"), + default="chips", + help="chips = DOM-only End-chip probe; submit = credit-free route-abort " + "capture of the interpolation submit body (needs --start-frame/--end-frame)", + ) + parser.add_argument("--start-frame", type=Path) + parser.add_argument("--end-frame", type=Path) + args = parser.parse_args() + out = Path(args.out_dir) + if args.mode == "submit": + if not args.start_frame or not args.end_frame: + parser.error("--mode submit requires --start-frame and --end-frame") + sys.exit( + asyncio.run( + submit_probe(args.profile, args.project, args.start_frame, args.end_frame, out) + ) + ) + sys.exit(asyncio.run(capture(args.profile, args.project, out))) + + +if __name__ == "__main__": + main() diff --git a/server.json b/server.json index 470dddf41..0a991fe9a 100644 --- a/server.json +++ b/server.json @@ -3,7 +3,7 @@ "name": "io.github.ffroliva/gflow-cli", "title": "gflow-cli", "description": "Drive Google Flow from an agent: Veo video and Imagen image generation", - "version": "0.77.1", + "version": "0.78.0", "websiteUrl": "https://ffroliva.github.io/gflow-cli/", "repository": { "url": "https://github.com/ffroliva/gflow-cli", @@ -14,7 +14,7 @@ "registryType": "pypi", "registryBaseUrl": "https://pypi.org", "identifier": "gflow-cli", - "version": "0.77.1", + "version": "0.78.0", "runtimeHint": "uvx", "transport": { "type": "stdio" diff --git a/skills/gflow-cli/SKILL.md b/skills/gflow-cli/SKILL.md index b06a99d80..53187b636 100644 --- a/skills/gflow-cli/SKILL.md +++ b/skills/gflow-cli/SKILL.md @@ -70,13 +70,13 @@ gflow image t2i "<prompt>" [--model {nano2|nano-pro|image4}] \ [-n 1..4] [--out DIR] gflow image i2i "<prompt>" --ref PATH_OR_UUID [--ref ...] [...same as t2i] gflow image batch <manifest.tsv|manifest.json> [-n 1..4] [--aspect ...] [--out DIR] # shared project, up to 5 prompts -# On migrated flow.google.com accounts (#639), t2i and i2i are ported for an -# existing --project (required — exit 11 without it): i2i accepts local --ref -# files only, aspects 16:9/4:3/1:1/9:16. UUIDs, @Name/entity references, Imagen 4 -# (image4), --aspect 3:4, and image batch are refused there with exit 36. +# On migrated flow.google.com accounts (#639), t2i and i2i are ported (a project is +# created when --project is omitted, #864): i2i accepts local --ref files only, all +# five aspects. UUIDs, @Name/entity references, Imagen 4 (image4), and image batch +# are refused there with exit 36. # Video generation (Veo 3.1) -gflow video t2v "<prompt>" [--project ID] [--model ...] [--duration 4|6|8|10] [--out-dir DIR] [--aspect ...] # --project required on the migrated flow.google.com host (#639); 10s is omni-flash-only +gflow video t2v "<prompt>" [--project ID] [--model ...] [--duration 4|6|8|10] [--out-dir DIR] [--aspect ...] # without --project a project is created first, on either host (#864); 10s is omni-flash-only gflow video i2v --initial-frame <image|media-UUID> "<prompt>" [--out-dir DIR] [...same as t2v] # UUID = in-project asset, no re-upload (#287; pair with --project) # `gflow video` has no `batch` subcommand — that stub never worked and was # removed. For multi-clip runs, loop `gflow video t2v`/`i2v` from the shell. @@ -107,7 +107,8 @@ gflow instructions apply FILE --project ID # declarative full-syn gflow instructions toggle-mode (--on | --off) --project ID # toggle master agent switch # Keeping gflow-cli current (every command shows a banner when a newer release exists) -gflow update [--check] [--json] # upgrades via uv tool / pipx / pip; source installs refused (exit 11) +gflow update [--check] [--json] +gflow docs [TOPIC] [--search TERM] [--json] # upgrades via uv tool / pipx / pip; source installs refused (exit 11) ``` Every subcommand accepts `--profile <name>` (per-subcommand, not global) to drive multiple Google accounts side-by-side. @@ -279,7 +280,7 @@ Documented errors agents commonly make — negative examples for the SkillOpt tr | `--model imagen` / `--model quality` / `--model high` | `--model image4` (Imagen 3.5), `--model nano-pro` (Gem Pix 2), `--model nano2` (Narwhal) | | Python: `client = FlowApiClient(...)` then method calls | Must use `async with FlowApiClient(...) as client:` — it's an async context manager | | Python: `from gflow_cli import FlowApiClient` | `from gflow_cli.api.client import FlowApiClient` | -| `gflow video t2v`/`i2v`/`r2v` without `--project` on an account Google moved to `flow.google.com` (exit 11), or an unported UUID/entity reference or model (exit 36) | Pass `--project <id>` — migrated hosts support video t2v, local-file i2v/r2v, and image t2i/i2i (local refs) only; exit 36 is non-retryable, `GFLOW_CLI_FLOW_HOST=labs.google` is the kill switch (see USAGE § gflow video t2v / i2v / r2v and image sections) | +| `gflow video t2v`/`i2v`/`r2v` with an unported UUID/entity reference or model on an account served `flow.google.com` (exit 36) | Pass local files instead — migrated hosts support video t2v, local-file i2v/r2v, and image t2i/i2i (local refs) only; exit 36 is non-retryable, `GFLOW_CLI_FLOW_HOST=labs.google` is the kill switch (see USAGE § gflow video t2v / i2v / r2v and image sections) | | Telling a user on `flow.google.com` whose run exits **25** to retry, switch profile or pass `--ui-mode classic` | Their account's composer is **agent-only** — no classic arm exists, so aspect/model/count are Agent-settings defaults and gflow has no driver for it (`retryable: false`, $0, pre-submit). Nothing in gflow reaches it; the Flow web UI still works ([#799](https://github.com/ffroliva/gflow-cli/issues/799)). Exit 25 on `labs.google` IS the retryable A/B cohort — check the host before advising | | Suggesting a native `batch` subcommand under `gflow video` | It doesn't exist — that stub never worked and was removed. Loop `gflow video t2v`/`i2v` from the shell for multi-clip runs (`gflow image batch manifest.tsv\|json` is the real, working batch command, but it's image-only) | diff --git a/skills/video-production/SKILL.md b/skills/video-production/SKILL.md index 8263fe04b..1d66df70d 100644 --- a/skills/video-production/SKILL.md +++ b/skills/video-production/SKILL.md @@ -85,7 +85,7 @@ Load `https://labs.google/fx/tools/flow/project/<id>` in the profile's Chrome an | Lands on | Lane | What runs there | |---|---|---| | `labs.google/fx/…` | **A, full** | everything | -| `flow.google.com/project/…` | **B, partial** | `video t2v --project`, `video i2v --initial-frame <local file> --project`, **`video r2v --ref <local file> --project`** (ported in v0.70.0, #683), `character create` (v0.70.0), and **`image t2i` / `image i2i` with local `--ref` files (#639)**. Everything else — `image batch`, `extend`, `scene create`, `movie run`, r2v by `@Name` or `--reference-entity`, i2v by media UUID / `@Name` or with `--end-frame`, Imagen 4, and the 3:4 image aspect — exits 36 **[CONSTRAINT]** | +| `flow.google.com/project/…` | **B, partial** | `video t2v --project`, `video i2v --initial-frame <local file> --project`, **`video r2v --ref <local file> --project`** (ported in v0.70.0, #683), `character create` (v0.70.0), and **`image t2i` / `image i2i` with local `--ref` files (#639)**. Everything else — `image batch`, `extend`, `scene create`, `movie run`, r2v by `@Name` or `--reference-entity`, i2v by media UUID / `@Name`, and Imagen 4 — exits 36 **[CONSTRAINT]**. A **local** `--end-frame` IS ported (start+end interpolation, #639) | Lane B grows as forms are ported, so **confirm the row rather than trusting it**: the maintained list is [CONFIGURATION § `GFLOW_CLI_FLOW_HOST`](../../docs/CONFIGURATION.md#gflow_cli_flow_host) diff --git a/src/gflow_cli/__init__.py b/src/gflow_cli/__init__.py index 35547b579..12031b3a2 100644 --- a/src/gflow_cli/__init__.py +++ b/src/gflow_cli/__init__.py @@ -1,3 +1,3 @@ """gflow-cli — CLI and MCP server for Google Flow.""" -__version__ = "0.77.1" +__version__ = "0.78.0" diff --git a/src/gflow_cli/api/client.py b/src/gflow_cli/api/client.py index ba3ba1a2b..1e3e74afb 100644 --- a/src/gflow_cli/api/client.py +++ b/src/gflow_cli/api/client.py @@ -1804,12 +1804,42 @@ async def _run_with_retry(self, attempt: Any, *, route: str) -> Any: async def create_project(self, title: str | None = None) -> ProjectInfo: """Bootstrap a fresh Flow project. Title defaults to a timestamp. - Maps to `POST .../trpc/project.createProject`. + Maps to `POST .../trpc/project.createProject`, and to flow.google.com's projects + page when that route refuses (#864, see :meth:`_labs_project_route_refused`). """ title = title or _default_project_title() - body = {"json": {"projectTitle": title, "toolName": "PINHOLE"}} - data = await self._post_json(routes.CREATE_PROJECT, body, content_type=_APPLICATION_JSON) - return ProjectInfo.from_create_response(data) + if self.settings.flow_host != "flow.google.com": + body = {"json": {"projectTitle": title, "toolName": "PINHOLE"}} + try: + data = await self._post_json( + routes.CREATE_PROJECT, body, content_type=_APPLICATION_JSON + ) + return ProjectInfo.from_create_response(data) + except (AuthExpiredError, WireFormatError) as exc: + if not self._labs_project_route_refused(exc): + raise + from gflow_cli.api.transports import migrated_composer # noqa: PLC0415 + + page = await self._checkout_page() + try: + return await migrated_composer.create_project(page, title) + finally: + self._checkin_page(page) + + def _labs_project_route_refused(self, exc: AuthExpiredError | WireFormatError) -> bool: + """Is this the labs project route's measured refusal, worth trying flow.google.com? + + Measured 2026-09-17 on four profiles, 12/12: 404 "Flow RPCs have been deprecated + and disabled" for a session holding a labs token, 401 for one without. Keyed on + that observed answer, never on which host an account is served — and only under + ``flow_host=auto``, so an operator who pinned labs.google keeps the labs error. + A 401 from a genuinely signed-out profile goes on to fail on flow.google.com too, + where the landing diagnosis names what the browser actually saw. + """ + refused = self.settings.flow_host == "auto" and exc.status in (401, 404) + if refused: + logger.info("project.labs_route_refused", status=exc.status, issue_ref="#864") + return refused async def get_credits(self) -> CreditsInfo: """Return the authenticated profile's current Flow credit balance.""" @@ -1833,10 +1863,26 @@ async def get_credits(self) -> CreditsInfo: async def rename_project(self, project_id: str, new_title: str) -> JsonObject: """Rename an existing Flow project. - Maps to `POST .../trpc/project.renameProject`. + Maps to `POST .../trpc/project.renameProject`, and to the project's header title + on flow.google.com when that route refuses (#864). Returns ``{}`` there. """ - body = {"json": {"projectId": project_id, "projectTitle": new_title}} - return await self._post_json(routes.RENAME_PROJECT, body, content_type=_APPLICATION_JSON) + if self.settings.flow_host != "flow.google.com": + body = {"json": {"projectId": project_id, "projectTitle": new_title}} + try: + return await self._post_json( + routes.RENAME_PROJECT, body, content_type=_APPLICATION_JSON + ) + except (AuthExpiredError, WireFormatError) as exc: + if not self._labs_project_route_refused(exc): + raise + from gflow_cli.api.transports import migrated_composer # noqa: PLC0415 + + page = await self._checkout_page() + try: + await migrated_composer.rename_project(page, project_id, new_title) + finally: + self._checkin_page(page) + return {} async def patch_agent_info( self, @@ -2878,6 +2924,15 @@ async def generate_video( msg, ) + if project_id is None: + # #864: as generate_image does. Leaving it to the transport meant the labs + # gallery's "new project" click, which cannot reach flow.google.com's + # projects page; create_project reaches both hosts. + try: + project_id = (await self.create_project()).project_id + except Exception as e: + await self._raise_with_incident(e, phase="video_generation") + wrapped_on_started = on_started if on_checkpoint is not None: observer: GenerationCheckpointObserver = on_checkpoint diff --git a/src/gflow_cli/api/transports/migrated_composer.py b/src/gflow_cli/api/transports/migrated_composer.py index b89461415..7114c8c43 100644 --- a/src/gflow_cli/api/transports/migrated_composer.py +++ b/src/gflow_cli/api/transports/migrated_composer.py @@ -36,17 +36,21 @@ import time from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING, Any, Literal +from typing import TYPE_CHECKING, Any, Literal, cast from urllib.parse import unquote_plus, urlsplit from uuid import uuid4 import structlog from playwright.async_api import TimeoutError as PlaywrightTimeoutError -from gflow_cli.api.dto import GeneratedImage +from gflow_cli.api.dto import GeneratedImage, ProjectInfo from gflow_cli.api.image import Aspect as ImageAspect from gflow_cli.api.image import Model as ImageModel -from gflow_cli.api.transports._common import extract_project_id, raise_if_known_landing +from gflow_cli.api.transports._common import ( + extract_project_id, + raise_if_known_landing, + safe_page_url, +) from gflow_cli.api.transports.batchexecute import ( GenerationRecord, generation_record, @@ -76,6 +80,8 @@ from gflow_cli.redaction import redact_sensitive_text if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + from playwright.async_api import Page from gflow_cli.api.image import GenerateImageRequest @@ -164,7 +170,13 @@ #: t2v submits on ``YhhmEf``, i2v on ``eb1hJf``, and an Ingredients (r2v) run on #: ``MZZa6b`` — measured 2026-09-05. Watching only the first is why r2v looked #: for several rounds like it never submitted at all. -SUBMIT_RPCS = ("YhhmEf", "eb1hJf", "MZZa6b") +SUBMIT_RPCS = ("YhhmEf", "eb1hJf", "MZZa6b", "nprQif") +#: The start+end (interpolation) submit. Same composer, different contract: the model +#: key is ``veo_3_1_interpolation_lite``, the body carries BOTH frame ids, and the rpc +#: name travels in the ``f.req`` body — the URL carries no ``rpcids`` query param +#: (captured 2026-09-16, issue #639). The reply embeds the standard ``CAE`` record, +#: so status/terminal/download are shared with the other submit rpcs. +INTERPOLATION_SUBMIT_RPC = "nprQif" IMAGE_SUBMIT_RPC = "ogiZ0b" STATUS_RPCS = ("jwpduf", "as29s") UPLOAD_RPC = "maseQ" @@ -363,10 +375,7 @@ def matches(self, text: str) -> bool: Aspect.LANDSCAPE: "crop_16_9", Aspect.PORTRAIT: "crop_9_16", } -#: Ligature per aspect. Only the four in :data:`IMAGE_ASPECT_LIGATURE_MEASURED` -#: were observed on the migrated host; ``crop_portrait`` is this driver's guess at -#: what a 3:4 radio WOULD be called, kept so that adding it later is a one-line -#: change, and deliberately not reachable until something measures it. +#: Ligature per aspect radio in the migrated composer's image settings. IMAGE_ASPECT_LIGATURE: dict[ImageAspect, str] = { ImageAspect.LANDSCAPE: "crop_16_9", ImageAspect.PORTRAIT: "crop_9_16", @@ -375,17 +384,11 @@ def matches(self, text: str) -> bool: ImageAspect.PORTRAIT_THREE_FOUR: "crop_portrait", } -#: The aspects actually enumerated in the migrated composer's radiogroup — -#: ``[crop_16_9*, crop_landscape, crop_square, crop_9_16]``, one account, -#: 2026-09-08 (docs/superpowers/spikes/2026-09-08-migrated-image-submit-wire.md). -IMAGE_ASPECT_LIGATURE_MEASURED: frozenset[ImageAspect] = frozenset( - { - ImageAspect.LANDSCAPE, - ImageAspect.PORTRAIT, - ImageAspect.SQUARE, - ImageAspect.LANDSCAPE_FOUR_THREE, - }, -) +#: The aspects actually enumerated in that radiogroup. Four on 2026-09-08 +#: (docs/superpowers/spikes/2026-09-08-migrated-image-submit-wire.md); five on +#: 2026-09-17, when ``crop_portrait`` (3:4) appeared between ``crop_square`` and +#: ``crop_9_16`` (scripts/dev/spike_migrated_aspect_radios.py, #864). +IMAGE_ASPECT_LIGATURE_MEASURED: frozenset[ImageAspect] = frozenset(IMAGE_ASPECT_LIGATURE) IMAGE_MODEL_MENU_MATCHERS: dict[ImageModel, ModelMenuMatcher] = { # Exact enough to exclude the separate "Nano Banana 2 Lite" entry without # depending on the decorative banana glyph that precedes both live labels. @@ -396,10 +399,12 @@ def matches(self, text: str) -> bool: def _unported_form(request: GenerateVideoRequest) -> str | None: """The noun for what this request asks of the new host that slice 1 does not - drive, or ``None`` when the composer takes it. i2v is ported for a **local** - start frame only: the Frames picker on this host lists assets by display name - with no UUID in its DOM (2026-09-05 spike), so a frame given by media UUID or - ``@Name`` has nothing to anchor on yet, and the End chip is unmeasured.""" + drive, or ``None`` when the composer takes it. i2v is ported for **local** + start AND end frames: the Frames picker on this host lists assets by display + name with no UUID in its DOM (2026-09-05 spike), so a frame given by media UUID + or ``@Name`` has nothing to anchor on yet. The End chip itself was measured on + 2026-09-15 (scripts/dev/spike_migrated_end_frame.py): two empty chips, Start + and End by text, and clicking the End chip opens the library picker.""" # Character entities are a different attach surface (a chip with an entity_id, in a # different wire slot) and unported. This check is MODE-INDEPENDENT and must stay # ahead of every early return (#716): it used to live inside the R2V branch, so a @@ -462,8 +467,10 @@ def _unported_form(request: GenerateVideoRequest) -> str | None: return None if request.mode is not Mode.I2V: # pragma: no cover - a fourth Mode would land here return f"the {request.mode.value} mode" - if request.end_image or request.end_image_ref_id or request.end_image_ref_name: - return "an end frame" + if request.end_image_ref_id: + return "an end frame given by Flow media UUID" + if request.end_image_ref_name: + return "an end frame given by @Name" if request.start_image_ref_id: return "a frame given by Flow media UUID" if request.start_image_ref_name: @@ -475,11 +482,13 @@ def _unported_form(request: GenerateVideoRequest) -> str | None: def migrated_can_serve(request: GenerateVideoRequest, project_id: str | None) -> bool: """Can the migrated composer take this request as it stands? Text-to-video, or - image-to-video / reference-to-video from **local** files, in an existing project, - with a model the new host offers (or none). Everything else — an end frame, a - frame or reference by UUID or ``@Name``, character references, a fresh project, - a model this host has not been observed to offer — is not ported yet, so an - account still served labs.google keeps the labs driver for it. + image-to-video / reference-to-video from **local** files (start and end frames + alike), in an existing project, with a model the new host offers (or none). + Everything else — a frame or reference by UUID or ``@Name``, character + references, a model this host has not been observed to offer — is not ported + yet, so an account still served labs.google keeps the labs driver for it. A + request with no project is not decided here: ``FlowApiClient.generate_video`` + creates one before any route is chosen (#864). Note what that last clause does NOT claim. A 2026-09-14 survey found ``labs.google/fx/tools/flow`` returning **HTTP 308** on the three profiles here @@ -512,12 +521,9 @@ def _unported_image_form(request: GenerateImageRequest) -> str | None: if request.model not in IMAGE_MODEL_MENU_MATCHERS: return f"the {request.model.value} model" if request.aspect not in IMAGE_ASPECT_LIGATURE_MEASURED: - # The aspect radiogroup was enumerated once on this host and carried four - # radios — crop_16_9, crop_landscape, crop_square, crop_9_16 — with no - # crop_portrait. Refusing here is the difference between exit 36 ("gflow - # has not ported this") and exit 23 ("file a frontend-drift bug"), and the - # second is a lie: nothing is drifting. If a later enumeration finds the - # radio, move the aspect into the measured map rather than deleting this. + # Every aspect gflow knows is measured today (#864). Kept so a new aspect + # added to the enum is refused as unported (exit 36), never left to miss its + # radio and report frontend drift (exit 23) about a frontend behaving fine. return f"the {request.aspect.value} aspect ratio" return None @@ -582,6 +588,17 @@ def _rpcid(url: str) -> str | None: return m.group(1) if m else None +def _body_rpcid(body: str) -> str | None: + """The rpc name inside a ``batchexecute`` POST body, or ``None``. + + The interpolation submit (``nprQif``) carries no ``rpcids`` query param — the + name lives at the head of ``f.req``. ``_post_data`` form-decodes first, so the + match runs on plain text either way. + """ + m = re.search(r'f\.req=\[\[\["([A-Za-z0-9]+)"', body or "") + return m.group(1) if m else None + + def _first_uuid(text: str) -> str | None: """The first UUID in a ``batchexecute`` reply — for ``maseQ`` that is the new media id (``[media_id, project_id, …]``, measured 2026-09-05).""" @@ -611,10 +628,13 @@ def _post_data(request: Any) -> str: return unquote_plus(body) if body else "" -def _i2v_body_problem(body: str, rpcid: str, media_id: str) -> str | None: +def _i2v_body_problem( + body: str, rpcid: str, media_id: str, end_media_id: str | None = None +) -> str | None: """Why this submit body is NOT the image-to-video generation the user asked for, or ``None``. A t2v key means the chip was empty at submit time; a body without - the uploaded id means the picker bound some other asset.""" + the uploaded id means the picker bound some other asset. When ``end_media_id`` + is given the body must also carry the bound end frame.""" if not body: return ( f"migrated host: the {rpcid} submit body could not be read, so the " @@ -635,6 +655,62 @@ def _i2v_body_problem(body: str, rpcid: str, media_id: str) -> str | None: f"frame {media_id} (ids in the body: {', '.join(other[:4]) or 'none'}) — the " "picker bound a different asset" ) + if end_media_id is not None and end_media_id not in body: + other = [u for u in UUID_RE.findall(body) if u.lower() != end_media_id.lower()] + return ( + f"migrated host: the {rpcid} submit body does not carry the uploaded end " + f"frame {end_media_id} (ids in the body: {', '.join(other[:4]) or 'none'}) — the " + "picker bound a different asset" + ) + return None + + +def _interpolation_body_problem( + body: str, rpcid: str, start_media_id: str | None, end_media_id: str | None +) -> str | None: + """Why this submit body is NOT the start+end interpolation run, or ``None``. + + Interpolation is keyed off the bound END frame: without one the app must never + send this rpc, so seeing it on a start-only run is a wire-format error rather + than a run to adopt. With both frames bound the body must carry the + interpolation model key and both uploaded ids (captured 2026-09-16, #639). + """ + if start_media_id is None or end_media_id is None: + return ( + f"migrated host: the app submitted on {rpcid} (start+end interpolation) " + "for a run with no bound end frame — refusing to bill an interpolation " + "the request never asked for" + ) + if not body: + return ( + f"migrated host: the {rpcid} submit body could not be read, so the " + "start+end interpolation request could not be confirmed before Flow " + "acted on it" + ) + key = MODEL_KEY.search(body) + key_text = key.group(0) if key else "no model key" + # Interpolation keys differ per model: veo uses ``*_interpolation_*`` and omni + # uses ``*_i2v_*s_first_last``. Both are MEASURED, not inferred — + # ``veo_3_1_interpolation_lite`` on the contributor's account (#831) and + # ``omni_flash_i2v_8s_first_last`` here on 2026-09-17 (see + # docs/superpowers/spikes/2026-09-17-migrated-end-frame-submit-contract.md). + # Matching on key SHAPE rather than a pinned literal is deliberate: the two + # cohorts disagree on the key, so a literal would refuse a valid run on one of + # them. A plain i2v/t2v key means a frame dropped before the app submitted. + if "interpolation" not in key_text and "first_last" not in key_text: + return ( + f"migrated host: the submit went out on {rpcid} with {key_text} for a " + "start+end (interpolation) request — expected an interpolation " + "model key (one of the frames was not bound when the app submitted)" + ) + for label, media_id in (("start", start_media_id), ("end", end_media_id)): + if media_id not in body: + other = [u for u in UUID_RE.findall(body) if u.lower() != media_id.lower()] + return ( + f"migrated host: the {rpcid} submit body does not carry the uploaded " + f"{label} frame {media_id} (ids in the body: " + f"{', '.join(other[:4]) or 'none'}) — the picker bound a different asset" + ) return None @@ -1173,6 +1249,13 @@ async def _dismiss_cookie_bar(self, page: Page) -> None: async def _open_pane(self, page: Page) -> Any: await self._dismiss_cookie_bar(page) + # And again here, not only in `ensure_editor` (#859). That probe runs one frame + # after `domcontentloaded`, before Angular has rendered anything, so the promo + # modal it is looking for does not exist yet and `is_visible()` returns False -- + # which is why a run blocked by one logs NEITHER `migrated.dialog_dismissed` nor + # `migrated.dialog_not_dismissed`. By the time the first real click is made the + # dialog IS up, and its CDK backdrop is what the post-mortem named. + await self._dismiss_dialog(page) trigger = page.locator(READY_ANCHOR).first try: # Visibility, not `count()`. Agent mode leaves the trigger in the DOM under a @@ -1476,6 +1559,22 @@ async def attach_start_frame(self, page: Page, project_id: str, image_path: Path await self._pick_frame_by_name(page, display_name, media_id) return media_id + async def attach_end_frame(self, page: Page, project_id: str, image_path: Path) -> str: + """Upload ``image_path`` through the editor's own Upload entry, bind it on the + End chip by file name, and return the media id — mirroring + :meth:`attach_start_frame`. The Start chip must already be bound: the pick + clicks the first REMAINING empty chip and then requires two bound chips, so + a silently unbound Start surfaces here instead of mis-binding End. + """ + from gflow_cli.api.client import validate_image_file # noqa: PLC0415 - cycle + + await validate_image_file(image_path) + media_id, display_name = await self._upload_via_toolbar(page, project_id, image_path) + await self._pick_frame_by_name( + page, display_name, media_id, chip_label="End", expect_bound_chips=2 + ) + return media_id + async def _upload_via_toolbar( self, page: Page, project_id: str, image_path: Path ) -> tuple[str, str]: @@ -1836,13 +1935,14 @@ async def _mention_by_name(self, page: Page, name: str, *, expect_chips: int) -> ) @staticmethod - async def _open_frame_picker(page: Page) -> Any: - """Click the empty Start chip and wait for the library picker's search box.""" + async def _open_frame_picker(page: Page, chip_label: str = "Start") -> Any: + """Click the first remaining empty chip (Start, then End once Start is bound) + and wait for the library picker's search box.""" chip = page.locator(EMPTY_CHIP).first if not await chip.count(): raise UiSelectorDriftError( detail=( - f"migrated host: no empty Start chip ({EMPTY_CHIP}) to bind the frame " + f"migrated host: no empty {chip_label} chip ({EMPTY_CHIP}) to bind the frame " "on — is the Frames submode selected? (host=migrated)" ), ) @@ -1856,21 +1956,36 @@ async def _open_frame_picker(page: Page) -> Any: raise UiSelectorDriftError( detail=( f"migrated host: the frame picker ({PICKER}) did not open within " - f"{FRAME_PICKER_OPEN_S:.0f}s of clicking the Start chip (host=migrated)" + f"{FRAME_PICKER_OPEN_S:.0f}s of clicking the {chip_label} chip (host=migrated)" ), ) from e return picker - async def _pick_frame_by_name(self, page: Page, name: str, media_id: str) -> None: - """Start chip → the library picker → search by display name → first option → - the chip must now hold a thumbnail. An unbound chip is refused here: an empty - Frames submit goes out as text-to-video (the labs #125 shape on this host).""" + async def _pick_frame_by_name( + self, + page: Page, + name: str, + media_id: str, + *, + chip_label: str = "Start", + expect_bound_chips: int = 1, + ) -> None: + """Empty chip → the library picker → search by display name → first option → + then ``expect_bound_chips`` bound thumbnails must be visible (1 after Start, + 2 after End). An unbound chip is refused here: an empty Frames submit goes + out as text-to-video (the labs #125 shape on this host).""" for attempt in range(1, FRAME_SEARCH_ATTEMPTS + 1): - picker = await self._open_frame_picker(page) + picker = await self._open_frame_picker(page, chip_label) search = picker.locator(PICKER_SEARCH).first await search.click(timeout=4000) await page.keyboard.insert_text(name) - options = picker.locator(PICKER_OPTION).filter(has_text=_exact(name)) + # Containment, never `_exact` (#860): a picker tile is an icon node followed + # by the file name, and a locator's text is the concatenation of both -- + # `imageshero-ab12cd34.png` for an asset named `hero-ab12cd34.png`. An anchored + # match can therefore never hold on this cohort. The display name is + # run-unique by construction (#792), so a substring is exact enough to be + # unable to bind a stale copy of the same file. + options = picker.locator(PICKER_OPTION).filter(has_text=name) try: await options.first.wait_for( state="visible", timeout=int(FRAME_PICKER_OPEN_S * 1000) @@ -1947,15 +2062,18 @@ async def _pick_frame_by_name(self, page: Page, name: str, media_id: str) -> Non ), ) from e try: - await page.locator(BOUND_CHIP).first.wait_for( - state="visible", timeout=int(FRAME_THUMB_VISIBLE_S * 1000) + await ( + page.locator(BOUND_CHIP) + .nth(expect_bound_chips - 1) + .wait_for(state="visible", timeout=int(FRAME_THUMB_VISIBLE_S * 1000)) ) except Exception as e: raise UiSelectorDriftError( detail=( - f"migrated host: the Start chip did not bind {name!r} — no " - f"{BOUND_CHIP} within {FRAME_THUMB_VISIBLE_S:.0f}s of the pick; " - "refusing to submit what would go out as text-to-video (host=migrated)" + f"migrated host: the {chip_label} chip did not bind {name!r} — " + f"fewer than {expect_bound_chips} bound chip(s) within " + f"{FRAME_THUMB_VISIBLE_S:.0f}s of the pick; refusing to submit what " + "would go out as text-to-video (host=migrated)" ), ) from e log.info("migrated.frame_bound", media_id=media_id) @@ -1988,35 +2106,75 @@ async def submit_and_observe( on_started: VideoStartedCallback | None, project_id: str | None, expect_media_id: str | None = None, + expect_end_media_id: str | None = None, expect_reference_ids: tuple[str, ...] = (), ) -> GenerationRecord: - """Click submit, then read the page's own ``YhhmEf``/``eb1hJf`` / ``jwpduf`` / - ``as29s`` replies until the record is terminal. Fires ``on_started`` as soon - as the submit reply names the media id — before the poll, as the labs path does. - - ``expect_media_id`` (i2v) and ``expect_reference_ids`` (r2v) inspect the submit - *request* the app sends: its body must carry those ids and the matching ``_i2v_`` - / ``_r2v_`` model key, else the run is a :class:`WireFormatError` — the generation - the user asked for is not the one Flow is billing.""" + """Click submit, then read the page's own submit / ``jwpduf`` / ``as29s`` + replies until the record is terminal. Fires ``on_started`` as soon as the + submit reply names the media id — before the poll, as the labs path does. + + ``expect_media_id`` (i2v) and ``expect_reference_ids`` (r2v) inspect the + submit *request* the app sends: its body must carry those ids and the + matching model key, else the run is a :class:`WireFormatError` — the + generation the user asked for is not the one Flow is billing. A bound end + frame (``expect_end_media_id``) switches the submit to the interpolation + rpc (``nprQif``), whose name travels in the ``f.req`` body rather than the + URL; that body must carry the interpolation key and both frame ids.""" loop = asyncio.get_running_loop() submitted: asyncio.Future[GenerationRecord] = loop.create_future() route_error: asyncio.Future[WireFormatError] = loop.create_future() def on_request(request: Any) -> None: url = str(getattr(request, "url", "")) - rpcid = _rpcid(url) if "batchexecute" in url else None - if rpcid not in SUBMIT_RPCS or route_error.done(): + if "batchexecute" not in url or route_error.done(): return - if expect_reference_ids: - problem = _r2v_body_problem(_post_data(request), rpcid, expect_reference_ids) - elif expect_media_id is not None: - problem = _i2v_body_problem(_post_data(request), rpcid, expect_media_id) - else: + # The URL `rpcids` param is the normal carrier and is tried first. The + # body fallback exists because the interpolation submit has been reported + # without that param; on the account measured 2026-09-17 it DID carry it + # (`nprQif` in the URL, key `omni_flash_i2v_8s_first_last`), so treat the + # body as a second carrier rather than the rule — cohorts differ here. + body = _post_data(request) + rpcid = _rpcid(url) or _body_rpcid(body) + # Record request rpcids too: at timeout the set shows both what went + # out and what answered (issue #639). + if rpcid is not None: + seen_submit_rpcs.add(rpcid) + if rpcid is None: return - if problem is not None: - route_error.set_result( - WireFormatError(detail=problem, route=f"batchexecute:{rpcid}") - ) + if rpcid in SUBMIT_RPCS: + if rpcid == INTERPOLATION_SUBMIT_RPC: + problem = _interpolation_body_problem( + body, rpcid, expect_media_id, expect_end_media_id + ) + elif expect_reference_ids: + problem = _r2v_body_problem(body, rpcid, expect_reference_ids) + elif expect_media_id is not None: + problem = _i2v_body_problem( + body, + rpcid, + expect_media_id, + end_media_id=expect_end_media_id, + ) + else: + return + if problem is not None: + route_error.set_result( + WireFormatError(detail=problem, route=f"batchexecute:{rpcid}") + ) + return + # An unwatched rpcid is RECORDED (`seen_submit_rpcs`, above) but never + # adopted as our submit. Adopting by body content was tried and removed: + # the predicate available here (`_i2v_body_problem`) requires `_i2v_` in + # the key, which `veo_3_1_interpolation_lite` does not contain — so it + # could never fire for the veo start+end case it was written for — while + # for the omni `*_i2v_*_first_last` key it matched broadly enough that any + # future rpc echoing composer state would be adopted, and a parse failure + # on an adopted id calls `submitted.set_exception` (fatal) rather than + # `continue`. That trades a named timeout for a hard failure on an + # already-billed run. Both submit rpcids we have measured — `eb1hJf` + # (start only) and `nprQif` (start+end, 2026-09-17) — are in SUBMIT_RPCS, + # so nothing needs adopting; a third one should be measured and added by + # name, not guessed at from a body. # ``terminal``: failed, or done WITH the signed URL. ``done_no_url``: the # first status-3 record that has no URL yet (a poll beats the result RPC). @@ -2031,16 +2189,34 @@ def _settle(rec: GenerationRecord) -> None: elif rec.is_done and not done_no_url.done(): done_no_url.set_result(rec) + # Every batchexecute rpcid seen while waiting for the submit reply. A + # start+end submit may answer on an rpcid this observer does not watch; + # recording the set turns an unknown-reply timeout from a mystery into a + # named fact (issue #639). + seen_submit_rpcs: set[str] = set() + async def on_response(response: Any) -> None: url = str(getattr(response, "url", "")) rpcid = _rpcid(url) if "batchexecute" in url else None - if rpcid not in SUBMIT_RPCS and rpcid not in STATUS_RPCS: + if rpcid is not None: + seen_submit_rpcs.add(rpcid) + if "batchexecute" not in url: + return + # `rpcid is None` falls through on purpose: the interpolation reply is a + # bare `batchexecute` with no `rpcids` param, and `parse_frames` recovers + # its id from the frame itself (measured 2026-09-17). + if rpcid is not None and rpcid not in SUBMIT_RPCS and rpcid not in STATUS_RPCS: return try: text = await response.text() except Exception: # noqa: BLE001 - an aborted/streamed body is not our frame return for rid, payload in parse_frames(text): + # Record the id the FRAME names, not just the URL's. The interpolation + # reply arrives on a bare `batchexecute` with no `rpcids` param, so a + # URL-only diagnostic reports "rpcs seen: none" for the very reply shape + # whose absence it is trying to explain (#639). + seen_submit_rpcs.add(rid) if rid in SUBMIT_RPCS and not submitted.done(): try: rec = generation_record(rid, payload) @@ -2068,11 +2244,15 @@ async def on_response(response: Any) -> None: _settle(rec) page.on("response", on_response) - # Both body assertions live in `on_request`, so the listener must be armed for - # either. Gating it on `expect_media_id` alone left `_r2v_body_problem` unit-tested - # but never reached in a live run — the check the r2v path was built around. - if expect_media_id is not None or expect_reference_ids: - page.on("request", on_request) + # Every body assertion lives in `on_request`, so the listener must be armed for + # all of them. Gating it on `expect_media_id` alone left `_r2v_body_problem` + # unit-tested but never reached in a live run — the check the r2v path was built + # around. It is now ALWAYS armed, including for t2v, because the interpolation + # guard is a BILLING guard that only matters when nothing was asked for: a stale + # End chip makes the app submit `nprQif` on a run that requested no end frame, + # and refusing that is free while the generation is not. `on_request` returns + # early for a t2v submit it has nothing to assert about. + page.on("request", on_request) try: submit = page.locator("button").filter(has=_ligature(page, "arrow_forward")).first if not await submit.count(): @@ -2114,10 +2294,11 @@ async def on_response(response: Any) -> None: # named as such and not as whatever the reply then says. raise route_error.result() if not submitted.done(): + seen = ", ".join(sorted(seen_submit_rpcs)) or "none" raise TransportTimeoutError( detail=( f"migrated host: no {'/'.join(SUBMIT_RPCS)} reply within " - f"{budget:.0f}s of clicking submit" + f"{budget:.0f}s of clicking submit (rpcs seen: {seen})" ), ) first = submitted.result() @@ -2148,8 +2329,7 @@ async def on_response(response: Any) -> None: if submitted.done() and not submitted.cancelled(): submitted.exception() page.remove_listener("response", on_response) - if expect_media_id is not None or expect_reference_ids: - page.remove_listener("request", on_request) + page.remove_listener("request", on_request) async def submit_images_and_observe( self, @@ -2383,30 +2563,28 @@ async def run_video( """The migrated-host twin of the labs ``_generate_video_locked`` tail: same inputs, same ``VideoResult``, so recorder, CLI, MCP and worker are untouched. - t2v, i2v from a local start frame (uploaded through the editor and bound on the - Start chip by file name), and r2v from local ``--ref`` files. An end frame and a - frame or reference given by UUID / ``@Name`` are not ported yet; a fresh project - can only be created through the labs gallery, so the caller must name one - (``--project``). + t2v, i2v from local start (and end) frames (uploaded through the editor and + bound on the Start/End chips by file name), and r2v from local ``--ref`` + files. A frame or reference given by UUID / ``@Name`` is not ported yet. The + project is created by ``FlowApiClient`` before this runs (#864), so reaching + here without one means a caller bypassed the client. """ unported = _unported_form(request) if unported is not None: raise FlowHostMigratedError( detail=( f"this account's Flow lives on flow.google.com, where gflow drives " - f"text-to-video, image-to-video from a local start frame, and " + f"text-to-video, image-to-video from local start (and end) frames, and " f"reference-to-video from local files; {unported} is not ported yet " - f"(#639) — pass --initial-frame / --ref as local files, without an " - f"end frame" + f"(#639) — pass --initial-frame / --ref as local files" ), ) pid = project_id or extract_project_id(page.url) if not pid: raise ConfigurationError( detail=( - "generating on the migrated flow.google.com host needs an existing project: " - "pass --project <id> (see `gflow project list` / `gflow project create`) — " - "creating one from the editor is not ported to this host yet" + "generating on the migrated flow.google.com host needs a project: pass " + "--project <id> (see `gflow project list` / `gflow project create`)" ), ) log.info("migrated.dispatch", project_id=pid, mode=request.mode.value) @@ -2414,10 +2592,13 @@ async def run_video( await composer.ensure_editor(page, pid) await composer.apply_video_settings(page, request) media_id: str | None = None + end_media_id: str | None = None reference_ids: tuple[str, ...] = () frame = request.start_image if request.mode is Mode.I2V and frame is not None: media_id = await composer.attach_start_frame(page, pid, frame) + if request.mode is Mode.I2V and request.end_image is not None: + end_media_id = await composer.attach_end_frame(page, pid, request.end_image) if request.mode is Mode.R2V and request.reference_images: reference_ids = await composer.attach_references(page, pid, request.reference_images) if request.reference_entities: @@ -2450,6 +2631,7 @@ async def run_video( on_started=on_started, project_id=pid, expect_media_id=media_id, + expect_end_media_id=end_media_id, expect_reference_ids=reference_ids, ) status = VideoStatus( @@ -2514,3 +2696,141 @@ async def run_images( request, reference_ids=reference_ids, ) + + +# --- projects (#864) --------------------------------------------------------------- +# +# labs.google's `project.createProject` now answers 404 "Flow RPCs have been deprecated +# and disabled" (401 for a session carrying no labs token) on every profile measured, so +# a project is created and renamed here instead. Both operations were measured on +# 2026-09-17 (docs/superpowers/spikes/2026-09-17-project-create-on-flow-google-com.md), +# and both replies state the outcome, so success is read from the wire, not the URL. + +MIGRATED_ROOT_URL = "https://flow.google.com/" +#: The projects page's floating `add` button — the page's only creation control. +NEW_PROJECT_BUTTON = "flow-projects-page button:has(mat-icon:text-is('add'))" +#: The project header's editable title. +PROJECT_TITLE_INPUT = "flow-editable-text input" +#: `["projects/*", [null, [title]], …]` -> `[project_id, [title]]` +CREATE_PROJECT_RPC = "jHPbke" +#: `["projects/<id>", [title], [["project_title"]], …]` -> `[title]` +RENAME_PROJECT_RPC = "o8DA4" + + +def _as_list_any(node: object) -> list[object]: + return cast("list[object]", node) if isinstance(node, list) else [] + + +async def _wait_anchor( + page: Page, selector: str, *, named: str, requested: str, at: str, timeout_s: float +) -> Any: + locator = page.locator(selector).first + try: + await locator.wait_for(state="visible", timeout=int(timeout_s * 1000)) + except PlaywrightTimeoutError as e: + await raise_if_known_landing(page, requested=requested, at=at) + raise UiSelectorDriftError( + detail=( + f"migrated host: the {named} ({selector}) did not become visible within " + f"{timeout_s:.0f}s on {safe_page_url(page.url)} (host=migrated)" + ), + ) from e + return locator + + +async def _rpc_reply( + page: Page, rpcid: str, action: Callable[[], Awaitable[None]], *, timeout_s: float +) -> Any: + """Run ``action`` and return the payload of the first ``rpcid`` frame the page gets.""" + reply: asyncio.Future[Any] = asyncio.get_running_loop().create_future() + + async def on_response(response: Any) -> None: + if reply.done() or "batchexecute" not in str(getattr(response, "url", "")): + return + try: + text = await response.text() + except Exception: # noqa: BLE001 - an aborted body is not our frame + return + for rid, payload in parse_frames(text): + if rid == rpcid and not reply.done(): + reply.set_result(payload) + + page.on("response", on_response) + try: + await action() + try: + return await asyncio.wait_for(reply, timeout=timeout_s) + except TimeoutError as e: + raise TransportTimeoutError( + detail=( + f"flow.google.com did not answer {rpcid} within {timeout_s:.0f}s " + f"on {safe_page_url(page.url)} (host=migrated)" + ), + ) from e + finally: + page.remove_listener("response", on_response) + + +async def create_project(page: Page, title: str, *, timeout_s: float = 30.0) -> ProjectInfo: + """Create a project through flow.google.com's projects page, titled ``title``. + + Leaves ``page`` on the new project, which is where every caller goes next.""" + await page.goto(MIGRATED_ROOT_URL, wait_until="domcontentloaded", timeout=45_000) + await MigratedComposer._dismiss_dialog(page) # noqa: SLF001 # pyright: ignore[reportPrivateUsage] + button = await _wait_anchor( + page, + NEW_PROJECT_BUTTON, + named="new-project button", + requested=MIGRATED_ROOT_URL, + at="migrated.create_project", + timeout_s=timeout_s, + ) + payload = await _rpc_reply( + page, CREATE_PROJECT_RPC, lambda: button.click(timeout=15_000), timeout_s=timeout_s + ) + reply = _as_list_any(payload) + project_id: object = reply[0] if reply else None + if not isinstance(project_id, str) or not UUID_RE.fullmatch(project_id): + raise WireFormatError( + detail=f"{CREATE_PROJECT_RPC} reply carried no project id: {str(payload)[:200]}", + route=f"batchexecute:{CREATE_PROJECT_RPC}", + ) + log.info("migrated.project_created", project_id=project_id) + names = _as_list_any(reply[1]) if len(reply) > 1 else [] + created: object = names[0] if names else None + if title and created != title: + await rename_project(page, project_id, title, timeout_s=timeout_s) + created = title + return ProjectInfo(project_id=project_id, title=str(created or "")) + + +async def rename_project( + page: Page, project_id: str, title: str, *, timeout_s: float = 30.0 +) -> None: + """Rename a project through its header title on flow.google.com.""" + target = MIGRATED_PROJECT_URL.format(project_id=project_id) + if not str(getattr(page, "url", "") or "").startswith(target): + await page.goto(target, wait_until="domcontentloaded", timeout=45_000) + await MigratedComposer._dismiss_dialog(page) # noqa: SLF001 # pyright: ignore[reportPrivateUsage] + field = await _wait_anchor( + page, + PROJECT_TITLE_INPUT, + named="project title input", + requested=target, + at="migrated.rename_project", + timeout_s=timeout_s, + ) + + async def submit() -> None: + await field.fill(title) + await field.press("Enter") + + payload = await _rpc_reply(page, RENAME_PROJECT_RPC, submit, timeout_s=timeout_s) + echo = _as_list_any(payload) + echoed: object = echo[0] if echo else None + if echoed != title: + raise WireFormatError( + detail=f"{RENAME_PROJECT_RPC} echoed title {echoed!r}, expected {title!r}", + route=f"batchexecute:{RENAME_PROJECT_RPC}", + ) + log.info("migrated.project_renamed", project_id=project_id) diff --git a/src/gflow_cli/cli.py b/src/gflow_cli/cli.py index c6b5219a9..61a6c8be9 100644 --- a/src/gflow_cli/cli.py +++ b/src/gflow_cli/cli.py @@ -21,6 +21,7 @@ from gflow_cli.cli_character import character as _character_group from gflow_cli.cli_credits import credits as _credits_group from gflow_cli.cli_data import data as _data_group +from gflow_cli.cli_docs import docs as _docs_command from gflow_cli.cli_doctor import doctor as _doctor_command from gflow_cli.cli_image import image as _image_group from gflow_cli.cli_instructions import instructions as _instructions_group @@ -491,6 +492,7 @@ def _resolve_or_prompt(default_for_first_run: str) -> str: main.add_command(_credits_group) main.add_command(_data_group) main.add_command(_doctor_command) +main.add_command(_docs_command) main.add_command(_update_command) main.add_command(_movie_group) main.add_command(_project_group) diff --git a/src/gflow_cli/cli_docs.py b/src/gflow_cli/cli_docs.py new file mode 100644 index 000000000..dc2bff861 --- /dev/null +++ b/src/gflow_cli/cli_docs.py @@ -0,0 +1,191 @@ +"""`gflow docs` — the documentation, reachable from the terminal (#861). + +Three shapes, all read-only, all offline: list the topics, print one, or search for the +line that answers a question. No network, no account, no credits, no database. + +**No MCP twin yet — deferred with a shape, not excluded on principle** (registered in +`tests/mcp/test_cli_parity.py::_MCP_EXEMPT`, so the decision is enforced rather than +asserted here). Documentation is not a tool call: MCP models documents as **resources**, +and the upgrade path is a resources provider over `gflow_cli.docs_catalog` — which is why +every decision lives in that module, Click-free and returning data. This file renders. +""" + +from __future__ import annotations + +import sys +from typing import Any + +import click +from rich.console import Console +from rich.table import Table + +from gflow_cli import docs_catalog, json_output +from gflow_cli._cli_helpers import run_with_handlers +from gflow_cli.docs_catalog import MAX_HITS +from gflow_cli.errors import ConfigurationError + +console = Console() + +#: Widths that keep one topic on one terminal row. 126 topics rendered as wrapped +#: two-line cells was 424 lines — seventeen screens nobody reads, which makes the +#: listing decorative and `--search` the only real entry point. Truncated, it is one +#: screenful per 40 topics and stays scannable; `--json` still carries the full text. +_TOPIC_COL = 34 +_SUMMARY_COL = 72 + + +def _forgive_console_encoding() -> None: + """Make stdout replace characters it cannot encode instead of raising. + + The pages are full of `—`, `→` and `✅`, and a Windows console is frequently cp1252. + Printing straight into one raises `UnicodeEncodeError` part-way through a page — the + exact shape of #846, shipped one release ago. + + One `reconfigure` covers `click.echo`, Rich's tables **and** Rich's box-drawing + glyphs; the per-string helper this replaces covered only the strings it was wrapped + around, and a review proved it: replacing every one of its call sites with `str` left + the whole suite green. A stdout without `reconfigure` (a captured stream in a test + harness) is already text-mode and UTF-8, so there is nothing to forgive there. + """ + reconfigure = getattr(sys.stdout, "reconfigure", None) + if reconfigure is None: + return + try: + reconfigure(errors="replace") + except (ValueError, OSError): # pragma: no cover - a stream that refuses to be retuned + pass + + +@click.command("docs") +@click.argument("topic", required=False) +@click.option( + "--search", + "term", + default=None, + metavar="TERM", + help="Find the lines mentioning TERM across every page, most relevant first.", +) +@click.option("--json", "as_json", is_flag=True, help="Machine-readable JSON.") +def docs(topic: str | None, term: str | None, as_json: bool) -> None: + """Browse gflow's own documentation. + + \b + gflow docs list every topic + gflow docs usage print one page + gflow docs --search "r2v duration" find the line that answers a question + + Entirely offline and read-only: the pages ship inside the package. + """ + run_with_handlers(lambda: _run(topic, term, as_json), cli_command="docs", as_json=as_json) + + +async def _run(topic: str | None, term: str | None, as_json: bool) -> None: + _forgive_console_encoding() + # `is not None`, not truthiness, on BOTH sides: `--search ""` is a passed flag, and + # testing it for truth let `gflow docs usage --search ""` through the guard and then + # silently discard the topic. + if topic is not None and term is not None: + raise ConfigurationError( + detail=( + "pass a topic or --search, not both — `gflow docs <topic>` prints one " + "page, `gflow docs --search <term>` looks across all of them" + ), + remediation_hint="Drop one of the two and re-run.", + ) + if term is not None: + _emit_search(term, as_json=as_json) + return + if topic is not None: + _emit_page(topic, as_json=as_json) + return + _emit_topics(as_json=as_json) + + +def _emit_topics(*, as_json: bool) -> None: + found = docs_catalog.topics() + if as_json: + json_output.emit( + { + "topics": [ + { + "topic": t.slug, + "title": t.title, + "summary": t.summary, + "path": t.repo_path, + } + for t in found + ] + } + ) + return + if not found: + console.print("[yellow]No documentation is bundled with this installation.[/]") + return + table = Table(title=f"gflow documentation ({len(found)} topics)") + table.add_column("topic", style="bold", no_wrap=True, width=_TOPIC_COL) + table.add_column("what it covers", no_wrap=True, overflow="ellipsis", width=_SUMMARY_COL) + for entry in found: + table.add_row(entry.slug, entry.summary or entry.title) + console.print(table) + console.print( + " Show one: [bold]gflow docs <topic>[/] Find a line: [bold]gflow docs --search <term>[/]" + ) + + +def _emit_page(topic: str, *, as_json: bool) -> None: + entry = docs_catalog.resolve(topic) + body = docs_catalog.read(entry) + if as_json: + json_output.emit( + { + "topic": entry.slug, + "title": entry.title, + "path": entry.repo_path, + "content": body, + } + ) + return + # Raw Markdown, not rendered: it pipes into a pager or an editor, and an agent reading + # stdout wants the source rather than box-drawing characters. + click.echo(body) + + +def _emit_search(term: str, *, as_json: bool) -> None: + hits = docs_catalog.search(term) + shown, held_back = hits[:MAX_HITS], max(0, len(hits) - MAX_HITS) + if as_json: + json_output.emit( + { + "term": term, + "total": len(hits), + "matches": [_match_json(m) for m in shown], + "omitted": held_back, + } + ) + return + if not shown: + # Exit 0: "nothing mentions that" is a true answer to a valid question, not a + # failure of the command. + console.print(f"No page mentions [bold]{term}[/].") + return + # The TOTAL in the title, not the shown count. They disagreed, and a header reading + # "20 match(es)" above a footer saying "and 116 more" is the command misreporting + # itself in the one place a reader checks whether to narrow. + table = Table(title=f"'{term}' — {len(hits)} match(es), showing {len(shown)}") + table.add_column("where", style="bold", no_wrap=True) + table.add_column("line", overflow="fold") + for match in shown: + table.add_row(match.position, match.text) + console.print(table) + if held_back: + console.print(f" … and {held_back} more. Add a second word to narrow the search.") + + +def _match_json(match: Any) -> dict[str, Any]: + return { + "topic": match.topic, + "path": match.position, + "line": match.line_no, + "text": match.text, + "score": match.score, + } diff --git a/src/gflow_cli/docs_catalog.py b/src/gflow_cli/docs_catalog.py new file mode 100644 index 000000000..895afb695 --- /dev/null +++ b/src/gflow_cli/docs_catalog.py @@ -0,0 +1,369 @@ +"""The documentation catalog behind `gflow docs` (#861) — enumeration, lookup, search. + +Pure: no network, no account, no database, no Click. Everything it reads is text that +shipped inside this package. + +**Why this exists.** 126 pages in `docs/` and nothing in the CLI points at any of them, so +at the moment of use the knowledge is unreachable and the reader guesses. The failure that +filed #861 was a run with `--duration 10` against a host that offers reference-to-video at +8 s only. That rule was already written down -- inside a 4 000-character bullet in +`MCP.md`. Which is why :func:`search` returns a **line with its position**, windowed around +the match: answering "it is in MCP.md" would leave the reader exactly where they started. + +**Why no manifest.** A hand-maintained topic list drifts the first time someone adds a +page. The directory listing is the list, at build time (`hatch_build.py` globs it) and at +read time (:func:`topics` iterates it). There is nothing to keep in sync. +""" + +from __future__ import annotations + +import difflib +import re +from dataclasses import dataclass +from importlib import resources +from pathlib import Path +from typing import Any + +from gflow_cli.errors import ConfigurationError + +#: The directory `hatch_build.py` installs the pages into, inside this package. +_PACKAGE_DOCS = "_docs" + +#: Where the pages live in the repository — printed so a reader can find the source, +#: and the prefix used for the `file:line` positions search reports. +REPO_DOCS_DIR = "docs" + +#: How much of a matching line to show. `MCP.md` has single lines over 4 000 characters; +#: printing one whole is the "go read the file" answer with extra steps. +_SNIPPET_WIDTH = 160 + +#: Body hits shown before the caller is told how many more there are. +MAX_HITS = 20 + + +@dataclass(frozen=True, slots=True) +class Topic: + """One documentation page.""" + + slug: str + """Lowercase, dash-separated, no suffix — `REFERENCE_STRATEGIES.md` → `reference-strategies`.""" + + file_name: str + """The page's own file name, e.g. `REFERENCE_STRATEGIES.md`.""" + + title: str + """The first `# ` heading, or the file name when a page has none.""" + + summary: str + """The first line of prose under the title. Empty when the page opens with a table.""" + + @property + def repo_path(self) -> str: + """Where to find this page in the repository.""" + return f"{REPO_DOCS_DIR}/{self.file_name}" + + +@dataclass(frozen=True, slots=True) +class Match: + """One search hit.""" + + file_name: str + """The page's real file name — `MCP.md`, not `mcp.md`. The slug is for typing at the + command line; a position a reader is meant to open has to be the path that exists.""" + + line_no: int + """1-indexed line within that page.""" + + text: str + """The matching line, windowed around the most selective term.""" + + score: int + """Relevance — see :func:`_score`. Only meaningful for ordering.""" + + @property + def topic(self) -> str: + return _slug(self.file_name) + + @property + def position(self) -> str: + return f"{REPO_DOCS_DIR}/{self.file_name}:{self.line_no}" + + +def _docs_dir() -> Any: + """The directory holding the pages, or ``None`` when there is none. + + Two sources, in order: + + 1. ``gflow_cli/_docs`` — what `hatch_build.py` ships. This is the only one that exists + for someone who installed from PyPI, which is the whole point of #861. + 2. the repository's own ``docs/`` — because the build hook runs at *build* time, so an + editable install (`uv sync`, every test run, every contributor) has no ``_docs``. + Without this fallback the command would work for users and be dead in development, + which is the reverse of the usual bug and twice as confusing. + + Resolved through ``importlib.resources`` rather than ``__file__`` so a zipimport or + frozen environment still reads. The repository fallback is derived from the package's + own location and never from user input. + """ + try: + shipped = resources.files("gflow_cli").joinpath(_PACKAGE_DOCS) + if shipped.is_dir(): + return shipped + except (ModuleNotFoundError, TypeError): # pragma: no cover - defensive + pass + try: + checkout = Path(__file__).resolve().parents[2] / REPO_DOCS_DIR + except IndexError: # pragma: no cover - defensive + return None + return checkout if checkout.is_dir() else None + + +def _slug(file_name: str) -> str: + return file_name[:-3].lower().replace("_", "-") if file_name.endswith(".md") else file_name + + +#: Markdown that carries no meaning once a line is a one-line summary in a table. +#: **Single `*` and `_` are deliberately NOT stripped.** They were, and it turned +#: `REFERENCE_STRATEGIES.md` into `REFERENCESTRATEGIES.md` — a file name that does not +#: exist, printed as the answer. 106 of the 126 pages carry an underscore. +_LINK = re.compile(r"\[([^\]]+)\]\([^)]*\)") +_EMPHASIS = re.compile(r"(\*\*|__|`)") +_LIST_ITEM = re.compile(r"^(\d+[.)]|[-*+])\s") + +#: How much of the first prose line the topic table shows. +_SUMMARY_WIDTH = 90 + + +def _plain(line: str) -> str: + """*line* with the Markdown taken out: links become their label, bold/code markers go. + + A summary column showing `Read [DISCLAIMER.md](../DISCLAIMER.md) first` is worse than + no summary — it costs the reader the same parsing the raw file would. + """ + return " ".join(_EMPHASIS.sub("", _LINK.sub(r"\1", line)).split()) + + +def _title_and_summary(text: str) -> tuple[str, str]: + """The page's `# ` heading and its first line of prose. + + Tolerant by design: a page with no heading, or one that opens with a table, a list or + a blockquote, still produces a :class:`Topic` — it just has less to say about itself. + """ + title, summary = "", "" + for raw in text.splitlines(): + line = raw.strip() + if not line: + continue + if not title: + if line.startswith("# "): + title = _plain(line[2:]) + continue + if line.startswith(("#", ">", "|", "`", "<")) or _LIST_ITEM.match(line): + continue + summary = _plain(line) + if len(summary) > _SUMMARY_WIDTH: + summary = summary[:_SUMMARY_WIDTH].rstrip() + " …" + break + return title, summary + + +def _read(entry: Any) -> str: + """A page's text. + + ``read_bytes`` then decode, never ``read_text(errors=…)``: ``Traversable.read_text`` + takes only ``encoding``, so passing ``errors`` raises ``TypeError`` on the one path + that matters — a page read out of a zipimport. Every test here exercises the checkout + fallback, where ``Path`` happens to accept it, so the bug could not surface locally. + + Replacement rather than strict: a page that cannot be decoded must still be listed and + searched, because a catalog that raises is worse than one that is lossy. + """ + try: + return entry.read_bytes().decode("utf-8", errors="replace") + except (OSError, ValueError, AttributeError): # pragma: no cover - defensive + return "" + + +def topics() -> list[Topic]: + """Every shipped page, by slug. Empty when no docs directory is available.""" + directory = _docs_dir() + if directory is None: + return [] + found: list[Topic] = [] + for entry in directory.iterdir(): + name = entry.name + if not name.endswith(".md") or not entry.is_file(): + continue + title, summary = _title_and_summary(_read(entry)) + found.append(Topic(slug=_slug(name), file_name=name, title=title or name, summary=summary)) + return sorted(found, key=lambda t: t.slug) + + +def read(topic: Topic) -> str: + """The full text of a page.""" + directory = _docs_dir() + if directory is None: # pragma: no cover - resolve() cannot yield a Topic without one + return "" + return _read(directory.joinpath(topic.file_name)) + + +#: Remediation for every refusal this module raises. Without it they inherit +#: `ConfigurationError._default_remediation`, which tells the reader to run +#: `gflow config list-transports` — advice about a transport registry, printed at someone +#: who asked for a documentation page. +_REMEDIATION = "Run `gflow docs` for the list of topics, or `gflow docs --search <term>`." + + +def resolve(name: str) -> Topic: + """The page *name* refers to. + + Accepts the slug, the file name, either case, and any unambiguous prefix — the three + forms a reader actually types (`usage`, `USAGE.md`, `ref`). + + **The lookup is a dictionary hit against the enumerated pages; the argument is never + joined onto a path.** That is what makes `gflow docs ../../../../etc/passwd` an + ordinary unknown-topic refusal rather than a file read, and it stays true without a + traversal check to keep correct. + """ + available = topics() + if not available: + raise ConfigurationError( + detail=( + "no documentation is available in this installation — the pages ship " + "inside the wheel, so a source tree without them cannot serve them" + ), + remediation_hint="Reinstall gflow-cli, or run from a checkout with a docs/ tree.", + ) + key = name.strip().lower() + if key.endswith(".md"): + key = key[:-3] + key = key.replace("_", "-") + by_slug = {t.slug: t for t in available} + if key in by_slug: + return by_slug[key] + if key: + starting = [t for t in available if t.slug.startswith(key)] + if len(starting) == 1: + return starting[0] + if len(starting) > 1: + raise ConfigurationError( + detail=( + f"{name!r} matches {len(starting)} topics: " + f"{', '.join(t.slug for t in starting[:8])}" + f"{' …' if len(starting) > 8 else ''} — name one of them" + ), + remediation_hint=_REMEDIATION, + ) + raise ConfigurationError( + detail=_unknown_detail(name, key, available), remediation_hint=_REMEDIATION + ) + + +def _unknown_detail(name: str, key: str, available: list[Topic]) -> str: + """The refusal, with the nearest topics attached. + + `difflib.get_close_matches` rather than only a substring scan: a substring scan offers + nothing for a typo, and a typo is the common case — `usge` shares no substring with + `usage` but is one transposition away. + """ + slugs = [t.slug for t in available] + near = difflib.get_close_matches(key, slugs, n=3, cutoff=0.6) if key else [] + if not near and key: + near = [s for s in slugs if key in s][:3] + hint = f" — did you mean {', '.join(near)}?" if near else "" + return f"no documentation topic named {name!r}{hint}" + + +def _snippet(line: str, terms: list[str]) -> str: + """*line*, windowed around its most selective term. + + `MCP.md` documents the migrated-host duration rule inside a single 4 000-character + bullet. Returning that line whole is the failure #861 describes, restated as output. + + The window centres on whichever term occurs **least** on this line, not on the first + one typed: for `r2v duration` against that bullet, `r2v` recurs throughout while + `duration` marks the one clause the reader wants. + """ + collapsed = " ".join(line.split()) + if len(collapsed) <= _SNIPPET_WIDTH: + return collapsed + lowered = collapsed.lower() + present = [t for t in terms if t in lowered] + if not present: # pragma: no cover - callers only pass lines that matched + return collapsed[:_SNIPPET_WIDTH] + " …" + at = lowered.find(min(present, key=lowered.count)) + start = max(0, at - _SNIPPET_WIDTH // 3) + end = min(len(collapsed), start + _SNIPPET_WIDTH) + return ("… " if start else "") + collapsed[start:end] + (" …" if end < len(collapsed) else "") + + +#: An `INDEX.md` § Topic shortcuts row — `**"How do I X?"** → [LABEL](TARGET.md)`. These +#: are the highest-signal lines in the tree: each was written to *be* an answer. They are +#: a RANKING bonus, not a separate pass. A separate parser existed and was measured out: +#: its hits were a strict subset of what the body scan already found (0 of 7 for the +#: flagship `r2v duration`), so 28 lines of regex only re-ordered rows search already had +#: — and stripping Markdown from the row mangled the file name it was pointing at. +_CURATED_BONUS = 20 +_HEADING_BONUS = 10 +_FENCE = "```" + + +def _score(line: str, terms: list[str], *, curated: bool, in_fence: bool) -> int: + """How much a matching line is *about* the terms, rather than merely containing them. + + A curated `INDEX.md` answer outranks a heading, which outranks prose; and a line that + names a term repeatedly is discussing it rather than mentioning it in passing. + + **The heading bonus must not fire inside a fenced code block.** `# 5. One cheapest + stable T2V generation, no explicit --duration` is a shell comment in a release record; + scored as a heading it ranked 2nd of 136 for `--search duration` — exactly the noise + this ordering was added to remove. + + **What ranking cannot do.** `--search duration` has 136 hits and no ordering tried here + puts the rule first. Three were built and measured; the third reused `INDEX.md`'s + routing as a reference-vs-record signal, and it is false — `INDEX.md` links 93 of the + 126 pages. It is not here because it did not work. What works is a second query word: + `r2v duration` returns 7 hits with the rule first. See `SCENARIO.md` § M3. + """ + lowered = line.lower() + occurrences = sum(lowered.count(term) for term in terms) + bonus = _CURATED_BONUS if curated else 0 + if not in_fence and line.lstrip().startswith("#"): + bonus += _HEADING_BONUS + return bonus + occurrences + + +def search(term: str) -> list[Match]: + """Every line mentioning *term*, most relevant first. + + Whitespace splits the query into terms that must **all** appear on the line, so + `--search "r2v duration"` narrows where a single common word cannot. + """ + terms = [t for t in term.strip().lower().split() if t] + if not terms: + return [] + hits: list[Match] = [] + for topic in topics(): + is_index = topic.file_name == "INDEX.md" + in_fence = False + for line_no, raw in enumerate(read(topic).splitlines(), start=1): + if raw.lstrip().startswith(_FENCE): + in_fence = not in_fence + lowered = raw.lower() + if not all(t in lowered for t in terms): + continue + hits.append( + Match( + file_name=topic.file_name, + line_no=line_no, + text=_snippet(raw, terms), + score=_score( + raw, + terms, + curated=is_index and raw.lstrip().startswith('**"'), + in_fence=in_fence, + ), + ) + ) + hits.sort(key=lambda m: (-m.score, m.file_name, m.line_no)) + return hits diff --git a/src/gflow_cli/errors.py b/src/gflow_cli/errors.py index 038c06a2c..90c97980a 100644 --- a/src/gflow_cli/errors.py +++ b/src/gflow_cli/errors.py @@ -802,10 +802,10 @@ class FlowHostMigratedError(GFlowError): title = "Flow served the migrated flow.google.com frontend" _default_remediation = ( "Google has moved this account's Flow from labs.google to flow.google.com. " - "gflow drives that frontend for text-to-video, image-to-video from a local " - "--initial-frame, reference-to-video from local --ref files, text-to-image, " - "and image-to-image from local files (no end frame, no UUID or @Name frame, " - "no character entities; --project required) " + "gflow drives that frontend for text-to-video, image-to-video from local " + "start (and end) frames, reference-to-video from local --ref files, " + "text-to-image, and image-to-image from local files (no UUID or @Name " + "frame, no character entities; --project required) " "(GFLOW_CLI_FLOW_HOST=auto, the default, or flow.google.com); you see this " "error because GFLOW_CLI_FLOW_HOST=labs.google switched the migrated composer " "off, or because this request type is not ported to the migrated host yet. " diff --git a/src/gflow_cli/mcp/server.py b/src/gflow_cli/mcp/server.py index 218aab4cb..63c63858b 100644 --- a/src/gflow_cli/mcp/server.py +++ b/src/gflow_cli/mcp/server.py @@ -25,7 +25,7 @@ from starlette.types import ASGIApp, Receive, Scope, Send from gflow_cli import __version__ -from gflow_cli.config import get_settings +from gflow_cli.config import get_settings, reset_settings from gflow_cli.mcp.tasks_extension import TasksExtension log = structlog.get_logger() @@ -126,6 +126,28 @@ def _configure_utf8_pipes() -> None: _SPEND_TOOLS = ("gflow_generate_image", "gflow_generate_video") +#: How long a tool call waits for a profile another process holds (#862). A human at the +#: CLI can retry a fail-fast `ProfileLockedError`; an MCP client has nobody to do that. +MCP_LEASE_WAIT_SECONDS = "180" + + +def _apply_mcp_lease_wait_default() -> None: + """Wait out cross-process profile contention unless the operator chose otherwise. + + Must reset the settings cache: `gflow`'s root command has already loaded settings + before `mcp run` / `serve` reach here, so an environment default alone is read too + late (#862 shipped exactly that). A value from the environment OR a `.env` file is + the operator's choice and wins — including an explicit fail-fast `0`. + + Same-process contention never waits (see `profile_lease`); calls inside this server + are serialized per profile in `mcp.tools` instead. + """ + if "lease_wait_seconds" in get_settings().model_fields_set: + return + os.environ["GFLOW_CLI_LEASE_WAIT_SECONDS"] = MCP_LEASE_WAIT_SECONDS + reset_settings() + + def no_spend_active() -> bool: """True when no-spend mode is requested (#496). @@ -304,6 +326,7 @@ async def run_stdio() -> None: import anyio from mcp.server.stdio import stdio_server + _apply_mcp_lease_wait_default() _configure_utf8_pipes() # Capture the REAL stdout for the JSON-RPC channel BEFORE redirecting @@ -350,6 +373,7 @@ async def run_http(host: str = "127.0.0.1", port: int = 8000) -> None: host: Bind address. Defaults to localhost-only for security. port: Port number. Defaults to 8000. """ + _apply_mcp_lease_wait_default() _configure_utf8_pipes() token = _daemon_token() @@ -385,6 +409,7 @@ async def run_sse(host: str = "127.0.0.1", port: int = 8000) -> None: host: Bind address. Defaults to localhost-only for security. port: Port number. Defaults to 8000. """ + _apply_mcp_lease_wait_default() _configure_utf8_pipes() token = _daemon_token() diff --git a/src/gflow_cli/mcp/tools.py b/src/gflow_cli/mcp/tools.py index c2f0f4a56..bb8196031 100644 --- a/src/gflow_cli/mcp/tools.py +++ b/src/gflow_cli/mcp/tools.py @@ -23,6 +23,7 @@ import functools import time import uuid +import weakref from collections.abc import Awaitable, Callable from dataclasses import replace from pathlib import Path @@ -94,6 +95,25 @@ async def acquire(self) -> bool: _rate_limiter = _TokenBucket() +_profile_locks: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, dict[str, asyncio.Lock]] = ( + weakref.WeakKeyDictionary() +) + + +def _profile_lock(profile: str) -> asyncio.Lock: + """Serialize this server's browser work on one profile (#862). + + Tool calls run in the server's own process, and a second same-process lease on a + profile fails fast by design (waiting on yourself would deadlock), so two calls + arriving together — a client retry, two agents on one server — must queue here. + Different profiles still run concurrently. Held per loop — weakly, so a finished + loop takes its locks with it — because an `asyncio.Lock` belongs to the loop that + first contends it, and a loop id can be reused once that loop is gone. + """ + locks = _profile_locks.setdefault(asyncio.get_running_loop(), {}) + return locks.setdefault(profile, asyncio.Lock()) + + def _rate_limited_envelope() -> dict[str, Any]: """The ONE rate-limited refusal shape (#498) — RFC 9457 problem details. @@ -368,13 +388,14 @@ async def _run_generation_task( # (shared with the daemon poll loop) is what guarantees this row is # executed at most once. A None claim = invalid payload failed at # claim time (no browser) or already taken; the read-back reports it. - worker = FlowWorker(profile_name=profile, db_path=str(db_path)) - try: - claimed = worker.repo.claim_task(task_id, claimant=f"mcp:{profile}") - if claimed is not None: - await worker.process_task(claimed) - finally: - worker.close() + async with _profile_lock(profile): + worker = FlowWorker(profile_name=profile, db_path=str(db_path)) + try: + claimed = worker.repo.claim_task(task_id, claimant=f"mcp:{profile}") + if claimed is not None: + await worker.process_task(claimed) + finally: + worker.close() # 3. Read the final task state back. with DataStore.open(db_path) as store: @@ -799,8 +820,8 @@ async def gflow_generate_image( ``GFLOW_CLI_PROFILE`` env var → ``config.toml`` default → auto-select if exactly one profile exists. project: Optional existing Flow project id to generate into (mirrors the - CLI ``--project`` flag). When omitted, a scratch project is created - as before. + CLI ``--project`` flag). When omitted, a fresh project is created — + on labs.google or flow.google.com, whichever serves the account. project_name: Optional human-readable project title to use when creating a fresh Flow project. instructions: Optional list of custom agent instructions to add or enable @@ -1016,8 +1037,9 @@ async def gflow_generate_video( # NOSONAR as ``hero-<8 hex>.png``), so a re-run of the same file cannot bind an earlier upload (#792); a Flow media UUID returns the exit-36-equivalent envelope. - end_frame: Path to end frame image (optional for i2v). Not ported to - flow.google.com yet — exit-36-equivalent envelope on a moved account. + end_frame: Path to end frame image (optional for i2v). Local files are + driven on flow.google.com; UUID/@Name refs return the + exit-36-equivalent envelope on a moved account. reference_images: List of reference image paths (ingredients) for r2v. reference_entities: Saved Flow CHARACTER entity **ids** to attach (mirrors the CLI ``--reference-entity``). Same wire as an @@ -1057,13 +1079,13 @@ async def gflow_generate_video( # NOSONAR auto-select if exactly one profile exists. project: Optional existing Flow project id to generate into (mirrors the CLI ``--project`` flag on ``video t2v``/``i2v``/``r2v``). When - omitted, a scratch project is created on labs.google. On an account - Google has moved to flow.google.com (``GFLOW_CLI_FLOW_HOST``, read from - the server/daemon environment, not per call) ``project`` is required — - omitting it returns the exit-11-equivalent envelope. There the ported - modes are 't2v'; 'i2v' with a local ``initial_frame`` and no - ``end_frame``; and 'r2v' with local ``reference_images``. A UUID - frame, an end frame, and r2v by ``ref_names`` or + omitted, a fresh project is created (titled ``project_name`` when + given) on labs.google or flow.google.com, whichever serves the + account. On an account served flow.google.com (``GFLOW_CLI_FLOW_HOST``, + read from the server/daemon environment, not per call) the ported + modes are 't2v'; 'i2v' with a local ``initial_frame`` with or without + a local ``end_frame``; and 'r2v' with local ``reference_images``. A UUID + frame, an end frame by UUID/``@Name``, and r2v by ``ref_names`` or ``reference_entities`` return the exit-36-equivalent envelope. ui_mode: Required Flow UI arm (mirrors the CLI ``--ui-mode`` on ``video t2v``/``i2v``; applies to every mode of this tool, @@ -1258,7 +1280,8 @@ async def gflow_get_credits( resolved = _resolve_and_validate_profile(profile) if isinstance(resolved, dict): return resolved - return await inspect_credit_profile(resolved) + async with _profile_lock(resolved): + return await inspect_credit_profile(resolved) def _character_to_dict(char: Any) -> dict[str, Any]: @@ -1311,10 +1334,13 @@ async def gflow_character_list( return resolved settings = get_settings() - async with FlowApiClient( - profile_dir=settings.profile_subdir(resolved), - headless=settings.headless, - ) as client: + async with ( + _profile_lock(resolved), + FlowApiClient( + profile_dir=settings.profile_subdir(resolved), + headless=settings.headless, + ) as client, + ): chars = await client.list_characters(project) return { @@ -1394,10 +1420,13 @@ async def gflow_character_show( return resolved settings = get_settings() - async with FlowApiClient( - profile_dir=settings.profile_subdir(resolved), - headless=settings.headless, - ) as client: + async with ( + _profile_lock(resolved), + FlowApiClient( + profile_dir=settings.profile_subdir(resolved), + headless=settings.headless, + ) as client, + ): char = await client.get_character(project, entity_id=entity_id, name=name) return {"status": "ok", "project": project, "character": _character_to_dict(char)} @@ -1674,7 +1703,10 @@ async def _run_instructions_op( profile_dir = settings.profile_subdir(resolved) log.info("mcp.tool.instructions", tool=tool, project=project, profile=resolved) try: - async with FlowApiClient(profile_dir=profile_dir, headless=settings.headless) as client: + async with ( + _profile_lock(resolved), + FlowApiClient(profile_dir=profile_dir, headless=settings.headless) as client, + ): # ValueError is scoped to op() ONLY — brief.find (not-found / # ambiguous) and AgentInstruction invariants raise it with # user-facing messages built from the caller's own card data. A diff --git a/src/gflow_cli/worker/daemon.py b/src/gflow_cli/worker/daemon.py index 540b825de..88caf434b 100644 --- a/src/gflow_cli/worker/daemon.py +++ b/src/gflow_cli/worker/daemon.py @@ -331,6 +331,12 @@ def observe_checkpoint(cp: GenerationCheckpoint) -> None: out_dir=out_dir, settings=settings, # same rationale as the image path above ) as client: + # #864: as `gflow video t2v --project-name` does. Without a + # name, client.generate_video creates the project itself. + project_name = task.payload.get("project_name") + if project_id is None and project_name: + created = await client.create_project(title=project_name) + project_id = created.project_id # Resolve @-mentions and expand --tool specs (shared helper). from gflow_cli.services.mentions import resolve_and_apply diff --git a/tests/api/test_client.py b/tests/api/test_client.py index beaf91011..c451815c9 100644 --- a/tests/api/test_client.py +++ b/tests/api/test_client.py @@ -660,7 +660,9 @@ async def test_generate_video_delegates_to_transport( request = GenerateVideoRequest(prompt="sunset over mountains", mode=Mode.T2V) async with FlowApiClient(profile_dir=tmp_path, transport=fake) as client: - result = await client.generate_video(req=request, out_dir=tmp_path, download=True) + result = await client.generate_video( + req=request, project_id="p1", out_dir=tmp_path, download=True + ) assert result.status.media_id == "media-1" diff --git a/tests/api/test_client_project_host.py b/tests/api/test_client_project_host.py new file mode 100644 index 000000000..5e3d1a066 --- /dev/null +++ b/tests/api/test_client_project_host.py @@ -0,0 +1,175 @@ +"""FlowApiClient project create/rename: which host does the work (#864). + +labs.google's project route answers 404 "Flow RPCs have been deprecated and disabled" +(401 for a session without a labs token) on every profile measured on 2026-09-17. The +client falls back to flow.google.com on THAT observed refusal — never on host membership, +never when the operator pinned labs.google, and never for an unrelated failure. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from gflow_cli.api.client import FlowApiClient +from gflow_cli.api.dto import ProjectInfo +from gflow_cli.config import Settings +from gflow_cli.errors import AuthExpiredError, WafRejectionError, WireFormatError + +PID = "57c099ee-a5d6-4e2d-83c9-0346f9644b09" +LABS_OK = { + "result": { + "data": {"json": {"result": {"projectId": "labs-1", "projectInfo": {"projectTitle": "t"}}}} + } +} +RETIRED = WireFormatError(detail="Flow RPCs have been deprecated and disabled.", status=404) +NO_TOKEN = AuthExpiredError(detail="UNAUTHORIZED", status=401) + + +def _client(tmp_path: Path, flow_host: str, labs: Any) -> FlowApiClient: + c = FlowApiClient(profile_dir=tmp_path / "prof", settings=Settings(flow_host=flow_host)) + c._page = MagicMock() # noqa: SLF001 - checkout affordance for unentered clients + c._post_json = ( + AsyncMock(side_effect=labs) if isinstance(labs, Exception) else AsyncMock(return_value=labs) + ) # type: ignore[method-assign] + return c + + +@pytest.fixture +def migrated() -> Any: + with ( + patch( + "gflow_cli.api.transports.migrated_composer.create_project", + new=AsyncMock(return_value=ProjectInfo(project_id=PID, title="t")), + ) as create, + patch( + "gflow_cli.api.transports.migrated_composer.rename_project", new=AsyncMock() + ) as rename, + ): + yield create, rename + + +async def test_pinned_flow_google_com_never_calls_labs(tmp_path: Path, migrated: Any) -> None: + c = _client(tmp_path, "flow.google.com", LABS_OK) + + info = await c.create_project(title="t") + await c.rename_project(PID, "t2") + + assert info.project_id == PID + c._post_json.assert_not_awaited() # type: ignore[attr-defined] + migrated[1].assert_awaited_once() + + +async def test_auto_keeps_a_working_labs_route(tmp_path: Path, migrated: Any) -> None: + c = _client(tmp_path, "auto", LABS_OK) + + info = await c.create_project(title="t") + + assert info.project_id == "labs-1" + migrated[0].assert_not_awaited() + + +@pytest.mark.parametrize("refusal", [RETIRED, NO_TOKEN], ids=["404-retired", "401-no-token"]) +async def test_auto_falls_back_on_the_observed_refusal( + tmp_path: Path, migrated: Any, refusal: Exception +) -> None: + c = _client(tmp_path, "auto", refusal) + + info = await c.create_project(title="t") + await c.rename_project(PID, "t2") + + assert info.project_id == PID + migrated[0].assert_awaited_once() + migrated[1].assert_awaited_once() + + +async def test_pinned_labs_never_falls_back(tmp_path: Path, migrated: Any) -> None: + c = _client(tmp_path, "labs.google", RETIRED) + + with pytest.raises(WireFormatError): + await c.create_project(title="t") + migrated[0].assert_not_awaited() + + +@pytest.mark.parametrize( + "other", + [ + WafRejectionError(detail="waf", status=403), + WireFormatError(detail="bad request", status=400), + ], + ids=["403", "400"], +) +async def test_other_failures_are_not_a_reason_to_switch_host( + tmp_path: Path, migrated: Any, other: Exception +) -> None: + c = _client(tmp_path, "auto", other) + + with pytest.raises(type(other)): + await c.create_project(title="t") + migrated[0].assert_not_awaited() + + +async def test_migrated_create_gets_the_default_title_when_none_given( + tmp_path: Path, migrated: Any +) -> None: + c = _client(tmp_path, "flow.google.com", LABS_OK) + + await c.create_project() + + title = migrated[0].await_args.args[1] + assert isinstance(title, str) and title + + +# --- video without a project ----------------------------------------------------------- + + +class _VideoTransport: + def __init__(self) -> None: + self.project_ids: list[str | None] = [] + + async def generate_video(self, *, request: Any, project_id: str | None, **_: Any) -> Any: + self.project_ids.append(project_id) + return MagicMock(project_id=project_id) + + +def _video_client(tmp_path: Path, flow_host: str) -> tuple[FlowApiClient, _VideoTransport]: + from gflow_cli.api.transports.base import VideoCapableTransport + + c = _client(tmp_path, flow_host, RETIRED) + t = _VideoTransport() + c.transport = t # type: ignore[assignment] + assert isinstance(t, VideoCapableTransport) + return c, t + + +async def test_video_without_a_project_gets_one_created_first( + tmp_path: Path, migrated: Any +) -> None: + """The labs gallery used to create it inside the transport — a path that cannot reach + flow.google.com's projects page. Creating at the client boundary, exactly as + generate_image already does, hands every video route an existing project.""" + from gflow_cli.api.video import GenerateVideoRequest, Mode + + c, t = _video_client(tmp_path, "auto") + + await c.generate_video(req=GenerateVideoRequest(prompt="a crane", mode=Mode.T2V)) + + assert t.project_ids == [PID] + migrated[0].assert_awaited_once() + + +async def test_video_with_a_project_creates_nothing(tmp_path: Path, migrated: Any) -> None: + from gflow_cli.api.video import GenerateVideoRequest, Mode + + c, t = _video_client(tmp_path, "auto") + + await c.generate_video( + req=GenerateVideoRequest(prompt="a crane", mode=Mode.T2V), project_id="p1" + ) + + assert t.project_ids == ["p1"] + c._post_json.assert_not_awaited() # type: ignore[attr-defined] + migrated[0].assert_not_awaited() diff --git a/tests/api/transports/test_migrated_composer.py b/tests/api/transports/test_migrated_composer.py index 1c3b9fbb1..10ceadc01 100644 --- a/tests/api/transports/test_migrated_composer.py +++ b/tests/api/transports/test_migrated_composer.py @@ -123,13 +123,24 @@ class Dom: # on a project with 30+ assets (denon82, 2026-09-05). 0 = listed at once. picker_lists_after_searches: int = 0 picked: list[str] = field(default_factory=list) - chip_binds: bool = True # the option click flips the Start chip to a bound one - chip_bound: bool = False + chip_binds: bool = True # the option click flips the next empty chip to a bound one + #: How many of the two Frames chips (Start, End) are bound. The End path needs a + #: fake that can hold TWO, because `attach_end_frame` asserts `expect_bound_chips=2` + #: — a one-bool fake made that assertion unmodellable and left the End path at zero + #: coverage. + bound_chip_count: int = 0 #: #792: a cohort whose picker does NOT commit on the option click — it stays open #: until its own "Add to prompt" confirm is clicked. The fake used to close the #: picker unconditionally on an option click, so the commit assertion tested the #: fake and this whole cohort was unmodellable. picker_needs_confirm: bool = False + + @property + def chip_bound(self) -> bool: + """At least one chip bound — the Start-path predicate every existing + assertion was written against.""" + return self.bound_chip_count > 0 + #: ...and one where the picker neither commits nor offers a confirm to click. picker_has_confirm: bool = True #: ...and one where the confirm is there, takes the click, and STILL does not commit. @@ -382,14 +393,14 @@ async def click(self, **_: Any) -> None: return # the pick landed; the picker stays up awaiting its confirm dom.picker_open = False if dom.chip_binds: - dom.chip_bound = True + dom.bound_chip_count += 1 elif self.kind == "picker_confirm": dom.confirm_clicks += 1 if not dom.picker_confirm_commits: return # clicked, and the picker still will not go away dom.picker_open = False if dom.chip_binds: - dom.chip_bound = True + dom.bound_chip_count += 1 elif self.kind == "dialog_close": dom.dialog_present = False dom.dialog_closed += 1 @@ -538,7 +549,9 @@ def __init__( # (url, text) or (url, text, http_status) — the status defaults to 200, so an # existing 2-tuple keeps working and a non-200 reply is expressible. self.scripted_responses: list[tuple[str, ...]] = [] # fired on submit click - self.scripted_request: tuple[str, str] | None = None # (rpcid, POST body) on submit + self.scripted_request: tuple[str | None, str] | None = ( + None # (rpcid, body); None = bare URL + ) async def goto(self, url: str, **_: Any) -> None: self.gotos.append(url) @@ -564,10 +577,10 @@ def _fire_response(self, response: FakeResponse) -> None: def _fire_submit(self) -> None: if self.scripted_request is not None: rpcid, body = self.scripted_request + # None rpcid: the real nprQif shape — no rpcids query param at all. + url = _batch_url(rpcid) if rpcid is not None else _batch_bare_url() for h in list(self._handlers["request"]): - asyncio.get_event_loop().create_task( - _maybe_await(h(FakeRequest(_batch_url(rpcid), body))) - ) + asyncio.get_event_loop().create_task(_maybe_await(h(FakeRequest(url, body)))) for scripted in self.scripted_responses: url, text, *rest = scripted self._fire_response(FakeResponse(url, text, *rest)) @@ -602,10 +615,10 @@ def locator(self, css: str, *, scope: FakeLocator | None = None) -> FakeLocator: present = dom.add_menu_open and dom.upload_item_present return FakeLocator(self, "upload_item", ["upload"] if present else []) if css == "flow-prompt-box button.empty-chip": - chips = ["End"] if dom.chip_bound else ["Start", "End"] + chips = ["Start", "End"][dom.bound_chip_count :] return FakeLocator(self, "empty_chip", chips) if css == "flow-prompt-box button.chip-container:has(img)": - return FakeLocator(self, "bound_chip", ["Start"] if dom.chip_bound else []) + return FakeLocator(self, "bound_chip", ["Start", "End"][: dom.bound_chip_count]) if css == "flow-add-menu-popover-content": return FakeLocator(self, "picker_marker", ["picker"]) if css == "input[type='text']": @@ -717,6 +730,7 @@ async def _maybe_await(value: Any) -> None: PROJ = "p1" MEDIA = "33333333-3333-4333-8333-333333333333" MEDIA_UP = "55555555-5555-4555-8555-555555555555" # what `maseQ` answers for an upload +END_UP = "77777777-7777-4777-8777-777777777777" # what `maseQ` answers for the end frame PROJ_UUID = "66666666-6666-4666-8666-666666666666" # the project id in the same reply VIDEO_URL = "https://flow-content.google/v/abc.mp4?Expires=1&KeyName=k&Signature=s" @@ -765,6 +779,11 @@ def _batch_url(rpcid: str) -> str: ) +def _batch_bare_url() -> str: + """A batchexecute URL with no ``rpcids`` query param — the nprQif request shape.""" + return "https://flow.google.com/_/AiSandboxAngularFrontend/data/batchexecute" + + def _t2v(**kw: Any) -> GenerateVideoRequest: base: dict[str, Any] = { "prompt": "a crane", @@ -1538,7 +1557,7 @@ def test_migrated_can_serve_decides_what_the_new_host_takes() -> None: assert migrated_can_serve(_t2v(), "p1") assert migrated_can_serve(_t2v(model=VideoModel.VEO_3_1_FAST), "p1") - assert not migrated_can_serve(_t2v(), None) # project creation not ported + assert not migrated_can_serve(_t2v(), None) # the client creates it first (#864) assert not migrated_can_serve(_t2v(model=VideoModel.VEO_3_1_LITE_LOWER_PRIORITY), "p1") assert not migrated_can_serve( GenerateVideoRequest(prompt="x", mode=Mode.I2V, start_image_ref_name="a"), "p1" @@ -1572,6 +1591,21 @@ async def test_no_submit_frame_within_budget_is_a_timeout() -> None: ) +async def test_submit_timeout_names_the_rpcs_it_did_see() -> None: + from gflow_cli.api.transports.migrated_composer import MigratedComposer + + page = FakePage() + page.dom.prompt = "a crane" + # Traffic, but on an rpcid the observer does not watch: the timeout must + # name it instead of reporting an empty wait (issue #639 slice 2). + page.scripted_responses = [(_batch_url("QqWwEe"), _frame("QqWwEe", [None, 881, []]))] + with pytest.raises(TransportTimeoutError, match="rpcs seen: QqWwEe") as ei: + await MigratedComposer().submit_and_observe( + page, poll_timeout_s=0.3, on_started=None, project_id=PROJ + ) + assert "rpcs seen: none" not in str(ei.value) + + async def test_submit_frame_without_record_is_wire_format_error() -> None: from gflow_cli.api.transports.migrated_composer import MigratedComposer @@ -1712,6 +1746,50 @@ async def test_t2v_without_a_model_leaves_the_picker_alone() -> None: assert page.dom.model_label == "Veo 3.1 - Quality" +async def test_attach_end_frame_binds_the_second_chip(tmp_path: Path) -> None: + """`attach_end_frame` had ZERO coverage: it is the PR's largest new DOM surface + and nothing offline exercised it. Start must be bound first; End then takes the + one remaining empty chip and the driver reads back TWO bound chips.""" + from gflow_cli.api.transports.migrated_composer import MigratedComposer + + page = FakePage() + composer = MigratedComposer() + start_png = _png(tmp_path) + end_png = tmp_path / "02-end.png" + end_png.write_bytes(start_png.read_bytes()) + + await composer.attach_start_frame(page, PROJ, start_png) + assert page.dom.bound_chip_count == 1 + with capture_logs() as logs: + media_id = await composer.attach_end_frame(page, PROJ, end_png) + + assert media_id == MEDIA_UP + assert page.dom.bound_chip_count == 2 + assert not page.dom.picker_open + # Both frames were uploaded and picked — a fake that bound only Start would + # leave one entry here, which is the ablation D12 used to catch a vacuous test. + assert len(page.dom.picked) == 2 + staged = page.dom.chosen_files[-1] + assert re.fullmatch(r"02-end-[0-9a-f]{8}\.png", staged), staged + events = [e["event"] for e in logs] + assert "migrated.frame_uploaded" in events and "migrated.frame_bound" in events + + +async def test_attach_end_frame_without_a_bound_start_is_selector_drift( + tmp_path: Path, +) -> None: + """The Start chip must already hold a frame. If it silently did not bind, End + would otherwise be written INTO the Start chip; the two-bound-chip readback is + what turns that into a named failure instead of a wrong billed generation.""" + from gflow_cli.api.transports.migrated_composer import MigratedComposer + + page = FakePage() + with pytest.raises(UiSelectorDriftError) as excinfo: + await MigratedComposer().attach_end_frame(page, PROJ, _png(tmp_path)) + assert "End" in str(excinfo.value) + assert page.dom.bound_chip_count == 1 # Start took it — exactly the mis-bind + + async def test_attach_uploads_then_binds_the_frame_by_file_name(tmp_path: Path) -> None: from gflow_cli.api.transports.migrated_composer import MigratedComposer @@ -2102,6 +2180,18 @@ def _i2v_body(media_id: str = MEDIA_UP, key: str = "veo_3_1_i2v_lite") -> str: return f'f.req=[[["eb1hJf","[\\"{key}\\",\\"{media_id}\\",\\"{PROJ}\\"]",null,"generic"]]]' +def _interp_body( + start_id: str = MEDIA_UP, + end_id: str = END_UP, + key: str = "veo_3_1_interpolation_lite", +) -> str: + """The captured nprQif shape: rpc in f.req, interpolation key, both frame ids.""" + return ( + f'f.req=[[["nprQif","[\\"a prompt\\",\\"{key}\\",1,null,' + f'[null,\\"{start_id}\\"],[null,\\"{end_id}\\"],\\"{PROJ}\\"]",null,"generic"]]]' + ) + + async def test_submit_accepts_eb1hjf_as_the_i2v_submit_rpc() -> None: from gflow_cli.api.transports.migrated_composer import MigratedComposer @@ -2127,6 +2217,189 @@ async def test_submit_accepts_eb1hjf_as_the_i2v_submit_rpc() -> None: assert page.listeners("request") == [] and page.listeners("response") == [] +async def test_submit_observes_the_interpolation_rpc_from_the_request_body() -> None: + """Start+end submits travel on nprQif with the rpc name in f.req, not the URL + (captured 2026-09-16, #639): the observer routes by body, asserts the + interpolation key and both frame ids, then reads the standard CAE reply.""" + from gflow_cli.api.transports.migrated_composer import MigratedComposer + + page = FakePage() + page.dom.prompt = "a crane" + page.scripted_request = (None, _interp_body()) + page.scripted_responses = [ + (_batch_url("nprQif"), _frame("nprQif", [None, 9312, [[MEDIA]], [[_record(6)]]])), + (_batch_url("jwpduf"), _frame("jwpduf", [None, 9312, [[_record(2)]]])), + (_batch_url("as29s"), _frame("as29s", _record(3, VIDEO_URL))), + ] + started: list[Any] = [] + with capture_logs() as logs: + rec = await MigratedComposer().submit_and_observe( + page, + poll_timeout_s=2.0, + on_started=started.append, + project_id=PROJ, + expect_media_id=MEDIA_UP, + expect_end_media_id=END_UP, + ) + assert rec.is_done and rec.video_url == VIDEO_URL and started[0].media_id == MEDIA + observed = next(e for e in logs if e["event"] == "migrated.submit_observed") + assert observed["rpc"] == "nprQif" + assert page.listeners("request") == [] and page.listeners("response") == [] + + +async def test_interpolation_submit_missing_the_end_frame_is_a_wire_format_error() -> None: + from gflow_cli.api.transports.migrated_composer import MigratedComposer + + page = FakePage() + page.dom.prompt = "a crane" + page.scripted_request = (None, _interp_body(end_id=MEDIA)) # some other asset + page.scripted_responses = [ + (_batch_url("nprQif"), _frame("nprQif", [None, 9312, [[MEDIA]], [[_record(6)]]])), + (_batch_url("as29s"), _frame("as29s", _record(3, VIDEO_URL))), + ] + with pytest.raises(WireFormatError, match="end frame") as ei: + await MigratedComposer().submit_and_observe( + page, + poll_timeout_s=2.0, + on_started=None, + project_id=PROJ, + expect_media_id=MEDIA_UP, + expect_end_media_id=END_UP, + ) + assert "nprQif" in ei.value.route + + +async def test_interpolation_submit_on_a_start_only_run_is_refused() -> None: + """nprQif without a bound end frame is a billing mismatch, not a run to adopt.""" + from gflow_cli.api.transports.migrated_composer import MigratedComposer + + page = FakePage() + page.dom.prompt = "a crane" + page.scripted_request = (None, _interp_body()) + page.scripted_responses = [ + (_batch_url("nprQif"), _frame("nprQif", [None, 9312, [[MEDIA]], [[_record(6)]]])), + (_batch_url("as29s"), _frame("as29s", _record(3, VIDEO_URL))), + ] + with pytest.raises(WireFormatError, match="no bound end frame") as ei: + await MigratedComposer().submit_and_observe( + page, + poll_timeout_s=2.0, + on_started=None, + project_id=PROJ, + expect_media_id=MEDIA_UP, + ) + assert "nprQif" in ei.value.route + + +def test_interpolation_body_problem_names_what_is_wrong() -> None: + from gflow_cli.api.transports.migrated_composer import _interpolation_body_problem + + ok = _interp_body() + assert _interpolation_body_problem(ok, "nprQif", MEDIA_UP, END_UP) is None + missing_end = _interpolation_body_problem( + _interp_body(end_id=MEDIA), "nprQif", MEDIA_UP, END_UP + ) + assert missing_end is not None and "end frame" in missing_end and END_UP in missing_end + wrong_key = _interpolation_body_problem( + _interp_body(key="veo_3_1_t2v_lite"), "nprQif", MEDIA_UP, END_UP + ) + assert wrong_key is not None and "expected an interpolation" in wrong_key + omni = _interpolation_body_problem( + _interp_body(key="omni_flash_i2v_4s_first_last"), "nprQif", MEDIA_UP, END_UP + ) + assert omni is None + no_end_bound = _interpolation_body_problem(ok, "nprQif", MEDIA_UP, None) + assert no_end_bound is not None and "no bound end frame" in no_end_bound + unreadable = _interpolation_body_problem("", "nprQif", MEDIA_UP, END_UP) + assert unreadable is not None and "could not be read" in unreadable + + +async def test_interpolation_reply_on_a_bare_batchexecute_url_resolves() -> None: + """The captured interpolation reply shape: a `batchexecute` POST with NO + `rpcids` query param, whose frame names `nprQif`. `parse_frames` recovers the + id from the frame, so the response handler must let a param-less URL through + instead of filtering it out. Measured 2026-09-17 (spike: end-frame submit + contract) — the omni key is what this cohort sends.""" + from gflow_cli.api.transports.migrated_composer import MigratedComposer + + end = "77777777-7777-4777-8777-777777777777" + body = ( + f'f.req=[[["nprQif","[\\"omni_flash_i2v_8s_first_last\\",\\"{MEDIA_UP}\\",' + f'\\"{end}\\"]",null,"generic"]]]' + ) + page = FakePage() + page.dom.prompt = "a crane" + page.scripted_request = ("nprQif", body) + page.scripted_responses = [ + (_batch_bare_url(), _frame("nprQif", [None, 881, [[MEDIA]], [[_record(6)]]])), + (_batch_url("as29s"), _frame("as29s", _record(3, VIDEO_URL))), + ] + started: list[Any] = [] + with capture_logs() as logs: + rec = await MigratedComposer().submit_and_observe( + page, + poll_timeout_s=2.0, + on_started=started.append, + project_id=PROJ, + expect_media_id=MEDIA_UP, + expect_end_media_id=end, + ) + assert rec.is_done and rec.media_id == MEDIA and started[0].media_id == MEDIA + observed = next(e for e in logs if e["event"] == "migrated.submit_observed") + assert observed["rpc"] == "nprQif" + assert page.listeners("request") == [] and page.listeners("response") == [] + + +async def test_submit_on_an_unwatched_rpc_is_never_adopted() -> None: + """Adoption-by-content was removed: an unwatched rpcid whose body merely LOOKS + like ours must not become our submit. It is recorded in the timeout diagnostic + and nothing else — a named timeout beats a hard failure on a billed run.""" + from gflow_cli.api.transports.migrated_composer import MigratedComposer + + end = "77777777-7777-4777-8777-777777777777" + body = ( + f'f.req=[[["QxJxZz","[\\"veo_3_1_i2v_lite\\",\\"{MEDIA_UP}\\",' + f'\\"{end}\\"]",null,"generic"]]]' + ) + page = FakePage() + page.dom.prompt = "a crane" + page.scripted_request = ("QxJxZz", body) + page.scripted_responses = [ + (_batch_url("QxJxZz"), _frame("QxJxZz", [None, 881, [[MEDIA]], [[_record(6)]]])), + ] + with pytest.raises(TransportTimeoutError) as excinfo: + await MigratedComposer().submit_and_observe( + page, + poll_timeout_s=2.0, + on_started=None, + project_id=PROJ, + expect_media_id=MEDIA_UP, + expect_end_media_id=end, + ) + assert "QxJxZz" in str(excinfo.value) + assert page.listeners("request") == [] and page.listeners("response") == [] + + +async def test_submit_timeout_names_request_rpcids_too() -> None: + from gflow_cli.api.transports.migrated_composer import MigratedComposer + + page = FakePage() + page.dom.prompt = "a crane" + # A request went out on an rpcid nobody watches and nothing answered: the + # timeout must name the request side, not just heard replies (issue #639). + page.scripted_request = ("QwErTy", "unrelated-poll-body") + page.scripted_responses = [] + with pytest.raises(TransportTimeoutError, match="rpcs seen: QwErTy") as ei: + await MigratedComposer().submit_and_observe( + page, + poll_timeout_s=0.3, + on_started=None, + project_id=PROJ, + expect_media_id=MEDIA_UP, + ) + assert "QwErTy" in str(ei.value) + + async def test_submit_body_without_the_bound_media_id_is_wire_format_error() -> None: from gflow_cli.api.transports.migrated_composer import MigratedComposer @@ -2144,6 +2417,21 @@ async def test_submit_body_without_the_bound_media_id_is_wire_format_error() -> assert EXIT_CODE_MAP[WireFormatError] == 7 and "eb1hJf" in ei.value.route +def test_i2v_body_problem_requires_the_bound_end_frame_when_expected() -> None: + from gflow_cli.api.transports.migrated_composer import _i2v_body_problem + + start, end = MEDIA_UP, "77777777-7777-4777-8777-777777777777" + + def body(*ids: str) -> str: + inner = ",".join(f'"{i}"' for i in ids) + return f'f.req=[[["eb1hJf","[\\"veo_3_1_i2v_lite\\",{inner}]",null,"generic"]]]' + + assert _i2v_body_problem(body(start, end), "eb1hJf", start, end_media_id=end) is None + assert _i2v_body_problem(body(start), "eb1hJf", start) is None + problem = _i2v_body_problem(body(start), "eb1hJf", start, end_media_id=end) + assert problem is not None and "end frame" in problem and end in problem + + async def test_submit_body_that_cannot_be_read_is_named_as_such() -> None: """An unreadable body must not be reported as "a text-to-video key" — that is a different fault with a different fix.""" @@ -2694,8 +2982,8 @@ async def test_run_images_refuses_an_unported_form_before_touching_the_page() -> async def test_run_images_without_a_project_is_a_configuration_error() -> None: - """A fresh project can only be made through the labs gallery, so the caller must - name one. Exit 11, not a mid-run failure after the editor is already mounted. + """The client creates the project before this runs (#864); reaching it without one + is a caller bug. Exit 11, not a mid-run failure after the editor is already mounted. """ from gflow_cli.api.image import GenerateImageRequest from gflow_cli.api.transports.migrated_composer import run_images diff --git a/tests/api/transports/test_migrated_dispatch.py b/tests/api/transports/test_migrated_dispatch.py index 7b710950c..8d6a33b8c 100644 --- a/tests/api/transports/test_migrated_dispatch.py +++ b/tests/api/transports/test_migrated_dispatch.py @@ -146,7 +146,8 @@ async def test_a_composer_run_does_not_route_the_next_request_by_its_page( async def test_unmoved_account_without_a_project_keeps_the_labs_driver( harness: dict[str, Any], ) -> None: - """Project creation is not ported to the new host, so the labs gallery does it.""" + """No project reaches the transport only past the client, which creates one (#864); + here the labs arm stays exactly as it was.""" with pytest.raises(_LabsDriverTouchedError): await harness["transport"].generate_video(request=_req(), project_id=None) assert harness["run_video"] == [] @@ -270,7 +271,7 @@ async def test_run_video_needs_a_project_on_the_migrated_host() -> None: await _run(_req(), url="https://flow.google.com/", project_id=None) -# --- i2v (slice 1: a local start frame) --------------------------------------- +# --- i2v (slice 1: a local start frame; slice 2: a local end frame) ------------ def _png(tmp_path: Path, name: str = "hero.png") -> Path: @@ -282,7 +283,7 @@ def _png(tmp_path: Path, name: str = "hero.png") -> Path: _UUID = "33333333-3333-4333-8333-333333333333" -def test_migrated_can_serve_takes_i2v_only_with_a_local_start_frame(tmp_path: Path) -> None: +def test_migrated_can_serve_takes_i2v_with_local_start_and_end_frames(tmp_path: Path) -> None: from gflow_cli.api.transports.migrated_composer import migrated_can_serve from gflow_cli.api.video import VideoModel @@ -292,9 +293,15 @@ def test_migrated_can_serve_takes_i2v_only_with_a_local_start_frame(tmp_path: Pa _req(mode=Mode.I2V, start_image=png, model=VideoModel.VEO_3_1_LITE), "p1" ) assert not migrated_can_serve(_req(mode=Mode.I2V, start_image=png), None) - assert not migrated_can_serve(_req(mode=Mode.I2V, start_image=png, end_image=png), "p1") + assert migrated_can_serve(_req(mode=Mode.I2V, start_image=png, end_image=png), "p1") assert not migrated_can_serve(_req(mode=Mode.I2V, start_image_ref_id=_UUID), "p1") assert not migrated_can_serve(_req(mode=Mode.I2V, start_image_ref_name="hero"), "p1") + assert not migrated_can_serve( + _req(mode=Mode.I2V, start_image=png, end_image_ref_id=_UUID), "p1" + ) + assert not migrated_can_serve( + _req(mode=Mode.I2V, start_image=png, end_image_ref_name="hero"), "p1" + ) assert not migrated_can_serve( _req(mode=Mode.I2V, start_image=png, model=VideoModel.VEO_3_1_LITE_LOWER_PRIORITY), "p1" ) @@ -310,13 +317,24 @@ async def test_i2v_with_a_local_start_frame_is_served_by_the_migrated_host( assert len(harness["run_video"]) == 1 -async def test_i2v_with_an_end_frame_keeps_the_labs_driver_on_an_unmoved_account( +async def test_i2v_with_a_local_end_frame_is_served_by_the_migrated_host( + harness: dict[str, Any], tmp_path: Path +) -> None: + png = _png(tmp_path) + await harness["transport"].generate_video( + request=_req(mode=Mode.I2V, start_image=png, end_image=png), project_id="p1" + ) + assert len(harness["run_video"]) == 1 + + +async def test_i2v_with_an_end_frame_by_uuid_keeps_the_labs_driver_on_an_unmoved_account( harness: dict[str, Any], tmp_path: Path ) -> None: png = _png(tmp_path) with pytest.raises(_LabsDriverTouchedError): await harness["transport"].generate_video( - request=_req(mode=Mode.I2V, start_image=png, end_image=png), project_id="p1" + request=_req(mode=Mode.I2V, start_image=png, end_image_ref_id=_UUID), + project_id="p1", ) assert harness["run_video"] == [] @@ -331,10 +349,10 @@ async def test_i2v_by_uuid_keeps_the_labs_driver_on_an_unmoved_account( assert harness["run_video"] == [] -async def test_run_video_names_the_end_frame_in_the_exit_36_detail(tmp_path: Path) -> None: +async def test_run_video_names_the_end_frame_uuid_in_the_exit_36_detail(tmp_path: Path) -> None: png = _png(tmp_path) - with pytest.raises(FlowHostMigratedError, match="end frame") as ei: - await _run(_req(mode=Mode.I2V, start_image=png, end_image=png)) + with pytest.raises(FlowHostMigratedError, match="end frame given by Flow media UUID") as ei: + await _run(_req(mode=Mode.I2V, start_image=png, end_image_ref_id=_UUID)) assert "--initial-frame" in str(ei.value) diff --git a/tests/api/transports/test_migrated_images.py b/tests/api/transports/test_migrated_images.py index e17811053..87ff35bc8 100644 --- a/tests/api/transports/test_migrated_images.py +++ b/tests/api/transports/test_migrated_images.py @@ -124,23 +124,23 @@ def test_unported_image_forms_are_named_and_refused_pre_submit() -> None: assert _unported_image_form(_request(model=Model.IMAGEN_3_5)) is not None -def test_only_the_measured_aspects_are_offered_and_three_four_is_refused() -> None: - """3:4 has no radio in the enumerated aspect row, so it must be refused as an - unported form (exit 36) — never left to miss its selector and surface as - UiSelectorDriftError (exit 23), which tells the user to file a frontend bug - about a frontend that is behaving correctly. +def test_every_image_aspect_flow_renders_is_offered() -> None: + """All five of gflow's image aspects are served on flow.google.com. + + The 2026-09-08 enumeration found four radios and 3:4 was refused (exit 36). The + 2026-09-17 re-enumeration (scripts/dev/spike_migrated_aspect_radios.py) found five: + ``crop_16_9, crop_landscape, crop_square, crop_portrait, crop_9_16``. """ from gflow_cli.api.transports.migrated_composer import ( + IMAGE_ASPECT_LIGATURE, IMAGE_ASPECT_LIGATURE_MEASURED, _unported_image_form, ) - for aspect in IMAGE_ASPECT_LIGATURE_MEASURED: + assert set(Aspect) == IMAGE_ASPECT_LIGATURE_MEASURED + assert IMAGE_ASPECT_LIGATURE[Aspect.PORTRAIT_THREE_FOUR] == "crop_portrait" + for aspect in Aspect: assert _unported_image_form(_request(aspect=aspect)) is None, aspect - assert Aspect.PORTRAIT_THREE_FOUR not in IMAGE_ASPECT_LIGATURE_MEASURED - refusal = _unported_image_form(_request(aspect=Aspect.PORTRAIT_THREE_FOUR)) - assert refusal is not None - assert "aspect" in refusal def test_image_submit_body_requires_every_uploaded_reference() -> None: diff --git a/tests/api/transports/test_migrated_project.py b/tests/api/transports/test_migrated_project.py new file mode 100644 index 000000000..8bcc2d09b --- /dev/null +++ b/tests/api/transports/test_migrated_project.py @@ -0,0 +1,213 @@ +"""Project create / rename on flow.google.com (#864). + +The fake models what `docs/superpowers/spikes/2026-09-17-project-create-on-flow-google-com.md` +measured: the projects page's `add` FAB fires `jHPbke` and answers `[id, [title]]`; the +project header's `flow-editable-text` input renames through `o8DA4`, answering `[title]`. +""" + +from __future__ import annotations + +import json +from typing import Any + +import pytest + +from gflow_cli.api.transports import migrated_composer as mc +from gflow_cli.errors import TransportTimeoutError, UiSelectorDriftError, WireFormatError + +PID = "57c099ee-a5d6-4e2d-83c9-0346f9644b09" + + +def _frame(rpcid: str, payload: Any) -> str: + frame = [["wrb.fr", rpcid, json.dumps(payload), None, None, None, "generic"]] + return ")]}'\n\n10\n" + json.dumps(frame) + "\n" + + +def _batch(rpcid: str) -> str: + return f"https://flow.google.com/_/AiSandboxAngularFrontend/data/batchexecute?rpcids={rpcid}" + + +class _Response: + def __init__(self, url: str, text: str) -> None: + self.url, self._text = url, text + + async def text(self) -> str: + return self._text + + +class _Loc: + def __init__(self, page: FakePage, sel: str) -> None: + self.page, self.sel = page, sel + + @property + def first(self) -> _Loc: + return self + + async def wait_for(self, **_: Any) -> None: + if not self.page.present.get(self.sel, False): + raise mc.PlaywrightTimeoutError(f"waiting for {self.sel}") + + async def click(self, **_: Any) -> None: + self.page.clicks.append(self.sel) + await self.page.fire(self.page.on_click.get(self.sel)) + + async def fill(self, value: str, **_: Any) -> None: + self.page.filled = value + + async def press(self, key: str, **_: Any) -> None: + if key == "Enter": + await self.page.fire(self.page.on_enter) + + async def is_visible(self) -> bool: + return False + + async def count(self) -> int: + return 1 if self.page.present.get(self.sel, False) else 0 + + +class FakePage: + def __init__(self, *, url: str = "about:blank") -> None: + self.url = url + self.gotos: list[str] = [] + self.clicks: list[str] = [] + self.filled: str | None = None + self.present: dict[str, bool] = {mc.NEW_PROJECT_BUTTON: True, mc.PROJECT_TITLE_INPUT: True} + self.on_click: dict[str, tuple[str, str] | None] = {} + self.on_enter: tuple[str, str] | None = None + self.navigate_on_create: str | None = None + self._handlers: list[Any] = [] + self.keyboard = self + + async def goto(self, url: str, **_: Any) -> None: + self.gotos.append(url) + self.url = url + + def locator(self, sel: str) -> _Loc: + return _Loc(self, sel) + + def on(self, event: str, handler: Any) -> None: + if event == "response": + self._handlers.append(handler) + + def remove_listener(self, event: str, handler: Any) -> None: + if event == "response" and handler in self._handlers: + self._handlers.remove(handler) + + async def fire(self, reply: tuple[str, str] | None) -> None: + if reply is None: + return + for h in list(self._handlers): + await h(_Response(*reply)) + if self.navigate_on_create: + self.url = self.navigate_on_create + + +def _page_creating(title: str = "Sep 17 - 17:02", pid: str = PID) -> FakePage: + page = FakePage() + page.on_click[mc.NEW_PROJECT_BUTTON] = ( + _batch(mc.CREATE_PROJECT_RPC), + _frame("jHPbke", [pid, [title]]), + ) + page.navigate_on_create = f"https://flow.google.com/project/{pid}" + return page + + +# --- create ----------------------------------------------------------------------------- + + +async def test_create_reads_id_and_title_from_the_rpc_reply() -> None: + page = _page_creating(title="Sep 17 - 17:02") + page.on_enter = (_batch(mc.RENAME_PROJECT_RPC), _frame("o8DA4", ["my film"])) + + info = await mc.create_project(page, "my film") # type: ignore[arg-type] + + assert info.project_id == PID + assert info.title == "my film" + assert page.gotos[0] == "https://flow.google.com/" + assert page.clicks == [mc.NEW_PROJECT_BUTTON] + assert page.filled == "my film" # the requested title was applied by rename + + +async def test_create_skips_rename_when_the_reply_already_carries_the_title() -> None: + page = _page_creating(title="my film") + + info = await mc.create_project(page, "my film") # type: ignore[arg-type] + + assert info.title == "my film" + assert page.filled is None + + +async def test_create_without_the_fab_is_drift_naming_the_anchor() -> None: + page = _page_creating() + page.present[mc.NEW_PROJECT_BUTTON] = False + + with pytest.raises(UiSelectorDriftError, match="new-project"): + await mc.create_project(page, "x", timeout_s=0.1) # type: ignore[arg-type] + + +async def test_create_with_no_reply_times_out() -> None: + page = _page_creating() + page.on_click[mc.NEW_PROJECT_BUTTON] = None + + with pytest.raises(TransportTimeoutError, match=mc.CREATE_PROJECT_RPC): + await mc.create_project(page, "x", timeout_s=0.1) # type: ignore[arg-type] + + +async def test_create_reply_without_a_project_id_is_a_wire_format_error() -> None: + page = _page_creating(pid="not-a-uuid") + + with pytest.raises(WireFormatError, match=mc.CREATE_PROJECT_RPC): + await mc.create_project(page, "x", timeout_s=0.1) # type: ignore[arg-type] + + +async def test_create_ignores_other_rpcs_while_waiting() -> None: + page = FakePage() + page.on_click[mc.NEW_PROJECT_BUTTON] = (_batch("UpteDb"), _frame("UpteDb", [[[PID, ["old"]]]])) + + with pytest.raises(TransportTimeoutError): + await mc.create_project(page, "x", timeout_s=0.1) # type: ignore[arg-type] + + +# --- rename ----------------------------------------------------------------------------- + + +async def test_rename_navigates_to_the_project_and_confirms_the_echo() -> None: + page = FakePage() + page.on_enter = (_batch(mc.RENAME_PROJECT_RPC), _frame("o8DA4", ["new title"])) + + await mc.rename_project(page, PID, "new title") # type: ignore[arg-type] + + assert page.gotos == [f"https://flow.google.com/project/{PID}"] + assert page.filled == "new title" + + +async def test_rename_does_not_reload_a_project_page_already_open() -> None: + page = FakePage(url=f"https://flow.google.com/project/{PID}") + page.on_enter = (_batch(mc.RENAME_PROJECT_RPC), _frame("o8DA4", ["t"])) + + await mc.rename_project(page, PID, "t") # type: ignore[arg-type] + + assert page.gotos == [] + + +async def test_rename_echoing_another_title_is_a_wire_format_error() -> None: + page = FakePage() + page.on_enter = (_batch(mc.RENAME_PROJECT_RPC), _frame("o8DA4", ["something else"])) + + with pytest.raises(WireFormatError, match="something else"): + await mc.rename_project(page, PID, "wanted", timeout_s=0.1) # type: ignore[arg-type] + + +async def test_rename_without_the_title_input_is_drift() -> None: + page = FakePage() + page.present[mc.PROJECT_TITLE_INPUT] = False + + with pytest.raises(UiSelectorDriftError, match="title"): + await mc.rename_project(page, PID, "t", timeout_s=0.1) # type: ignore[arg-type] + + +def test_anchors_are_structural_not_labels() -> None: + """AGENTS.md locale-invariance: no display text in these selectors.""" + assert "flow-projects-page" in mc.NEW_PROJECT_BUTTON + assert "'add'" in mc.NEW_PROJECT_BUTTON or '"add"' in mc.NEW_PROJECT_BUTTON + assert mc.PROJECT_TITLE_INPUT.startswith("flow-editable-text") diff --git a/tests/cli/test_cli_docs.py b/tests/cli/test_cli_docs.py new file mode 100644 index 000000000..4f4a8c843 --- /dev/null +++ b/tests/cli/test_cli_docs.py @@ -0,0 +1,300 @@ +"""`gflow docs` — resolution, refusal, encoding and `--json` (#861). + +Covers `SCENARIO.md` scenarios 1, 2, 4, 6, 7, 10, 12, 16. The two that need the real +shipped pages live in `test_docs_catalog_real_docs.py`; the one that needs a built wheel +lives in `tests/integration/test_docs_ships_in_wheel.py`. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path +from typing import Any + +import pytest +from click.testing import CliRunner + +from gflow_cli import docs_catalog +from gflow_cli.cli import main +from gflow_cli.errors import EXIT_CODE_MAP, ConfigurationError + +_PAGES = { + "USAGE.md": "# Usage\n\nCommand-by-command reference for every flag.\n", + "USER_GUIDE.md": "# User Guide\n\nTask-oriented walkthroughs.\n", + "CONFIGURATION.md": "# Configuration\n\nEnvironment variables and precedence.\n", + "INDEX.md": ( + "# Documentation Index\n\n" + "## Topic shortcuts\n\n" + '**"Where do generated files land?"** → [CONFIGURATION](CONFIGURATION.md#paths)\n' + ), +} + + +@pytest.fixture +def docs_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """A small documentation tree standing in for the shipped one.""" + directory = tmp_path / "docs" + directory.mkdir() + for name, body in _PAGES.items(): + (directory / name).write_text(body, encoding="utf-8") + monkeypatch.setattr(docs_catalog, "_docs_dir", lambda: directory) + return directory + + +def _run(*args: str) -> Any: + return CliRunner().invoke(main, ["docs", *args], catch_exceptions=False) + + +# --- resolution ------------------------------------------------------------- + + +@pytest.mark.parametrize("name", ["usage", "USAGE", "USAGE.md", "usage.md", " usage "]) +def test_a_page_resolves_from_every_form_a_reader_types(docs_dir: Path, name: str) -> None: + assert docs_catalog.resolve(name).file_name == "USAGE.md" + + +def test_an_unambiguous_prefix_resolves(docs_dir: Path) -> None: + # Scenario 16: `gflow docs conf` is what a reader types; requiring the full name + # makes the command feel broken for the most obvious input. + assert docs_catalog.resolve("conf").file_name == "CONFIGURATION.md" + + +def test_an_ambiguous_prefix_is_refused_and_names_the_candidates(docs_dir: Path) -> None: + with pytest.raises(ConfigurationError) as caught: + docs_catalog.resolve("us") + detail = str(caught.value) + assert "usage" in detail and "user-guide" in detail + + +def test_an_underscore_in_the_name_resolves_like_the_dash(docs_dir: Path) -> None: + assert docs_catalog.resolve("user_guide").file_name == "USER_GUIDE.md" + + +# --- refusal ---------------------------------------------------------------- + + +@pytest.mark.parametrize( + "hostile", + [ + "../../../../etc/passwd", + r"..\..\..\Windows\System32\config\SAM", + "/etc/shadow", + "C:\\Windows\\win.ini", + "..", + "../INDEX", + "usage/../../secret", + ], +) +def test_a_topic_name_is_never_used_to_build_a_path(docs_dir: Path, hostile: str) -> None: + """Scenarios 1 and 2. + + The argument is a key looked up in the enumerated set of pages, so a traversal + attempt is an ordinary unknown-topic refusal. Asserted per input anyway: this is the + one property of this command whose breakage would be a file-read primitive, and a + future refactor that starts joining paths must fail here. + """ + with pytest.raises(ConfigurationError): + docs_catalog.resolve(hostile) + + +@pytest.mark.parametrize( + "hostile", ["../../../../etc/passwd", "usage\x00.md", r"..\..\Windows\win.ini"] +) +def test_a_hostile_name_is_refused_through_the_command_too(docs_dir: Path, hostile: str) -> None: + """The traversal tests above stop at `resolve()`. If `_emit_page` ever grew its own + path join, every one of them would still pass — so the refusal is asserted at the + surface a user actually reaches.""" + result = _run(hostile) + assert result.exit_code == 11, result.output + assert "passwd" not in result.output or "no documentation topic" in result.output + + +def test_a_refusal_carries_a_remediation_about_docs_not_transports(docs_dir: Path) -> None: + """Every refusal used to inherit ConfigurationError's default hint: "Run `gflow config + list-transports`" — advice about a transport registry, printed at someone who asked + for a page.""" + # The error lane emits two JSON documents (the structured-log line, then the payload), + # so this asserts on the text rather than pretending it is one object. + output = _run("nosuchtopic", "--json").output + assert '"remediation_hint"' in output, output + assert "gflow docs" in output, output + assert "list-transports" not in output, output + + +def test_an_unknown_topic_exits_11_and_suggests(docs_dir: Path) -> None: + # Scenario 6: a class already in EXIT_CODE_MAP — no new exception, no new exit code. + assert EXIT_CODE_MAP[ConfigurationError] == 11 + # "guide" is nobody's prefix, but it IS inside `user-guide` — so the refusal has + # something to offer. A refusal that only says no makes the reader run `docs` and + # scan 126 rows, which is the state this command exists to end. + result = _run("guide") + assert result.exit_code == 11, result.output + assert "user-guide" in result.output + + +def test_a_typo_is_offered_the_nearest_topic(docs_dir: Path) -> None: + # `usge` shares no substring with `usage`, so the substring scan offered nothing and + # the documented promise ("suggests the nearest ones") was false until difflib. + result = _run("usge") + assert result.exit_code == 11, result.output + assert "usage" in result.output + + +@pytest.mark.parametrize("term", ["flag", ""]) +def test_a_topic_and_a_search_together_are_refused(docs_dir: Path, term: str) -> None: + # The empty term is the interesting one: the guard used to test truthiness while the + # dispatch tested `is not None`, so `--search ""` slipped through and the topic was + # silently discarded. + result = _run("usage", "--search", term) + assert result.exit_code == 11, result.output + assert "not both" in result.output + + +def test_an_empty_topic_is_refused_rather_than_listing_everything(docs_dir: Path) -> None: + result = _run("") + assert result.exit_code == 11, result.output + + +# --- search ----------------------------------------------------------------- + + +def test_a_search_with_no_matches_exits_zero(docs_dir: Path) -> None: + # Scenario 7: an empty result is a true answer to a valid question, not a failure. + result = _run("--search", "zzzznotinanypage") + assert result.exit_code == 0, result.output + + +def test_a_curated_index_answer_outranks_a_body_hit(docs_dir: Path) -> None: + # Scenario 9. "generated files land" is an INDEX shortcut; "Environment" is body text. + matches = docs_catalog.search("generated files land") + assert matches + assert matches[0].file_name == "INDEX.md" + assert matches[0].score >= docs_catalog._CURATED_BONUS + + +def test_search_still_works_when_the_index_has_no_curated_rows( + docs_dir: Path, +) -> None: + # Scenario 10: the curated bonus is additive. If INDEX.md's format changes, search + # degrades to plain body text — it must not crash and must not silently return nothing. + (docs_dir / "INDEX.md").write_text("# Index\n\nNo shortcuts here any more.\n", "utf-8") + matches = docs_catalog.search("precedence") + assert matches and matches[0].file_name == "CONFIGURATION.md" + + +def test_a_hash_inside_a_fenced_block_is_not_scored_as_a_heading(docs_dir: Path) -> None: + """A `#` in a code fence is a shell comment, not a section heading. + + Scored as a heading it beat real prose: `# 5. One cheapest stable T2V generation, no + explicit --duration` — a line from a release record — ranked 2nd of 136 for + `--search duration`, which is the noise the ranking was added to remove. + """ + (docs_dir / "USAGE.md").write_text( + "\n".join( + [ + "# Usage", # 1 + "", # 2 + "A real heading follows.", # 3 + "", # 4 + "## widget options", # 5 — a real heading + "", # 6 + "```bash", # 7 + "# widget --flag", # 8 — a shell comment, not a heading + "```", # 9 + ] + ), + encoding="utf-8", + ) + by_line = {m.line_no: m.score for m in docs_catalog.search("widget")} + heading, fenced = by_line[5], by_line[8] + assert heading >= docs_catalog._HEADING_BONUS, by_line + assert fenced < docs_catalog._HEADING_BONUS, by_line + + +def test_all_query_terms_must_appear_on_the_line(docs_dir: Path) -> None: + assert docs_catalog.search("environment precedence") + assert not docs_catalog.search("environment walkthroughs") + + +def test_a_long_line_is_windowed_around_the_match(docs_dir: Path) -> None: + (docs_dir / "USAGE.md").write_text( + "# Usage\n\n" + ("padding " * 400) + "NEEDLE" + (" padding" * 400) + "\n", + encoding="utf-8", + ) + text = docs_catalog.search("needle")[0].text + assert "NEEDLE" in text + assert len(text) < 250, len(text) # not the 6 KB line + + +# --- rendering -------------------------------------------------------------- + + +@pytest.mark.parametrize("args", [["docs", "usage"], ["docs"], ["docs", "--search", "the"]]) +def test_the_command_survives_a_cp1252_console(args: list[str]) -> None: + """The #846 shape, one release old, asserted on the COMMAND rather than on a helper. + + The first version of this test monkeypatched `sys.stdout` and called the encoding + helper directly. A council mutation proved it worthless: replacing every call site of + that helper with `str` left the whole suite green. Nothing required the command to + use it, and Rich's own box-drawing glyphs were never covered at all. + + A subprocess with `PYTHONIOENCODING=cp1252` is the only arrangement where a real + `UnicodeEncodeError` can happen, so it is the only one that can fail. + """ + env = {**os.environ, "PYTHONIOENCODING": "cp1252"} + env.pop("PYTHONUTF8", None) + done = subprocess.run( # noqa: S603 - fixed argv, no shell + [sys.executable, "-m", "gflow_cli.cli", *args], + capture_output=True, + text=True, + encoding="cp1252", + errors="replace", + env=env, + ) + assert done.returncode == 0, done.stderr + assert "UnicodeEncodeError" not in done.stderr, done.stderr + assert done.stdout.strip(), "the command printed nothing" + + +# --- --json ----------------------------------------------------------------- + + +def test_topics_json_lists_every_page(docs_dir: Path) -> None: + payload = json.loads(_run("--json").output) + assert {t["topic"] for t in payload["topics"]} == { + "usage", + "user-guide", + "configuration", + "index", + } + assert all(t["path"].startswith("docs/") for t in payload["topics"]) + + +def test_page_json_carries_the_content(docs_dir: Path) -> None: + payload = json.loads(_run("usage", "--json").output) + assert payload["topic"] == "usage" + assert payload["path"] == "docs/USAGE.md" + assert "Command-by-command" in payload["content"] + + +def test_search_json_reports_positions_and_what_it_held_back(docs_dir: Path) -> None: + payload = json.loads(_run("--search", "reference", "--json").output) + assert payload["term"] == "reference" + assert payload["matches"], payload + first = payload["matches"][0] + # The position must be the path that EXISTS — the file name, never the slug. + assert first["path"] == "docs/USAGE.md:3", first + assert payload["omitted"] == 0 + assert payload["total"] == len(payload["matches"]) + + +def test_an_installation_with_no_pages_says_so_instead_of_crashing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(docs_catalog, "_docs_dir", lambda: None) + assert docs_catalog.topics() == [] + assert _run().exit_code == 0 + assert _run("usage").exit_code == 11 diff --git a/tests/cli/test_docs_catalog_real_docs.py b/tests/cli/test_docs_catalog_real_docs.py new file mode 100644 index 000000000..af4854947 --- /dev/null +++ b/tests/cli/test_docs_catalog_real_docs.py @@ -0,0 +1,100 @@ +"""`gflow docs` against the **real** pages this repository ships (#861). + +Separate from `test_cli_docs.py` on purpose. Those tests use a four-page fixture and +answer "does the code do what it says". These answer "does the feature solve the problem +it was filed for", and that question is only decidable against the actual corpus — 126 +pages, 136 lines mentioning `duration`, and one of them being the rule that cost a +session. A fixture can be made to pass by choosing its contents. + +Scenarios 8, 8b and 11 from `SCENARIO.md`. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from gflow_cli import docs_catalog + +_REPO_DOCS = Path(__file__).resolve().parents[2] / "docs" + +pytestmark = pytest.mark.skipif( + not _REPO_DOCS.is_dir(), reason="runs against the repository's own docs/ tree" +) + + +def test_every_shipped_page_is_a_topic_with_no_manifest_to_maintain() -> None: + """Scenario 11. The directory listing IS the topic list, at build time and read time. + + If this ever fails because someone added a page, the right fix is not to update a + list — it is that a list appeared somewhere and should be deleted. + + This also covers what a separate `repo_path` test used to assert: `repo_path` is + `"docs/" + file_name`, and `file_name` comes from this same glob, so equality here + already proves every printed position exists. + """ + on_disk = {p.name for p in _REPO_DOCS.glob("*.md")} + catalogued = {t.file_name for t in docs_catalog.topics()} + assert catalogued == on_disk + assert len(on_disk) > 100, len(on_disk) # the corpus this feature exists for + + +def test_the_query_that_filed_this_issue_finds_the_rule_it_missed() -> None: + """Scenario 8 — the feature's acceptance test. + + #861 was filed after `video r2v --duration 10` against a host that offers r2v at 8 s + only. The rule was already written down, inside a ~4 000-character bullet in `MCP.md`, + and was never found. This is the query a reader would type, and it has to land. + + Top three rather than exactly first: `_score` gives a heading +10, so a future page + with a heading naming both terms would take first place without the feature being any + worse. Pinning position 1 would make an ordinary docs edit red the build. + """ + matches = docs_catalog.search("r2v duration") + assert matches, "the rule is unreachable — the feature does not do its job" + top = [m.position for m in matches[:3]] + assert any(p.startswith("docs/MCP.md:") for p in top), [m.position for m in matches[:5]] + # And the line must arrive readable. The raw line is over 4 000 characters; handing + # that to a terminal is "go read the file" with extra steps. + assert len(matches[0].text) < 250, len(matches[0].text) + + +def test_a_bare_common_word_is_answered_honestly_rather_than_silently_truncated() -> None: + """Scenario 8b, and the limit this feature does not pretend past. + + `--search duration` alone has >100 hits and no ranking tried here could put the rule + first (see `_score`'s docstring for the two that were built and measured). What the + command owes the reader is the count and a way to narrow, not a confident wrong first + result. + """ + hits = docs_catalog.search("duration") + assert len(hits) > docs_catalog.MAX_HITS, len(hits) + + +def test_a_vague_query_leads_with_curated_answers_not_release_records() -> None: + """What the fence fix and the curated bonus buy, measured on the real corpus. + + Before them, `--search duration` opened with eight `LIVE_VERIFICATION_*` rows — one of + which was a `#` shell comment inside a fenced block, scored as a heading. Those files + are dated records of what was true at one release; a reader asking about durations + wants the reference pages. + + Asserted as a property of the top three rather than a pinned file:line, so an ordinary + docs edit cannot red the build. + """ + top = docs_catalog.search("duration")[:3] + assert top, "no hits at all" + assert not all(m.file_name.startswith("LIVE_VERIFICATION") for m in top), [ + m.position for m in top + ] + + +def test_a_curated_index_row_keeps_the_file_name_it_points_at() -> None: + """Stripping Markdown from a curated row turned `REFERENCE_STRATEGIES.md` into + `REFERENCESTRATEGIES.md` — a path that does not exist, printed as the answer, in the + rows that rank first. 106 of the 126 pages carry an underscore.""" + hits = [m for m in docs_catalog.search("reference") if m.file_name == "INDEX.md"] + assert hits, "no curated rows matched — the fixture for this assertion is gone" + assert not any("REFERENCESTRATEGIES" in m.text for m in hits) + assert any("_" in m.text for m in docs_catalog.search("REFERENCE_STRATEGIES.md")) diff --git a/tests/conftest.py b/tests/conftest.py index f59cd341c..62906e1ef 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -139,6 +139,12 @@ def _isolate_settings(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterat # without CI set would spawn a real PyPI request. tests/test_update_check.py # re-enables it explicitly. monkeypatch.setenv("GFLOW_CLI_UPDATE_CHECK", "0") + # #864: the MCP entry points write a lease-wait default straight into os.environ + # (production wants it process-wide). Recording the variable here makes teardown + # remove it, so a test that runs `run_http` unpatched cannot turn every later + # lease-contention test into a 180 s wait. + monkeypatch.setenv("GFLOW_CLI_LEASE_WAIT_SECONDS", "0") + monkeypatch.delenv("GFLOW_CLI_LEASE_WAIT_SECONDS") reset_settings() yield reset_settings() diff --git a/tests/e2e/test_blocking_dialog_dismissal_bdd.py b/tests/e2e/test_blocking_dialog_dismissal_bdd.py new file mode 100644 index 000000000..d6ae0c52f --- /dev/null +++ b/tests/e2e/test_blocking_dialog_dismissal_bdd.py @@ -0,0 +1,217 @@ +"""E2E for clearing Flow's promo modal before the first click (#859). + +Binds ``tests/features/blocking_dialog_dismissal.feature``. The Gherkin's ``@e2e`` tags +become pytest markers via pytest-bdd, so ``-m e2e`` / ``-m e2e_auth`` select this file +exactly like a hand-written e2e — see ``docs/E2E_TESTING.md`` § BDD-bound e2e. + +**Why an e2e and not a unit test.** The defect is *when* the dialog is in the DOM: +``ensure_editor``'s probe runs one frame after ``domcontentloaded`` and the modal mounts +after it, so the dismissal looks healthy while doing nothing. A mocked page is whatever +the test constructed before the test ran — it has no "later", so the broken ordering and +the fixed one are indistinguishable to it. + +**Cost: zero.** Every Flow origin is served by ``page.route(...).fulfill(...)``, the +same harness as ``test_click_attribution_bdd.py`` — no Google, no profile, no credits. +""" + +from __future__ import annotations + +import asyncio +from typing import Any + +import pytest +from playwright.async_api import Page, Route, async_playwright +from pytest_bdd import given, scenarios, then, when + +from gflow_cli.api.transports.migrated_composer import MigratedComposer +from gflow_cli.errors import UiSelectorDriftError + +scenarios("../features/blocking_dialog_dismissal.feature") + +PROJECT_URL = "https://flow.google.com/project/e2e-blocking-dialog" + +#: How long after load the promo panel mounts. Long enough that `ensure_editor`'s +#: instantaneous probe cannot see it, short enough that the driver's own 5 s click +#: budget is still running when it appears — the shape of the reported incident. +_DIALOG_DELAY_MS = 250 + +_STYLE = """ + body { margin: 0; font-family: sans-serif; } + /* Below the dialog's own box on purpose: the element on top of the trigger must be + the BACKDROP, which is what the live incident's post-mortem named. Put the trigger + under the panel itself and the message names the panel, testing other geometry. */ + .settings-trigger-button { position: absolute; top: 400px; left: 40px; + width: 147px; height: 32px; } + .cdk-overlay-backdrop { position: absolute; top: 0; left: 0; + width: 100vw; height: 100vh; z-index: 9999; } + [role='dialog'] { position: absolute; top: 20px; left: 20px; + width: 320px; height: 200px; z-index: 10000; background: #fff; } +""" + +#: Clicking the trigger mounts the overlay `_open_pane` waits for, so the pane-opens +#: assertions exercise the whole path rather than just "no exception". +_OPENS_PANE_JS = """ + document.querySelector('.settings-trigger-button').addEventListener('click', () => { + const pane = document.createElement('div'); + pane.className = 'cdk-overlay-pane'; + pane.innerHTML = "<div role='radiogroup'><div role='radio'>16:9</div></div>"; + document.body.appendChild(pane); + }); +""" + +#: Flow's promo panel, in the shape the incident reported: a `role="dialog"` div (not a +#: `<dialog>` element) over an Angular CDK backdrop, mounted *after* the editor settles. +#: `document.title` carries which gesture removed it, because a passing run leaves no +#: other trace and a failing one still has to be readable. +_DIALOG_JS = """ + document.addEventListener('keydown', (e) => { + if (e.key !== 'Escape') return; + const d = document.querySelector('[role=dialog]'); + if (!d) return; + document.title = 'ESCAPE'; + if (__CLOSEABLE__) { d.remove(); document.querySelector('.cdk-overlay-backdrop').remove(); } + }); + setTimeout(() => { + const backdrop = document.createElement('div'); + backdrop.className = 'cdk-overlay-backdrop cdk-overlay-backdrop-showing'; + document.body.appendChild(backdrop); + const dialog = document.createElement('div'); + dialog.setAttribute('role', 'dialog'); + dialog.innerHTML = __CLOSE_BUTTON__; + document.body.appendChild(dialog); + const close = dialog.querySelector('button'); + if (close) close.addEventListener('click', () => { + document.title = 'CLOSE'; + if (__CLOSEABLE__) { dialog.remove(); backdrop.remove(); } + }); + }, __DELAY__); +""" + +#: The close affordance `DIALOG_CLOSE` matches: a button carrying the `close` ligature. +_CLOSE_BUTTON = '"<button><mat-icon>close</mat-icon></button><p>Take Flow on the road</p>"' +_NO_CLOSE_BUTTON = '"<p>Take Flow on the road</p>"' + + +def _page(*, dialog: bool = True, closeable: bool = True, close_button: bool = True) -> str: + script = _OPENS_PANE_JS + if dialog: + script += ( + _DIALOG_JS.replace("__CLOSEABLE__", "true" if closeable else "false") + .replace("__CLOSE_BUTTON__", _CLOSE_BUTTON if close_button else _NO_CLOSE_BUTTON) + .replace("__DELAY__", str(_DIALOG_DELAY_MS)) + ) + return ( + f"<!doctype html><html><head><style>{_STYLE}</style></head><body>" + "<button class='settings-trigger-button' aria-label='Settings trigger'>settings</button>" + f"<script>{script}</script>" + "</body></html>" + ) + + +@pytest.fixture +def world() -> dict[str, Any]: + return {} + + +async def _drive(html: str, world: dict[str, Any]) -> None: + """Launch a real browser, serve `html` for every Flow URL, open the settings pane.""" + async with async_playwright() as pw: + browser = await pw.chromium.launch(headless=True) + try: + page: Page = await (await browser.new_context()).new_page() + + async def _handler(route: Route) -> None: + await route.fulfill(status=200, content_type="text/html", body=html) + + await page.route("https://flow.google.com/**", _handler) + await page.goto(PROJECT_URL, wait_until="domcontentloaded") + # Let the modal mount, exactly as it does between `ensure_editor` returning + # and the first real click. Nothing in the driver waits for it. + await page.wait_for_timeout(_DIALOG_DELAY_MS * 3) + try: + await MigratedComposer()._open_pane(page) # noqa: SLF001 + world["error"] = None + except BaseException as exc: # noqa: BLE001 - the failure IS the assertion + world["error"] = exc + finally: + # Read the page's own record BEFORE the browser goes away; the title + # survives a failed run, `document` does not. + world["gesture"] = await page.title() + finally: + await browser.close() + + +# --------------------------------------------------------------------------- given + + +@given("a Flow project page whose promo dialog appears only after the editor settles") +def _late_dialog(world: dict[str, Any]) -> None: + world["html"] = _page() + + +@given("a Flow project page whose promo dialog offers no close button") +def _no_close_button(world: dict[str, Any]) -> None: + world["html"] = _page(close_button=False) + + +@given("a Flow project page whose promo dialog ignores its own close button") +def _stuck_dialog(world: dict[str, Any]) -> None: + world["html"] = _page(closeable=False) + + +@given("a Flow project page with no promo dialog") +def _no_dialog(world: dict[str, Any]) -> None: + world["html"] = _page(dialog=False) + + +# ---------------------------------------------------------------------------- when + + +@when("the driver opens the settings pane") +def _open(world: dict[str, Any]) -> None: + asyncio.run(_drive(world["html"], world)) + + +# ---------------------------------------------------------------------------- then + + +@then("the pane opens and nothing is raised") +def _pane_opened(world: dict[str, Any]) -> None: + assert world["error"] is None, f"the settings pane never opened: {world['error']!r}" + + +@then("the dialog was dismissed by its close button") +def _closed_by_button(world: dict[str, Any]) -> None: + assert world["gesture"] == "CLOSE", ( + f"page recorded {world['gesture']!r} — Escape is the fallback, and preferring it " + "over a close button the dialog offers is a gesture aimed at whatever else has focus" + ) + + +@then("the dialog was dismissed by Escape") +def _closed_by_escape(world: dict[str, Any]) -> None: + assert world["gesture"] == "ESCAPE", world["gesture"] + + +@then("it fails with exit 23") +def _exit_23(world: dict[str, Any]) -> None: + from gflow_cli.errors import EXIT_CODE_MAP, is_retryable + + error = world["error"] + assert isinstance(error, UiSelectorDriftError), f"expected exit 23, got {error!r}" + assert EXIT_CODE_MAP[UiSelectorDriftError] == 23 + # A flag is a claim: this raise site's answer is PRESERVED, not re-measured. + assert is_retryable(error) is False + + +@then("the message names the covering backdrop") +def _names_backdrop(world: dict[str, Any]) -> None: + text = str(world["error"]) + assert "cdk-overlay-backdrop" in text, text + + +@then("no dismissal gesture was made") +def _nothing_pressed(world: dict[str, Any]) -> None: + # An unconditional Escape would close the settings pane the next line opens, so + # "it did nothing" is the assertion, not an absence of evidence. + assert world["gesture"] not in ("CLOSE", "ESCAPE"), world["gesture"] diff --git a/tests/e2e/test_frame_picker_binding_bdd.py b/tests/e2e/test_frame_picker_binding_bdd.py new file mode 100644 index 000000000..f953085e2 --- /dev/null +++ b/tests/e2e/test_frame_picker_binding_bdd.py @@ -0,0 +1,209 @@ +"""E2E for the Frames picker's name match on the migrated composer (#860). + +Binds ``tests/features/frame_picker_binding.feature``. The Gherkin's ``@e2e`` tags +become pytest markers via pytest-bdd, so ``-m e2e`` / ``-m e2e_auth`` select this file +exactly like a hand-written e2e — see ``docs/E2E_TESTING.md`` § BDD-bound e2e. + +**Why an e2e and not a unit test.** #860 is a defect in what a *locator* reads: the +picker tile is ``<mat-icon>image</mat-icon><span>name</span>`` and Playwright's text +engine concatenates the two, so ``filter(has_text=re.compile(r"^\\s*name\\s*$"))`` +matches nothing while the asset is plainly listed. A fake locator returns whatever +list the test wrote into it and happily "matches" the anchored pattern, so the mocked +version of this test passes against the bug. Only a real DOM can falsify it. + +**Cost: zero.** Every Flow origin is served by ``page.route(...).fulfill(...)``, the +same harness as ``test_click_attribution_bdd.py`` — no Google, no profile, no credits. +""" + +from __future__ import annotations + +import asyncio +import json +from typing import Any + +import pytest +from playwright.async_api import Page, Route, async_playwright +from pytest_bdd import given, scenarios, then, when + +from gflow_cli.api.transports import migrated_composer +from gflow_cli.api.transports.migrated_composer import ( + BOUND_CHIP, + EMPTY_CHIP, + PICKER_OPTION, + MigratedComposer, +) +from gflow_cli.errors import ReferenceNotFoundError + +scenarios("../features/frame_picker_binding.feature") + +PROJECT_URL = "https://flow.google.com/project/e2e-frame-picker-binding" + +#: What this run uploaded. The trailing tag is `_unique_display_name`'s (#792). +UPLOADED = "hero-ab12cd34.png" +#: Yesterday's copy of the same file: same stem, same suffix, different tag. +STALE = "hero-99887766.png" +MEDIA_ID = "e2e-0000-media-id" + +_STYLE = """ + body { margin: 0; font-family: sans-serif; } + .cdk-overlay-pane { position: absolute; top: 40px; left: 0; background: #fff; } + button.asset-item { display: block; width: 260px; height: 28px; } + button.chip-container, button.chip-container img { display: block; width: 40px; height: 40px; } +""" + +#: The picker, in the shape the failing run reported. The `mat-icon` is load-bearing: +#: its ligature text is what the tile concatenates into the file name, and removing it +#: would make every scenario here pass against the bug. +_SCRIPT = """ + const ASSETS = __ASSETS__; + document.querySelector('flow-prompt-box button.empty-chip') + .addEventListener('click', () => { + const pane = document.createElement('div'); + pane.className = 'cdk-overlay-pane'; + const picker = document.createElement('flow-add-menu-popover-content'); + const search = document.createElement('input'); + search.type = 'text'; + picker.appendChild(search); + for (const name of ASSETS) { + const opt = document.createElement('button'); + opt.className = 'asset-item'; + opt.setAttribute('role', 'option'); + opt.innerHTML = "<mat-icon>image</mat-icon><span class='asset-name'>" + + name + "</span>"; + opt.addEventListener('click', () => { + document.title = name; + pane.remove(); + const box = document.querySelector('flow-prompt-box'); + const empty = box.querySelector('button.empty-chip'); + if (empty) empty.remove(); + const chip = document.createElement('button'); + chip.className = 'chip-container'; + chip.innerHTML = "<img alt='" + name + "' src='data:image/gif;base64," + + "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'>"; + box.appendChild(chip); + }); + picker.appendChild(opt); + } + pane.appendChild(picker); + document.body.appendChild(pane); + }); +""" + + +def _page(assets: list[str]) -> str: + """A migrated-host prompt box with one empty Start chip and a library of `assets`.""" + return ( + f"<!doctype html><html><head><style>{_STYLE}</style></head><body>" + "<flow-prompt-box><button class='empty-chip'>Start</button></flow-prompt-box>" + f"<script>{_SCRIPT.replace('__ASSETS__', json.dumps(assets))}</script>" + "</body></html>" + ) + + +@pytest.fixture +def world() -> dict[str, Any]: + return {} + + +async def _drive(html: str, world: dict[str, Any]) -> None: + """Launch a real browser, serve `html` for every Flow URL, run the picker.""" + async with async_playwright() as pw: + browser = await pw.chromium.launch(headless=True) + try: + page: Page = await (await browser.new_context()).new_page() + + async def _handler(route: Route) -> None: + await route.fulfill(status=200, content_type="text/html", body=html) + + await page.route("https://flow.google.com/**", _handler) + await page.goto(PROJECT_URL, wait_until="domcontentloaded") + # Read the surface before driving it: mount the picker once, record what a + # real locator sees, then reload so the driver starts from a pristine page. + # After a successful pick the pane is gone, so this cannot be read afterwards. + await page.locator(EMPTY_CHIP).first.click(timeout=4000) + await page.locator(PICKER_OPTION).first.wait_for(state="visible", timeout=4000) + world["listed"] = await page.locator(PICKER_OPTION).all_text_contents() + await page.goto(PROJECT_URL, wait_until="domcontentloaded") + try: + await MigratedComposer()._pick_frame_by_name(page, UPLOADED, MEDIA_ID) # noqa: SLF001 + world["error"] = None + except BaseException as exc: # noqa: BLE001 - the failure IS the assertion + world["error"] = exc + finally: + # Read the page's record BEFORE the browser goes away; the title + # survives a failed run, `document` does not. + world["clicked"] = await page.title() + world["chips"] = await page.locator(BOUND_CHIP).count() + finally: + await browser.close() + + +# --------------------------------------------------------------------------- given + + +@given("a Frames picker listing the uploaded frame") +def _lists_it(world: dict[str, Any]) -> None: + world["html"] = _page([UPLOADED]) + + +@given("a Frames picker listing an older copy of the same file") +def _lists_a_stale_copy(world: dict[str, Any]) -> None: + # Stale first, so "the first option" is the wrong answer. + world["html"] = _page([STALE, UPLOADED]) + + +@given("a Frames picker that never lists the uploaded frame") +def _lists_something_else(world: dict[str, Any], monkeypatch: pytest.MonkeyPatch) -> None: + world["html"] = _page([STALE]) + # The miss path is three searches of FRAME_PICKER_OPEN_S with a pause between -- + # half a minute of waiting for a page that mounts synchronously. The timeout is + # not what this scenario is about, so it is shortened rather than served. + monkeypatch.setattr(migrated_composer, "FRAME_PICKER_OPEN_S", 0.5) + monkeypatch.setattr(migrated_composer, "FRAME_SEARCH_RETRY_PAUSE_S", 0.01) + + +# ---------------------------------------------------------------------------- when + + +@when("the driver picks the uploaded frame by name") +def _pick(world: dict[str, Any]) -> None: + asyncio.run(_drive(world["html"], world)) + + +# ---------------------------------------------------------------------------- then + + +@then("the picker's own text content concatenates the icon into the name") +def _concatenates(world: dict[str, Any]) -> None: + # Without this the scenario could go green for the wrong reason: a tile that does + # NOT concatenate is not the surface #860 happens on, and the anchored matcher + # would have bound it too. + assert f"image{UPLOADED}" in world["listed"], world["listed"] + + +@then("the Start chip binds the uploaded frame and nothing is raised") +def _bound(world: dict[str, Any]) -> None: + assert world["error"] is None, f"the picker refused an asset it was listing: {world['error']!r}" + assert world["clicked"] == UPLOADED, ( + f"bound {world['clicked']!r}, not the run's own upload {UPLOADED!r} — a match " + "loose enough to take a stale copy gives away what the run-unique tag buys (#792)" + ) + assert world["chips"] == 1, world["chips"] + + +@then("it fails with exit 32 naming the file and what the picker did list") +def _refused(world: dict[str, Any]) -> None: + from gflow_cli.errors import EXIT_CODE_MAP + + error = world["error"] + assert isinstance(error, ReferenceNotFoundError), f"expected exit 32, got {error!r}" + assert EXIT_CODE_MAP[ReferenceNotFoundError] == 32 + text = str(error) + assert UPLOADED in text, text + assert STALE in text, f"the listing is the diagnostic and it is missing: {text}" + + +@then("no chip was bound") +def _nothing_bound(world: dict[str, Any]) -> None: + assert world["clicked"] != STALE, "bound the stale copy it was refusing to find" + assert world["chips"] == 0, world["chips"] diff --git a/tests/e2e/test_i2v_flags_e2e.py b/tests/e2e/test_i2v_flags_e2e.py index 9972600fe..0a6c2cce5 100644 --- a/tests/e2e/test_i2v_flags_e2e.py +++ b/tests/e2e/test_i2v_flags_e2e.py @@ -14,7 +14,7 @@ Criteria covered: I2V-FLAG-1 — ``gflow video i2v --initial-frame <img> "<prompt>"`` (canonical - form) produces a downloaded mp4 and the ``frame_attached`` + form) produces a downloaded mp4 and a frame-bound structlog event fires at least once, confirming the initial frame was bound through the editor's media dialog (not silently dropped). I2V-FLAG-2 — Positional back-compat form (``gflow video i2v <img> "<prompt>"``) @@ -59,6 +59,18 @@ pytestmark = [pytest.mark.e2e, pytest.mark.e2e_video] +#: The two drivers name the same event differently: the labs path logs +#: ``ui_automation_video.frame_attached`` and the migrated composer logs +#: ``migrated.frame_bound``. Both maintainer accounts are served flow.google.com, so +#: asserting only the labs name made these tests spend a Veo credit and THEN fail on +#: the name. Assert the behaviour (a frame bound), not one driver's spelling. +_FRAME_BOUND_EVENTS = ("ui_automation_video.frame_attached", "migrated.frame_bound") + + +def _frames_bound(capture: structlog.testing.LogCapture) -> int: + return len([e for e in capture.entries if e.get("event") in _FRAME_BOUND_EVENTS]) + + # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- @@ -123,7 +135,7 @@ def test_e2e_i2v_initial_frame_flag( """I2V-FLAG-1: ``--initial-frame`` (canonical) routes to I2V and downloads an mp4. Confirms that the flag rename does not silently fall back to T2V: the - ``ui_automation_video.frame_attached`` event must fire at least once + A frame-bound event (either driver's spelling) must fire at least once (analogous to the assertion in ``test_e2e_i2v_start_end_frame_attach``). This test uses CliRunner to verify the Click-layer "swap logic" in a real @@ -162,13 +174,8 @@ def test_e2e_i2v_initial_frame_flag( mp4_files = list(out_dir.glob("*.mp4")) assert mp4_files, "expected at least one mp4 in out_dir" - frame_attached_events = [ - e - for e in install_log_capture.entries - if e.get("event") == "ui_automation_video.frame_attached" - ] - assert frame_attached_events, ( - "frame_attached event never fired — initial frame may have been silently dropped " + assert _frames_bound(install_log_capture), ( + "no frame-bound event fired — the initial frame may have been silently dropped " "(check for T2V mis-routing, issue #125)" ) @@ -226,8 +233,8 @@ def test_e2e_i2v_start_end_frame_flags( """I2V-FLAG-3: both ``--initial-frame`` and ``--end-frame`` together. Verifies the full interpolation CLI path: ``gflow video i2v --initial-frame <a> - --end-frame <b> "prompt"``. The ``ui_automation_video.frame_attached`` event - must fire TWICE (one per slot). + --end-frame <b> "prompt"``. A frame-bound event must fire TWICE (one per + slot) — on whichever driver served the account. """ start = _make_png(tmp_path / "start.png") end = _make_png(tmp_path / "end.png") @@ -263,15 +270,9 @@ def test_e2e_i2v_start_end_frame_flags( mp4_files = list(out_dir.glob("*.mp4")) assert mp4_files, "expected a downloaded mp4 for start+end interpolation" - frame_attached_events = [ - e - for e in install_log_capture.entries - if e.get("event") == "ui_automation_video.frame_attached" - ] - # One for start, one for end. - assert len(frame_attached_events) >= 2, ( - f"expected at least 2 frame_attached events, got {len(frame_attached_events)}" - ) + # One for start, one for end — on whichever driver served the account. + bound = _frames_bound(install_log_capture) + assert bound >= 2, f"expected at least 2 frame-bound events, got {bound}" def test_e2e_i2v_omni_flash_end_frame_max_duration( diff --git a/tests/e2e/test_migrated_host_e2e.py b/tests/e2e/test_migrated_host_e2e.py index a28ac6c92..8302b393e 100644 --- a/tests/e2e/test_migrated_host_e2e.py +++ b/tests/e2e/test_migrated_host_e2e.py @@ -115,6 +115,96 @@ async def test_e2e_migrated_host_serves_this_account( await transport.teardown() +@pytest.mark.asyncio +@pytest.mark.e2e_auth +async def test_e2e_end_frame_binds_the_second_chip_and_submits_interpolation( + e2e_profile_dir: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """$0 (#639): the start+end interpolation contract, without billing a run. + + This is the e2e the feature owed. Generating to verify costs a Veo credit per + run, so it is not re-runnable in review — instead both frames are really + uploaded and bound (uploads are free), the submit is intercepted with + ``route.abort()``, and the REQUEST BODY is asserted. Playwright hands us the + body before it leaves the browser, so Flow never sees the submit and nothing + is billed (memory: credit-free-route-abort-verification). + + Measured this way on 2026-09-17 (ffroliva, flow.google.com): rpc ``nprQif``, + key ``omni_flash_i2v_8s_first_last``, both media ids present. The key differs + by cohort (the contributor's account sends ``veo_3_1_interpolation_lite``), + which is why the assertion is on key SHAPE, not a literal. + """ + project = _project_id() + _set_flow_host(monkeypatch, None) + start = _write_reference(tmp_path / "e2e-start.png") + end = _write_reference(tmp_path / "e2e-end.png") + + transport = UiAutomationTransport() + captured: list[dict[str, object]] = [] + try: + await transport.setup(e2e_profile_dir) + page = transport._page # noqa: SLF001 - the e2e reads the live page + assert page is not None + composer = MigratedComposer() + await composer.ensure_editor(page, project, timeout_s=45.0) + + pane = await composer._open_pane(page) # noqa: SLF001 - production path + await composer._select(page, pane, axis="mode", lig="videocam") # noqa: SLF001 + await composer._select( # noqa: SLF001 + page, pane, axis="submode", lig=migrated_composer.FRAMES_LIGATURE + ) + await composer._close_pane(page, strict=True) # noqa: SLF001 + + start_id = await composer.attach_start_frame(page, project, start) + end_id = await composer.attach_end_frame(page, project, end) + assert start_id and end_id and start_id != end_id + + # Two bound chips is the driver's own readback — assert it on the live DOM. + assert await page.locator(migrated_composer.BOUND_CHIP).count() == 2 + + await composer.send_prompt(page, "a slow dolly between the two frames") + + async def block_submit(route: object, request: object) -> None: + body = getattr(request, "post_data", None) or "" + rpcid = migrated_composer._body_rpcid(body) or migrated_composer._rpcid( # noqa: SLF001 + str(getattr(request, "url", "")) + ) + if rpcid in migrated_composer.SUBMIT_RPCS: + key = migrated_composer.MODEL_KEY.search(body) + captured.append( + { + "rpcid": rpcid, + "model_key": key.group(0) if key else None, + "start": start_id in body, + "end": end_id in body, + } + ) + await route.abort() # type: ignore[attr-defined] # never reaches Flow + return + await route.continue_() # type: ignore[attr-defined] + + await page.route("**/batchexecute*", block_submit) + submit = ( + page.locator("button") + .filter(has=migrated_composer._ligature(page, "arrow_forward")) # noqa: SLF001 + .first + ) + await submit.click(timeout=15_000) + for _ in range(40): + if captured: + break + await page.wait_for_timeout(250) + finally: + await transport.teardown() + + assert captured, "the submit click produced no batchexecute request" + hit = captured[0] + assert hit["rpcid"] == migrated_composer.INTERPOLATION_SUBMIT_RPC, hit + assert hit["start"] and hit["end"], hit + key = str(hit["model_key"] or "") + assert "interpolation" in key or "first_last" in key, hit + + @pytest.mark.asyncio @pytest.mark.e2e_auth async def test_e2e_agent_mode_is_left_before_the_readiness_gate( diff --git a/tests/e2e/test_project_create_e2e.py b/tests/e2e/test_project_create_e2e.py new file mode 100644 index 000000000..369042fd6 --- /dev/null +++ b/tests/e2e/test_project_create_e2e.py @@ -0,0 +1,210 @@ +"""E2E: generating without a project on an account Flow serves from flow.google.com (#864). + +labs.google's ``project.createProject`` answers 404 "Flow RPCs have been deprecated and +disabled" (or 401 for a session without a labs token), so every no-project path now +creates the project on flow.google.com. Each surface the change touches is driven here, +and each MCP twin separately — they share a service, not an adapter:: + + GFLOW_CLI_E2E_PROFILE=<profile> uv run pytest -m e2e tests/e2e/test_project_create_e2e.py -v + +Cost: $0 in credits. ``e2e_auth`` tests create empty projects (free) and, for video, +intercept the submit with ``route.abort()`` so Flow never sees it. ``e2e_image`` tests use +Flow's daily image quota. Every created project is titled ``gflow-e2e-864-*``; there is no +delete route, so remove them in Flow when convenient. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import uuid +from pathlib import Path +from typing import Any + +import pytest +import structlog + +from gflow_cli.api.client import FlowApiClient +from gflow_cli.api.transports import migrated_composer +from gflow_cli.api.video import Aspect, GenerateVideoRequest, Mode +from gflow_cli.config import reset_settings +from gflow_cli.errors import TransportTimeoutError +from gflow_cli.mcp import tools as mcp_tools + +pytestmark = pytest.mark.e2e + +_UUID = migrated_composer.UUID_RE + + +def _gflow(env: dict[str, str], *args: str) -> dict[str, Any]: + proc = subprocess.run( # noqa: S603 - fixed argv, our own CLI + [sys.executable, "-m", "gflow_cli.cli", *args, "--json"], + env=env, + capture_output=True, + text=True, + encoding="utf-8", + timeout=600, + check=False, + ) + assert proc.returncode == 0, ( + f"exit {proc.returncode}\n{proc.stdout[-2000:]}\n{proc.stderr[-2000:]}" + ) + # `--json` pretty-prints one document; anything logged before it is not JSON. + out = proc.stdout + return json.loads(out[out.index("{") :]) + + +def _title(kind: str) -> str: + return f"gflow-e2e-864-{kind}-{uuid.uuid4().hex[:6]}" + + +@pytest.fixture +def auto_host(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("GFLOW_CLI_FLOW_HOST", raising=False) + reset_settings() + + +async def _read_title(profile_dir: Path, project_id: str) -> str: + async with FlowApiClient(profile_dir=profile_dir) as client: + page = client._page # noqa: SLF001 - the e2e reads the live page + assert page is not None + await page.goto( + migrated_composer.MIGRATED_PROJECT_URL.format(project_id=project_id), + wait_until="domcontentloaded", + ) + field = page.locator(migrated_composer.PROJECT_TITLE_INPUT).first + await field.wait_for(state="visible", timeout=45_000) + return await field.input_value() + + +# --- project create / rename -------------------------------------------------------------- + + +@pytest.mark.e2e_auth +async def test_cli_project_create_and_rename_land_on_flow( + e2e_env: dict[str, str], e2e_profile_dir: Path, auto_host: None +) -> None: + """The commands users run, then Flow's own header as the witness.""" + created = _gflow(e2e_env, "project", "create", "--name", _title("create")) + project_id = created["project_id"] + assert _UUID.fullmatch(project_id), created + + renamed = _title("renamed") + _gflow(e2e_env, "project", "rename", project_id, renamed) + + assert await _read_title(e2e_profile_dir, project_id) == renamed + + +# --- image without a project --------------------------------------------------------------- + + +@pytest.mark.e2e_image +def test_cli_image_t2i_without_a_project_at_three_four(e2e_env: dict[str, str]) -> None: + """Both halves of #864 on the image path: no `--project`, and the 3:4 radio that + flow.google.com now renders (refused with exit 36 before).""" + result = _gflow( + e2e_env, "image", "t2i", "a teal origami crane on a wooden table", "--aspect", "3:4" + ) + + assert _UUID.fullmatch(result["project_id"]), result + files = [Path(i["local_path"]) for i in result["images"]] + assert files and all(p.exists() and p.stat().st_size > 10_000 for p in files), result + dims = result["images"][0]["dimensions"] + assert abs(dims["width"] / dims["height"] - 3 / 4) < 0.02, dims + + +@pytest.mark.e2e_image +async def test_mcp_image_without_a_project(e2e_profile_dir: Path, auto_host: None) -> None: + del e2e_profile_dir # fixture selects the real authenticated gflow home + result = await mcp_tools.gflow_generate_image( + prompt="a teal origami crane on a wooden table", + profile=os.environ["GFLOW_CLI_E2E_PROFILE"].strip(), + wait=True, + ) + + assert result["status"] == "completed", result + files = [Path(p) for p in result["files"]] + assert files and all(p.exists() and p.stat().st_size > 10_000 for p in files) + + +# --- video without a project: up to the submit, never billed ------------------------------- + + +def _abort_video_submits(monkeypatch: pytest.MonkeyPatch, captured: list[str]) -> None: + """Every FlowApiClient in this process aborts a video submit before it leaves Chrome.""" + original = FlowApiClient.__aenter__ + + async def enter(self: FlowApiClient) -> FlowApiClient: + client = await original(self) + + async def block(route: Any, request: Any) -> None: + body = getattr(request, "post_data", None) or "" + rpcid = migrated_composer._rpcid(str(request.url)) or migrated_composer._body_rpcid( # noqa: SLF001 + body + ) + if rpcid in migrated_composer.SUBMIT_RPCS: + captured.append(rpcid) + await route.abort() + return + await route.continue_() + + assert client._context is not None # noqa: SLF001 + await client._context.route("**/batchexecute*", block) # noqa: SLF001 + return client + + monkeypatch.setattr(FlowApiClient, "__aenter__", enter) + + +def _created_a_project(capture: structlog.testing.LogCapture) -> bool: + events = {str(e.get("event")) for e in capture.entries} + return "migrated.project_created" in events + + +@pytest.mark.e2e_auth +async def test_video_without_a_project_reaches_the_submit( + e2e_profile_dir: Path, + auto_host: None, + monkeypatch: pytest.MonkeyPatch, + install_log_capture: structlog.testing.LogCapture, +) -> None: + """The client path every CLI video command takes (`FlowApiClient.generate_video`).""" + captured: list[str] = [] + _abort_video_submits(monkeypatch, captured) + req = GenerateVideoRequest( + prompt="a teal origami crane", mode=Mode.T2V, aspect=Aspect.LANDSCAPE + ) + + async with FlowApiClient(profile_dir=e2e_profile_dir) as client: + with pytest.raises(TransportTimeoutError): + await client.generate_video(req=req, poll_timeout_s=20.0, download=False) + + assert captured, "no video submit was attempted" + assert _created_a_project(install_log_capture) + + +@pytest.mark.e2e_auth +async def test_mcp_video_without_a_project_reaches_the_submit( + e2e_profile_dir: Path, + auto_host: None, + monkeypatch: pytest.MonkeyPatch, + install_log_capture: structlog.testing.LogCapture, +) -> None: + """The MCP twin: queue payload -> worker -> client, with `project_name` honoured.""" + del e2e_profile_dir + captured: list[str] = [] + _abort_video_submits(monkeypatch, captured) + + result = await mcp_tools.gflow_generate_video( + prompt="a teal origami crane", + mode="t2v", + aspect="16:9", + profile=os.environ["GFLOW_CLI_E2E_PROFILE"].strip(), + project_name=_title("mcp-video"), + wait=True, + ) + + assert result["status"] != "completed", result + assert captured, f"no video submit was attempted: {result}" + assert _created_a_project(install_log_capture) diff --git a/tests/features/blocking_dialog_dismissal.feature b/tests/features/blocking_dialog_dismissal.feature new file mode 100644 index 000000000..d3779218f --- /dev/null +++ b/tests/features/blocking_dialog_dismissal.feature @@ -0,0 +1,51 @@ +@e2e @e2e_auth +Feature: A promo modal over a fresh editor is cleared before the first click + # #859. On the migrated host a `role="dialog"` promo panel over a freshly loaded + # editor made `image t2i` fail as exit 23 — ".settings-trigger-button did not accept + # a click within 5000 ms — it is covered by div.cdk-overlay-backdrop". + # + # `_dismiss_dialog` existed and its selector matched. The issue was filed saying the + # dismissal was ordered AFTER the click; it is not — `ensure_editor` calls it. It + # calls it one frame after `domcontentloaded`, before Angular has rendered anything, + # so `is_visible()` answers False and the method returns without logging either of + # its two events. That silence in the incident's event stream is the evidence: the + # dismissal ran, saw nothing, and the dialog mounted afterwards. + # + # The consent bar had already been given the right shape one line over — dismissed + # in `_open_pane`, where both the image and video paths take their FIRST click. The + # promo dialog now shares it. + # + # These are e2e because the whole defect is WHEN the DOM has the dialog in it. A + # mocked page is whatever the test built before the test ran; it has no "later". + # + # Cost: zero. Route interception serves every Flow origin — no Google, no profile. + + Scenario: a dialog that mounts after the editor is ready is dismissed anyway + Given a Flow project page whose promo dialog appears only after the editor settles + When the driver opens the settings pane + Then the pane opens and nothing is raised + And the dialog was dismissed by its close button + + Scenario: a dialog with no close button is dismissed with Escape + Given a Flow project page whose promo dialog offers no close button + When the driver opens the settings pane + Then the pane opens and nothing is raised + And the dialog was dismissed by Escape + + Scenario: a dialog that refuses to close is named, never swallowed + # The dismissal stays best-effort by design: rather than a second error path, a + # modal that will not go falls through to the click post-mortem, which names the + # backdrop. One attributed failure beats two competing ones. + Given a Flow project page whose promo dialog ignores its own close button + When the driver opens the settings pane + Then it fails with exit 23 + And the message names the covering backdrop + + Scenario: an editor with no dialog is not touched + # The A/B control, and it guards a real hazard: with no close button the dismissal + # falls back to Escape, and an Escape pressed on a healthy page would close the + # settings pane the very next line opens. + Given a Flow project page with no promo dialog + When the driver opens the settings pane + Then the pane opens and nothing is raised + And no dismissal gesture was made diff --git a/tests/features/frame_picker_binding.feature b/tests/features/frame_picker_binding.feature new file mode 100644 index 000000000..832666027 --- /dev/null +++ b/tests/features/frame_picker_binding.feature @@ -0,0 +1,43 @@ +@e2e @e2e_auth +Feature: The Frames picker binds the asset it was searched for + # #860. `_pick_frame_by_name` matched a picker option with an ANCHORED regex + # (`_exact` -> `^\s*<name>\s*$`). A picker tile is a `mat-icon` ligature followed by + # the file name, and a locator's text is the concatenation of BOTH nodes — the + # picker offers `imageshero-ab12cd34.png` for an asset actually named + # `hero-ab12cd34.png`. The anchors can never hold, so every i2v run on this cohort + # died with exit 32, "the frame picker lists no asset named ...", while the picker + # had been listing it the whole time. The reported listing is what pinned it: the + # `image` prefix is the icon, not a rename. + # + # These are e2e because the defect IS the browser's text model. A faked locator + # returns whatever list the test author put in it, so a mocked picker passes + # against the broken matcher — the most comfortable wrong answer in this repo + # (Bug Lane, step 5). Only a real DOM concatenates the icon into the name. + # + # Cost: zero. Every Flow origin is served by Playwright route interception, so + # nothing reaches Google, no profile is needed and no credit is spent. + + Scenario: an option whose tile renders an icon before the name still binds + Given a Frames picker listing the uploaded frame + When the driver picks the uploaded frame by name + Then the picker's own text content concatenates the icon into the name + And the Start chip binds the uploaded frame and nothing is raised + + Scenario: an older copy of the same file is never bound + # #792's guarantee, which `_exact` was the enforcement of and containment must + # not give away: the uploaded name carries a run-unique tag, so a library still + # holding yesterday's copy of the same stem cannot win the match. The picker's + # search box does no filtering here on purpose — the locator is what must + # discriminate, and a stub that pre-filters would prove nothing. + Given a Frames picker listing an older copy of the same file + When the driver picks the uploaded frame by name + Then the Start chip binds the uploaded frame and nothing is raised + + Scenario: an asset the picker does not list is still refused + # The A/B control. Without it the two scenarios above would also pass against a + # matcher that binds the first option it sees, which would silently submit some + # other asset as the start frame. + Given a Frames picker that never lists the uploaded frame + When the driver picks the uploaded frame by name + Then it fails with exit 32 naming the file and what the picker did list + And no chip was bound diff --git a/tests/features/migrated_i2v.feature b/tests/features/migrated_i2v.feature index 91f3eef4c..559e5ffdf 100644 --- a/tests/features/migrated_i2v.feature +++ b/tests/features/migrated_i2v.feature @@ -47,12 +47,12 @@ Feature: image-to-video on the migrated flow.google.com host Then the run fails with exit 27 naming route batchexecute:maseQ And no submit was clicked - Scenario: an end frame is not ported yet on a moved account + Scenario: a moved account routes local start and end frames to the migrated host Given the editor hands the session to flow.google.com after entering the project When gflow video i2v runs with a local start frame and a local end frame - Then the run fails with exit 36 and the remediation names the end frame + Then the migrated host takes the run, not the labs driver - Scenario: an unmoved account with an end frame keeps the labs driver + Scenario: an unmoved account with local start and end frames reaches the migrated host Given the account has not been moved and a project is given When gflow video i2v runs with a local start frame and a local end frame - Then the labs driver serves the request + Then the migrated host takes the run, not the labs driver diff --git a/tests/features/test_migrated_i2v_steps.py b/tests/features/test_migrated_i2v_steps.py index d6b9fcfb9..a10ebe4f0 100644 --- a/tests/features/test_migrated_i2v_steps.py +++ b/tests/features/test_migrated_i2v_steps.py @@ -302,16 +302,21 @@ def _no_submit(world: dict[str, Any]) -> None: assert world["page"].dom.submit_clicked == 0 -@then("the run fails with exit 36 and the remediation names the end frame") -def _exit_36_end_frame(world: dict[str, Any]) -> None: - exc = world.get("error") - assert isinstance(exc, FlowHostMigratedError), exc - assert EXIT_CODE_MAP[FlowHostMigratedError] == 36 - assert "end frame" in str(exc) - assert world["page"].dom.submit_clicked == 0 - - -@then("the labs driver serves the request") -def _labs_served(world: dict[str, Any]) -> None: - assert isinstance(world.get("error"), _LabsDriverTouchedError), world.get("error") - assert world["page"].dom.submit_clicked == 0 +@then("the migrated host takes the run, not the labs driver") +def _migrated_takes_it(world: dict[str, Any]) -> None: + # Routing pin (#639): local start+end frames no longer refuse with exit 36 and no + # longer fall back to the labs driver. + # + # The negatives below are NOT enough on their own. Ablation (delete the end-frame + # call from `run_video`, so the end frame is silently dropped) left them all green + # AND let the run submit — the exact "strictly worse than exit 36" failure this + # driver refuses elsewhere. `len(dom.picked) == 2` is the discriminator: it is the + # one assertion that fails when the end frame is dropped. The fake now models both + # Frames chips, so the scenario drives the whole path through to a submit. + dom = world["page"].dom + assert not isinstance(world.get("error"), _LabsDriverTouchedError), world.get("error") + assert not isinstance(world.get("error"), FlowHostMigratedError), world.get("error") + assert world.get("error") is None, world["error"] + assert len(dom.picked) == 2, f"expected start AND end frames picked, got {dom.picked}" + assert dom.submit_clicked == 1, "the run never reached a submit" + assert world["result"].status.succeeded # VideoResult WRAPS VideoStatus diff --git a/tests/integration/test_docs_ships_in_wheel.py b/tests/integration/test_docs_ships_in_wheel.py new file mode 100644 index 000000000..bbc10389d --- /dev/null +++ b/tests/integration/test_docs_ships_in_wheel.py @@ -0,0 +1,115 @@ +"""`gflow docs` works for someone who installed from PyPI (#861, scenario 3). + +**Why this test and not a unit test.** The user in #861 ran `uv tool install gflow-cli` +and has no checkout. `docs_catalog` falls back to the repository's own `docs/` when the +package data is absent, which is what makes every other test in this feature pass — and +which would also make all of them pass against a build that ships no documentation at all. +This is the only test that can tell those two worlds apart, so it builds a wheel, installs +it somewhere with no repository in sight, and runs the command there. + +**It runs on every plain `pytest`, including PR CI.** `addopts` excludes only +`e2e, live, smoke, containers`, so the `integration` marker does not opt out of anything — +it is a label, not a gate. That is the right outcome (the wheel claim is enforced where it +matters) but it is worth stating, because it costs a build and a venv on every local run. +`uv build` and `uv venv` are the only requirements: no Docker, no network beyond the +resolver's cache, no account. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import sys +import zipfile +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.integration + +_REPO = Path(__file__).resolve().parents[2] + + +def _uv() -> str: + """`uv` or a skip — but a LOUD one. + + A silent skip would let the PR's headline claim ("proven from an installed wheel") + degrade to nothing on a runner without uv, reported as a pass. uv is this project's + build tool and is present in CI, so reaching the skip is itself the signal. + """ + found = shutil.which("uv") + if found is None: # pragma: no cover - uv is this project's build tool + pytest.skip("uv not on PATH — the installed-wheel guarantee was NOT verified") + return found + + +@pytest.fixture(scope="module") +def wheel(tmp_path_factory: pytest.TempPathFactory) -> Path: + out = tmp_path_factory.mktemp("wheel") + subprocess.run( # noqa: S603 - fixed argv, no shell + [_uv(), "build", "--wheel", "--out-dir", str(out)], + cwd=_REPO, + check=True, + capture_output=True, + ) + built = sorted(out.glob("*.whl")) + assert built, "uv build produced no wheel" + return built[0] + + +def test_the_wheel_carries_the_pages_and_only_the_pages(wheel: Path) -> None: + """The measured reason `hatch_build.py` is a hook and not two lines of config. + + `force-include = { "docs" = ... }` plus `exclude` was tried on 2026-09-17 and + hatchling ignored the exclusions: 145 files from `docs/superpowers/` and 3 from + `docs/assets/` shipped anyway, 4.3 MB of plans, spikes and PNGs. This asserts the + shape that replaced it. + """ + with zipfile.ZipFile(wheel) as archive: + shipped = [n for n in archive.namelist() if "/_docs/" in n] + assert shipped, "no documentation in the wheel — `gflow docs` would be empty" + assert not [n for n in shipped if "/assets/" in n] + assert not [n for n in shipped if "/superpowers/" in n] + assert all(n.endswith(".md") for n in shipped), shipped[:5] + assert len(shipped) == len(list((_REPO / "docs").glob("*.md"))) + + +def test_docs_works_from_an_installed_wheel_with_no_checkout(wheel: Path, tmp_path: Path) -> None: + """Install into a scratch venv and run the command with the repository nowhere near. + + `cwd` is the scratch directory precisely so the `docs/` fallback cannot rescue it: if + the pages did not ship, `--json` comes back with an empty topic list and this fails. + """ + venv = tmp_path / "venv" + subprocess.run([_uv(), "venv", str(venv)], check=True, capture_output=True) # noqa: S603 + python = venv / ("Scripts/python.exe" if sys.platform == "win32" else "bin/python") + subprocess.run( # noqa: S603 - fixed argv, no shell + [_uv(), "pip", "install", "--python", str(python), str(wheel)], + check=True, + capture_output=True, + ) + + listed = subprocess.run( # noqa: S603 + [str(python), "-m", "gflow_cli.cli", "docs", "--json"], + cwd=tmp_path, + check=True, + capture_output=True, + text=True, + encoding="utf-8", + ) + topics = json.loads(listed.stdout)["topics"] + assert len(topics) > 100, len(topics) + assert any(t["topic"] == "usage" for t in topics) + + # And the content is there, not just the names — a listing that cannot open a page + # would still pass everything above. + page = subprocess.run( # noqa: S603 + [str(python), "-m", "gflow_cli.cli", "docs", "usage", "--json"], + cwd=tmp_path, + check=True, + capture_output=True, + text=True, + encoding="utf-8", + ) + assert len(json.loads(page.stdout)["content"]) > 1000 diff --git a/tests/mcp/test_cli_parity.py b/tests/mcp/test_cli_parity.py index a15b2e225..6d04f2cff 100644 --- a/tests/mcp/test_cli_parity.py +++ b/tests/mcp/test_cli_parity.py @@ -116,6 +116,17 @@ "informed consent on the user's behalf (the #481 shape). Note it is FREE, " "so cost is NOT the reason — irreversibility is" ), + # Deferred with a named shape, not a policy exclusion (#861). The consumer that + # filed #861 IS an agent, so "agents do not need it" would be false. What is true is + # that documentation is not a tool call: MCP models documents as *resources*, and + # wrapping `docs --search` as a tool would hand an agent a second, worse way to read + # the same prose its tool descriptions already carry. The upgrade path is a resources + # provider over `gflow_cli.docs_catalog` — which is why every decision in that module + # is Click-free and returns data. + "docs": ( + "read-only local documentation; the MCP shape for prose is a resources " + "provider over docs_catalog, not a tool — deferred, not excluded (#861)" + ), "data errors export": "local catalog maintenance — deliberately CLI-only (#345)", "data errors prune": "destructive local retention — deliberately CLI-only (#345)", "data list errors": "local catalog maintenance — not yet ported", diff --git a/tests/mcp/test_mcp_profile_contention.py b/tests/mcp/test_mcp_profile_contention.py new file mode 100644 index 000000000..38595c42a --- /dev/null +++ b/tests/mcp/test_mcp_profile_contention.py @@ -0,0 +1,163 @@ +"""An MCP server waits out profile contention instead of failing it (#862, #864). + +Two separate defects, both measured before this file existed: + +* The wait default was applied inside the server entry point, but ``gflow``'s root + command had already loaded and cached settings, so ``gflow mcp run`` kept the CLI's + fail-fast ``0``. +* Tool calls run inside the server's own process, where lease contention fails fast + by design (waiting on yourself would deadlock). Two calls on one profile therefore + have to be serialized in the server; no wait setting can do it. +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +from gflow_cli.config import get_settings, reset_settings +from gflow_cli.data.store import DataStore + +ENV = "GFLOW_CLI_LEASE_WAIT_SECONDS" + + +@pytest.fixture +def unset_wait(monkeypatch: pytest.MonkeyPatch) -> Any: + monkeypatch.setenv(ENV, "0") # registers the original state for undo + monkeypatch.delenv(ENV) + reset_settings() + yield + reset_settings() + + +def test_wait_default_reaches_settings_the_cli_already_cached(unset_wait: None) -> None: + from gflow_cli.mcp.server import _apply_mcp_lease_wait_default + + assert get_settings().lease_wait_seconds == 0 # what `gflow`'s root command caches + _apply_mcp_lease_wait_default() + + assert get_settings().lease_wait_seconds == 180 + + +def test_an_explicit_value_is_never_overridden( + unset_wait: None, monkeypatch: pytest.MonkeyPatch +) -> None: + from gflow_cli.mcp.server import _apply_mcp_lease_wait_default + + monkeypatch.setenv(ENV, "0") + reset_settings() + _apply_mcp_lease_wait_default() + + assert get_settings().lease_wait_seconds == 0 + + +@pytest.mark.parametrize("entry", ["run_stdio", "run_http", "run_sse"]) +def test_every_entry_point_applies_the_default_first(entry: str) -> None: + import inspect + + from gflow_cli.mcp import server + + body = inspect.getsource(getattr(server, entry)) + first_call = body.split('"""')[-1].strip().splitlines() + assert any("_apply_mcp_lease_wait_default()" in line for line in first_call[:4]), entry + + +class _Peak: + def __init__(self) -> None: + self.active = 0 + self.peak = 0 + + async def hold(self, *_: Any, **__: Any) -> dict[str, Any]: + self.active += 1 + self.peak = max(self.peak, self.active) + await asyncio.sleep(0.05) + self.active -= 1 + return {"status": "ok"} + + +async def test_calls_on_one_profile_run_one_at_a_time() -> None: + from gflow_cli.mcp import tools + + peak = _Peak() + with ( + patch("gflow_cli.mcp.tools._resolve_and_validate_profile", return_value="p"), + patch("gflow_cli.mcp.tools.inspect_credit_profile", new=peak.hold), + ): + await asyncio.gather(tools.gflow_get_credits(), tools.gflow_get_credits()) + + assert peak.peak == 1 + + +async def test_calls_on_different_profiles_still_run_together() -> None: + from gflow_cli.mcp import tools + + peak = _Peak() + names = iter(["a", "b"]) + with ( + patch( + "gflow_cli.mcp.tools._resolve_and_validate_profile", side_effect=lambda _: next(names) + ), + patch("gflow_cli.mcp.tools.inspect_credit_profile", new=peak.hold), + ): + await asyncio.gather(tools.gflow_get_credits(), tools.gflow_get_credits()) + + assert peak.peak == 2 + + +async def test_generations_on_one_profile_run_one_at_a_time( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from gflow_cli.mcp import tools + + async def always_allow() -> bool: + return True + + # The token bucket is module-global: spending two tokens here starves every later + # generation test in the session (seen as `rate_limited` in test_tools_wired). + monkeypatch.setattr(tools._rate_limiter, "acquire", always_allow) + peak = _Peak() + + class _Worker: + def __init__(self, **_: Any) -> None: + self.repo = MagicMock(claim_task=lambda *_a, **_k: object()) + + async def process_task(self, _task: object) -> None: + await peak.hold() + + def close(self) -> None: + pass + + db = DataStore.open(tmp_path / "gflow.db") + db.close() + settings = MagicMock( + resolved_db_path=lambda: tmp_path / "gflow.db", + profile_subdir=lambda _: tmp_path / "profile_p", + timeout_seconds=30, + ) + with ( + patch("gflow_cli.mcp.tools._resolve_and_validate_profile", return_value="p"), + patch("gflow_cli.mcp.tools.FlowWorker", _Worker), + patch("gflow_cli.mcp.tools.get_settings", return_value=settings), + ): + await asyncio.gather( + tools.gflow_generate_image(prompt="a"), tools.gflow_generate_image(prompt="b") + ) + + assert peak.peak == 1 + + +def test_a_dotenv_value_is_the_operators_choice_too( + unset_wait: None, monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + from gflow_cli.mcp.server import _apply_mcp_lease_wait_default + + (tmp_path / ".env").write_text(f"{ENV}=5\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + reset_settings() + _apply_mcp_lease_wait_default() + + assert get_settings().lease_wait_seconds == 5 diff --git a/tests/mcp/test_migrated_i2v_parity.py b/tests/mcp/test_migrated_i2v_parity.py index e6791864a..e5c8c7cb3 100644 --- a/tests/mcp/test_migrated_i2v_parity.py +++ b/tests/mcp/test_migrated_i2v_parity.py @@ -48,11 +48,13 @@ def test_a_uuid_initial_frame_from_mcp_keeps_the_labs_routing() -> None: assert migrated_can_serve(request, "p1") is False -def test_an_end_frame_from_mcp_keeps_the_labs_routing(tmp_path: Path) -> None: +def test_a_local_end_frame_from_mcp_is_served_by_the_migrated_host(tmp_path: Path) -> None: png = tmp_path / "hero.png" png.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 24) media, err = _build_video_media_inputs( mode="i2v", initial_frame=str(png), end_frame=str(png), reference_images=None ) assert err is None and media is not None - assert migrated_can_serve(build_video_request(_payload(media)), "p1") is False + request = build_video_request(_payload(media)) + assert request.end_image == png.resolve() or request.end_image == png + assert migrated_can_serve(request, "p1") is True diff --git a/tests/mcp/test_server.py b/tests/mcp/test_server.py index a95d18bb6..4fe18c990 100644 --- a/tests/mcp/test_server.py +++ b/tests/mcp/test_server.py @@ -567,6 +567,7 @@ async def fake_stdio_server(stdout=None): with ( patch("gflow_cli.mcp.server.server") as mock_server, + patch("gflow_cli.mcp.server._apply_mcp_lease_wait_default"), patch("gflow_cli.mcp.server._configure_utf8_pipes"), patch("gflow_cli.mcp.server._redirect_stdout_to_stderr"), patch("gflow_cli.mcp.server.sys.stdout", MagicMock()), @@ -600,6 +601,7 @@ async def test_run_http_starts_streamable_http_on_mcp_path(self) -> None: with ( patch("gflow_cli.mcp.server.server") as mock_server, + patch("gflow_cli.mcp.server._apply_mcp_lease_wait_default"), patch("gflow_cli.mcp.server._configure_utf8_pipes"), patch("gflow_cli.mcp.server._serve", new_callable=AsyncMock) as mock_serve, ): @@ -629,6 +631,7 @@ async def test_run_sse_configures_and_starts(self) -> None: with ( patch("gflow_cli.mcp.server.server") as mock_server, + patch("gflow_cli.mcp.server._apply_mcp_lease_wait_default"), patch("gflow_cli.mcp.server._configure_utf8_pipes"), patch("gflow_cli.mcp.server._serve", new_callable=AsyncMock) as mock_serve, ): diff --git a/tests/worker/test_daemon.py b/tests/worker/test_daemon.py index 7ba55f9e7..3fc0c5ce9 100644 --- a/tests/worker/test_daemon.py +++ b/tests/worker/test_daemon.py @@ -219,6 +219,33 @@ async def test_an_agent_supplied_project_name_becomes_the_created_project_title( worker.close() +@pytest.mark.asyncio +async def test_an_agent_supplied_project_name_titles_the_video_project_too( + temp_db: DataStore, +) -> None: + """#864: the MCP video tool writes ``project_name`` exactly as the image tool does, + and ``gflow video t2v --project-name`` honours it — the video branch read nothing.""" + repo = QueueRepository(temp_db) + task = repo.enqueue_task( + task_id="task-t2v-project-name", + profile_name="default", + task_type="t2v", + payload={"prompt": "a crane", "aspect": "16:9", "project_name": "Client reel"}, + ) + + worker = FlowWorker("default", str(temp_db.path)) + fake_client = FakeFlowApiClient() + fake_client.create_project.return_value = MagicMock(project_id="project-v", title="Client reel") + fake_client.generate_video.return_value = _completed_video_result("media-v") + + with patch("gflow_cli.worker.daemon.FlowApiClient", return_value=fake_client): + await worker.process_task(task) + + fake_client.create_project.assert_awaited_once_with(title="Client reel") + assert fake_client.generate_video.await_args.kwargs["project_id"] == "project-v" + worker.close() + + @pytest.mark.asyncio async def test_worker_process_t2v(temp_db: DataStore) -> None: repo = QueueRepository(temp_db) diff --git a/uv.lock b/uv.lock index d52edd913..fdde71078 100644 --- a/uv.lock +++ b/uv.lock @@ -801,7 +801,7 @@ wheels = [ [[package]] name = "gflow-cli" -version = "0.77.1" +version = "0.78.0" source = { editable = "." } dependencies = [ { name = "browser-cookie3" }, diff --git a/website/docs/CONFIGURATION.md b/website/docs/CONFIGURATION.md index d0b4715f9..fbe0ce4f3 100644 --- a/website/docs/CONFIGURATION.md +++ b/website/docs/CONFIGURATION.md @@ -284,9 +284,9 @@ GFLOW_CLI_AUTH_LOGIN_TIMEOUT=120 gflow auth login # abort after 2 minutes **What:** How long a command waits for another gflow process to release the profile lease before giving up with `ProfileLockedError` (exit 11). The default `0` keeps the historical fail-fast behavior. With a positive value, the waiter polls the kernel lock (0.5 s cadence) and simply takes over when the current holder — a CLI command or a `gflow serve` daemon task, both of which release at their natural end — finishes. Holders are never interrupted or asked to release early. **Values:** `0`–`3600` seconds (fractions allowed) -**Default:** `0` (fail fast) +**Default:** `0` (fail fast) for CLI commands; `180` under `gflow mcp run` / `gflow serve`, where no human is there to retry — unless you set it yourself, in the environment or a `.env` file. **Note:** Same-process contention always fails fast regardless of this setting — the holder is the same process, so waiting would deadlock. See [KNOWN_ISSUES § Same profile can't be used in parallel](../KNOWN_ISSUES.md#same-profile-cant-be-used-in-parallel). -**Shipped in:** #478. +**Shipped in:** #478; MCP default #864. ### `GFLOW_CLI_DB_PATH` @@ -361,11 +361,11 @@ GFLOW_CLI_HISTORY_PROMPTS=redacted gflow image t2i "confidential brief" **What:** Which Flow frontend gflow drives. Google has moved Flow from `labs.google/fx/tools/flow` onto `flow.google.com` ([#639](https://github.com/ffroliva/gflow-cli/issues/639)) — measured 2026-09-14, the old labs URL answers **HTTP 308 Permanent Redirect** on every account we tested. The two are the same product (the new frontend's own root element is `aisandbox-root`) on different widget toolkits and different wire protocols, so each has its own driver. Which host you are served does **not** tell you which capabilities work — see [the survey](../docs/superpowers/spikes/2026-09-14-two-domain-protocol-survey.md). **Values:** -- `auto` (default) — **`flow.google.com` is the default host for every video request it can serve today** (`video t2v`, local-file `video i2v` and `video r2v`, all with `--project`). Image requests use the migrated composer for `image t2i` and local-file `image i2i` where `flow.google.com` is what Flow serves, and stay on labs where labs is. A request the new host cannot serve keeps the labs driver where labs is what Flow serves; where flow.google.com is, there is no labs fallback and unsupported forms exit 36/11 before submit. +- `auto` (default) — **`flow.google.com` is the default host for every video request it can serve today** (`video t2v`, local-file `video i2v` and `video r2v`; with no `--project`, one is created first, #864). Image requests use the migrated composer for `image t2i` and local-file `image i2i` where `flow.google.com` is what Flow serves, and stay on labs where labs is. A request the new host cannot serve keeps the labs driver where labs is what Flow serves; where flow.google.com is, there is no labs fallback and unsupported forms exit 36/11 before submit. - `flow.google.com` — force the migrated composer for everything, including what it cannot serve yet (those requests then exit 36/11 instead of falling back). - `labs.google` — never use the migrated composer; where Flow serves `flow.google.com`, requests fail with exit 36 (kill switch). **Default:** `auto` -**Scope today:** the migrated composer covers `gflow video t2v`, local-file `video i2v` / `r2v`, `gflow image t2i`, and local-file `gflow image i2i`. Images support Nano Banana 2 / Pro, the four aspect ratios enumerated on that host (16:9, 4:3, 1:1, 9:16 — `3:4` was not present and is refused before submit) and count 1–4; the page owns the `ogiZ0b` reCAPTCHA + submit and the response already contains completed signed image URLs. An end frame, UUID/name references, character entities, Agent instructions, Imagen 4, scenes, extend, instructions and tools are not ported yet and fail before submit where Flow serves flow.google.com. MCP uses the same image service and queue payload, and inherits this setting from the server/daemon environment rather than per call. +**Scope today:** the migrated composer covers `gflow video t2v`, local-file `video i2v` / `r2v`, `gflow image t2i`, and local-file `gflow image i2i`. Images support Nano Banana 2 / Pro, all five aspect ratios (16:9, 4:3, 1:1, 3:4, 9:16 — `3:4` was added to that host's radiogroup by 2026-09-17, #864) and count 1–4; the page owns the `ogiZ0b` reCAPTCHA + submit and the response already contains completed signed image URLs. Local-file start+end frames (`--initial-frame` + `--end-frame`) are ported (#639). Frames or references given by UUID/`@Name`, character entities, Agent instructions, Imagen 4, scenes, extend, instructions and tools are not ported yet and fail before submit where Flow serves flow.google.com. MCP uses the same image service and queue payload, and inherits this setting from the server/daemon environment rather than per call. ### `GFLOW_CLI_PREFER_CLASSIC` *(deprecated — use `GFLOW_CLI_UI_MODE=classic`)* diff --git a/website/docs/KNOWN_ISSUES.md b/website/docs/KNOWN_ISSUES.md index f9d9fded9..633949abd 100644 --- a/website/docs/KNOWN_ISSUES.md +++ b/website/docs/KNOWN_ISSUES.md @@ -16,7 +16,7 @@ Living list of behaviour that's broken, surprising, or limited by design — alo ### Flow is migrating to `flow.google.com`; generation coverage is partial but includes images -- **Status:** Open (partially resolved) · **Severity:** High for unported forms · **Affected:** on accounts the rollout has reached, `gflow video t2v`, local-file `video i2v` / `r2v`, `gflow image t2i`, and local-file `gflow image i2i` now run on the migrated host. Image mode supports Nano Banana 2 / Pro, the four aspect ratios its radiogroup was enumerated with (16:9, 4:3, 1:1, 9:16), and count 1–4; `3:4` had no radio in that enumeration and is refused before submit rather than reported as selector drift. Image refs by UUID, `@Name` / `--reference-entity`, Agent instructions, Imagen 4, video end frames, video refs by UUID/name, scenes, extend, instructions and tools are not ported yet and fail before submit. **`character` is NOT in that list any more** — `character create` and `character list` work on the migrated host. Of the remainder, only the i2v-by-UUID case rests on a positive observation of absence (the Frames picker tiles carry no media id); `scenes`, `extend`, `instructions` and `tools` remain *unported by gflow*, never proven impossible on the host. +- **Status:** Open (partially resolved) · **Severity:** High for unported forms · **Affected:** on accounts the rollout has reached, `gflow video t2v`, local-file `video i2v` / `r2v`, `gflow image t2i`, and local-file `gflow image i2i` now run on the migrated host. Image mode supports Nano Banana 2 / Pro, all five aspect ratios its radiogroup renders (16:9, 4:3, 1:1, 3:4, 9:16 — 3:4 appeared by 2026-09-17, #864), and count 1–4. Image refs by UUID, `@Name` / `--reference-entity`, Agent instructions, Imagen 4, video frame refs by UUID/name, scenes, extend, instructions and tools are not ported yet and fail before submit. **`character` is NOT in that list any more** — `character create` and `character list` work on the migrated host. Of the remainder, only the i2v-by-UUID case rests on a positive observation of absence (the Frames picker tiles carry no media id); `scenes`, `extend`, `instructions` and `tools` remain *unported by gflow*, never proven impossible on the host. - **Tracked:** [#639](https://github.com/ffroliva/gflow-cli/issues/639) · Reported 2026-09-02 against 0.59.0, 0.62.1, 0.63.0 and 0.65.0 - **Confirmed live 2026-09-03 on a second, independent account** (`ffroliva`) — see [LIVE_VERIFICATION_v0.66.0](https://github.com/ffroliva/gflow-cli/blob/main/docs/LIVE_VERIFICATION_v0.66.0.md). A read-only probe of the migrated origin measured `i_total: 0`, reproducing the reporter's central measurement. - **`--reference-entity` was refused on `r2v` but not on `t2v`** ([#716](https://github.com/ffroliva/gflow-cli/issues/716), fixed in v0.71.0): the "not ported, exit 36" refusal above sat inside the r2v branch of the routing gate, so a `t2v` request carrying a character entity returned from that gate without its entities ever being inspected — and nothing downstream attaches one on this host. The generation was **submitted and billed** with the entity silently dropped, returning a plausible clip of the wrong person. The check is now mode-independent and ahead of every early return. If you ran `gflow video t2v @Name …` or `--reference-entity` on a moved account before this fix, the identity in those clips was never bound. @@ -55,9 +55,8 @@ the clip). Two real clips were generated this way on 2026-09-05 — spike `docs/superpowers/spikes/2026-09-05-migrated-host-wire-protocol.md`. Routing (`GFLOW_CLI_FLOW_HOST=auto`): flow.google.com is the **default** host for that command on every account — moved or not; `flow.google.com` forces it for -everything, and `labs.google` switches the migrated composer off. Limits today: `--project` is required (project creation from the -migrated editor is not ported), and `t2v`, `i2v` from a local `--initial-frame` (no end frame, -no UUID/`@Name` frame — the migrated Frames picker exposes no media id in its DOM, so a frame is +everything, and `labs.google` switches the migrated composer off. Limits today: only `t2v`, `i2v` from local start (and end) frames (no UUID/`@Name` frame — +the migrated Frames picker exposes no media id in its DOM, so a frame is found by the run-unique name gflow uploads it under, #792), and `r2v` from local `--ref` files (see the next paragraph), plus `image t2i` and local-file `image i2i` — unsupported forms still exit 36. @@ -108,7 +107,7 @@ with the distinct, non-retryable exit 36 instead of the misleading `UiSelectorDriftError` (exit 23, "file a selector bug"). `_check_logged_in` also accepts the migrated host, so a migrated load is no longer misread as a logged-out session. The generation forms listed above and characters are driven; -scenes, extend, instructions, tools, project creation, and the named reference/model +scenes, extend, instructions, tools, and the named reference/model variants are the remaining work tracked here — no retry helps for those until each is ported. @@ -1440,7 +1439,7 @@ key — surfaces as a `RuntimeError` that `auth/cookies.py` normalizes to - **Status:** Mitigated (crash → typed fail-fast rejection) · **Severity:** Low · **Affects:** all versions -Chromium refuses to open two persistent contexts on the same `user-data-dir` simultaneously. Historically this surfaced as an unhelpful Chromium "ProcessSingleton: profile is locked" error partway through a run. As of the profile-lease hardening (production-readiness plan, slice D1/D3), gflow-cli enforces this itself: a cross-process advisory lock (`ProfileLease`, kernel `flock` on POSIX / `msvcrt.locking` on Windows) guards every profile directory. A second `gflow` invocation, `gflow serve` daemon task, or MCP call against an already-leased profile is rejected **immediately** by default — before any Chrome process starts — with a typed `ProfileLockedError` (**exit code 11**); it never silently corrupts the profile. Since #478, setting [`GFLOW_CLI_LEASE_WAIT_SECONDS`](CONFIGURATION.md#gflow_cli_lease_wait_seconds) opts a waiter into a bounded wait that takes over as soon as the current holder finishes (holders always run to completion and are never asked to release early; same-process contention still fails fast — waiting on yourself would deadlock). +Chromium refuses to open two persistent contexts on the same `user-data-dir` simultaneously. Historically this surfaced as an unhelpful Chromium "ProcessSingleton: profile is locked" error partway through a run. As of the profile-lease hardening (production-readiness plan, slice D1/D3), gflow-cli enforces this itself: a cross-process advisory lock (`ProfileLease`, kernel `flock` on POSIX / `msvcrt.locking` on Windows) guards every profile directory. A second `gflow` invocation, `gflow serve` daemon task, or MCP call against an already-leased profile is rejected **immediately** by default — before any Chrome process starts — with a typed `ProfileLockedError` (**exit code 11**); it never silently corrupts the profile. Since #478, setting [`GFLOW_CLI_LEASE_WAIT_SECONDS`](CONFIGURATION.md#gflow_cli_lease_wait_seconds) opts a waiter into a bounded wait that takes over as soon as the current holder finishes (holders always run to completion and are never asked to release early; same-process contention still fails fast — waiting on yourself would deadlock). Since #864 an MCP server waits 180 s by default, and queues its own calls on one profile rather than rejecting the second. **Workaround:** use different profiles for parallel work — different profiles acquire independent leases and run fully concurrently. diff --git a/website/docs/MCP.md b/website/docs/MCP.md index 227c9b80c..e71020807 100644 --- a/website/docs/MCP.md +++ b/website/docs/MCP.md @@ -83,8 +83,8 @@ The server registers three protocol surfaces: ### Tools (Executable actions) * `gflow_generate_image(prompt, model, aspect, count, seed, reference_images, reference_entities, reference_entity_names, tools, profile, project, project_name, instructions, ui_mode, output, wait)`: Triggers text-to-image / image-to-image (Imagen / Nano Banana). `instructions` is an optional list of ephemeral agent-instruction strings (agentic cohort only). `reference_images` switches to i2i and accepts **either a local file path or a generated image's Flow media UUID**. A UUID reference is attached by **selecting the already-existing asset in Flow's reference picker — no duplicate copy is uploaded** (locating the tile by the media id in its thumbnail URL, and searching the recorded display name to surface it when needed); gflow falls back to uploading the asset's on-disk local file only when it can't be located in place (e.g. it lives in a different project's picker). `project` generates into an existing Flow project id (mirrors CLI `--project`) — pass the reference's project to keep it selectable in place. `ui_mode` selects the Flow UI arm (`auto`/`classic`/`agentic`, mirroring CLI `--ui-mode`, matched case-insensitively). Since [#595](https://github.com/ffroliva/gflow-cli/issues/595) `auto` means "no arm was asked for" and **resolves to `classic`** — the arm that can satisfy an image request — so an account in Flow's agentic cohort aborts pre-submit (exit-28 equivalent envelope, zero credits) instead of failing mid-run with selector drift or video bytes; the agentic arm is bound only when named. Passing `instructions` forces `agentic` automatically, so `ui_mode="classic"` + `instructions` is a hard conflict rather than a silent drop. An unknown value returns a 400 problem-details envelope. See [CONFIGURATION § GFLOW_CLI_UI_MODE](CONFIGURATION.md#gflow_cli_ui_mode). - On migrated `flow.google.com` accounts, T2I and local-file I2I are supported with Nano Banana 2 / Pro, the four measured aspects (16:9, 4:3, 1:1, 9:16) and count 1–4; the page owns reCAPTCHA and the `ogiZ0b` submit. UUID/entity references, instructions and Imagen 4 remain pre-submit refusals there. The queued (`wait=false`) and blocking (`wait=true`) calls use the same typed payload and worker path. -* `gflow_generate_video(prompt, mode, aspect, initial_frame, end_frame, reference_images, reference_entities, reference_entity_names, model, duration, count, tools, profile, project, project_name, ui_mode, output, wait)`: Triggers vertical or landscape video generation (Veo). `mode` is `t2v`/`i2v`/`r2v`; `model` (`veo_lite`/`veo_fast`/`veo_quality`/`omni_flash`, aliases accepted), `duration` (seconds — 4/6/8 for Veo 3.1 and 4/6/8/10 for omni_flash; 10 is omni_flash-only). **Whether Flow renders a duration control at all is account/cohort-dependent**: on an account that renders none, the job is accepted here and fails in the worker pre-submit (exit 23 equivalent on the labs driver, exit 11 equivalent on the migrated `flow.google.com` host — now the default t2v route, #650 — no credits spent either way) rather than being rejected up front — see [KNOWN_ISSUES](https://github.com/ffroliva/gflow-cli/blob/main/KNOWN_ISSUES.md) (#451/#288/#630), and `count` mirror the CLI `gflow video` flags — an omitted `model` lets the transport apply its i2v veo-lite default (issue #125), and every model — `omni_flash` included — accepts i2v with a start frame and with an end frame (wire-verified 2026-09-02, issue #626); `i2v` requires `initial_frame`, `r2v` requires `reference_images` **or** `reference_entities`; `project` generates into an existing Flow project id (mirrors CLI `--project`); on an account Google has moved to `flow.google.com` (`GFLOW_CLI_FLOW_HOST`, read from the server/daemon environment, not per call — see [CONFIGURATION § GFLOW_CLI_FLOW_HOST](CONFIGURATION.md#gflow_cli_flow_host)) `project` is **required** and omitting it returns the exit-11-equivalent envelope; there the ported modes are text-to-video; image-to-video with a **local** `initial_frame` and no `end_frame` (the file is uploaded through the editor and bound on the Start chip by display name — it stays in the Flow project like any upload, under a **run-unique** name: `hero.png` is listed as `hero-a1b2c3d4.png`, so a re-run cannot bind an earlier upload — [#792](https://github.com/ffroliva/gflow-cli/issues/792)); and reference-to-video with local `reference_images` (each file is uploaded the same way — run-unique name included — and attached as an `@` mention of *that* name in the prompt; `duration` there accepts only `8` and is pinned when omitted — Flow offers r2v at its base tier alone, and at 4 or 6 it silently drops the references and bills a text-to-video clip, so any other value returns the exit-11-equivalent envelope). A Flow media UUID as `initial_frame`, an `end_frame`, and `r2v` by `reference_entity_names` or `reference_entities` return the exit-36-equivalent envelope. `initial_frame`, `end_frame`, and `reference_images` each accept **either a local file path or the Flow image UUID of a generated asset** — pass a generated image's id straight in to chain image→video, and gflow attaches it for you. Since v0.58.0 (#529) the CLI and MCP surfaces are unified for i2v frames: the UUID keeps its identity and is enriched with the catalog's recorded display name plus an integrity-verified local fallback, so the transport prefers **selecting the exact asset in the project's media picker** (no duplicate upload) and re-uploads the recorded local file only when the tile is unreachable — and only if its byte count/SHA-256 still match. A UUID that isn't in your local asset catalog is rejected up front with a clear "Reference Not Found" error; a catalogued asset with neither a display name nor a verified local file gives a "Reference Not Usable" error (re-generate it or pass a local path). `r2v` UUID refs are resolved to the recorded local file for upload. `ui_mode` selects the Flow UI arm (#299 PR-A, mirroring CLI `--ui-mode`) and **applies to every mode of this tool, including `r2v`** — unlike the CLI, where `video r2v`/`chain` have no flag and follow the env-only path. Video generation has **only a classic driver**, so `auto` ≡ `classic`: both verify the classic editor pre-submit and abort before spending credits if it is unreachable. `ui_mode="agentic"` is rejected with a 400 problem-details envelope, because no agentic video driver exists yet. Values are matched case-insensitively. (MCP tools return envelopes, never process exit codes — the CLI equivalents of these aborts are exit 28 and exit 2 respectively.) See [CONFIGURATION § GFLOW_CLI_UI_MODE](CONFIGURATION.md#gflow_cli_ui_mode). + On migrated `flow.google.com` accounts, T2I and local-file I2I are supported with Nano Banana 2 / Pro, all five aspects (16:9, 4:3, 1:1, 3:4, 9:16) and count 1–4; the page owns reCAPTCHA and the `ogiZ0b` submit. UUID/entity references, instructions and Imagen 4 remain pre-submit refusals there. The queued (`wait=false`) and blocking (`wait=true`) calls use the same typed payload and worker path. +* `gflow_generate_video(prompt, mode, aspect, initial_frame, end_frame, reference_images, reference_entities, reference_entity_names, model, duration, count, tools, profile, project, project_name, ui_mode, output, wait)`: Triggers vertical or landscape video generation (Veo). `mode` is `t2v`/`i2v`/`r2v`; `model` (`veo_lite`/`veo_fast`/`veo_quality`/`omni_flash`, aliases accepted), `duration` (seconds — 4/6/8 for Veo 3.1 and 4/6/8/10 for omni_flash; 10 is omni_flash-only). **Whether Flow renders a duration control at all is account/cohort-dependent**: on an account that renders none, the job is accepted here and fails in the worker pre-submit (exit 23 equivalent on the labs driver, exit 11 equivalent on the migrated `flow.google.com` host — now the default t2v route, #650 — no credits spent either way) rather than being rejected up front — see [KNOWN_ISSUES](https://github.com/ffroliva/gflow-cli/blob/main/KNOWN_ISSUES.md) (#451/#288/#630), and `count` mirror the CLI `gflow video` flags — an omitted `model` lets the transport apply its i2v veo-lite default (issue #125), and every model — `omni_flash` included — accepts i2v with a start frame and with an end frame (wire-verified 2026-09-02, issue #626); `i2v` requires `initial_frame`, `r2v` requires `reference_images` **or** `reference_entities`; `project` generates into an existing Flow project id (mirrors CLI `--project`); on an account Google has moved to `flow.google.com` (`GFLOW_CLI_FLOW_HOST`, read from the server/daemon environment, not per call — see [CONFIGURATION § GFLOW_CLI_FLOW_HOST](CONFIGURATION.md#gflow_cli_flow_host)) omitting `project` creates one there first (#864); there the ported modes are text-to-video; image-to-video with a **local** `initial_frame`, optionally with a **local** `end_frame` (start+end interpolation, #639) (the file is uploaded through the editor and bound on the Start chip by display name — it stays in the Flow project like any upload, under a **run-unique** name: `hero.png` is listed as `hero-a1b2c3d4.png`, so a re-run cannot bind an earlier upload — [#792](https://github.com/ffroliva/gflow-cli/issues/792)); and reference-to-video with local `reference_images` (each file is uploaded the same way — run-unique name included — and attached as an `@` mention of *that* name in the prompt; `duration` there accepts only `8` and is pinned when omitted — Flow offers r2v at its base tier alone, and at 4 or 6 it silently drops the references and bills a text-to-video clip, so any other value returns the exit-11-equivalent envelope). A Flow media UUID as `initial_frame` or `end_frame`, and `r2v` by `reference_entity_names` or `reference_entities`, return the exit-36-equivalent envelope; a **local-file** `end_frame` is ported (#639). `initial_frame`, `end_frame`, and `reference_images` each accept **either a local file path or the Flow image UUID of a generated asset** — pass a generated image's id straight in to chain image→video, and gflow attaches it for you. Since v0.58.0 (#529) the CLI and MCP surfaces are unified for i2v frames: the UUID keeps its identity and is enriched with the catalog's recorded display name plus an integrity-verified local fallback, so the transport prefers **selecting the exact asset in the project's media picker** (no duplicate upload) and re-uploads the recorded local file only when the tile is unreachable — and only if its byte count/SHA-256 still match. A UUID that isn't in your local asset catalog is rejected up front with a clear "Reference Not Found" error; a catalogued asset with neither a display name nor a verified local file gives a "Reference Not Usable" error (re-generate it or pass a local path). `r2v` UUID refs are resolved to the recorded local file for upload. `ui_mode` selects the Flow UI arm (#299 PR-A, mirroring CLI `--ui-mode`) and **applies to every mode of this tool, including `r2v`** — unlike the CLI, where `video r2v`/`chain` have no flag and follow the env-only path. Video generation has **only a classic driver**, so `auto` ≡ `classic`: both verify the classic editor pre-submit and abort before spending credits if it is unreachable. `ui_mode="agentic"` is rejected with a 400 problem-details envelope, because no agentic video driver exists yet. Values are matched case-insensitively. (MCP tools return envelopes, never process exit codes — the CLI equivalents of these aborts are exit 28 and exit 2 respectively.) See [CONFIGURATION § GFLOW_CLI_UI_MODE](CONFIGURATION.md#gflow_cli_ui_mode). > **Attaching a saved character (the identity axis).** Three routes reach Flow's > `referenceEntities` wire and they dedupe against each other: an `@Name` mention inside @@ -129,7 +129,7 @@ We analyzed whether a terminal-driven CLI guided by a text skill (e.g., `skills/ | :--- | :--- | :--- | | **Output Fragility** | Ephemeral `stdout`/`stderr` log outputs are fragile to format adjustments, progress bars, and ANSI colors. | Strictly structured JSON payloads containing explicit metadata and absolute output file URIs. | | **Process Lifecycle**| High process startup overhead (Python import latency) on every individual execution. | Warm daemon process. Retains active caching of database metadata. | -| **Concurrency** | A second concurrent process against the same profile is rejected immediately by the cross-process `ProfileLease` with `ProfileLockedError` (exit code 11) — a clean fail-fast, never a crash, never a wait. Different profiles run fully in parallel. | Same `ProfileLease` enforcement applies to the daemon's requests — a same-profile call made while another is in flight (from the CLI or the MCP daemon) is rejected immediately with `ProfileLockedError` rather than queued or crashed; different profiles still run fully in parallel. | +| **Concurrency** | A second concurrent process against the same profile is rejected immediately by the cross-process `ProfileLease` with `ProfileLockedError` (exit code 11) — a clean fail-fast, never a crash, never a wait. Different profiles run fully in parallel. | Calls on one profile inside one server run one at a time — a second call waits for the first instead of failing. A profile held by a *different* process (a CLI run, another server) is waited out for up to 180 s (`GFLOW_CLI_LEASE_WAIT_SECONDS`, which a value you set yourself overrides) before `ProfileLockedError`; different profiles still run fully in parallel. | | **Error Handling** | Agent must scan logs for strings or parse exit codes to check status. | Strongly-typed JSON-RPC errors with mapped codes and clear remediation. | | **Transport Safety** | Volatile console printing. | Stdout is strictly isolated for JSON-RPC; all logs and warnings route to stderr. | @@ -356,6 +356,12 @@ unauthenticated, which is the local single-user default. > `gflow serve` — it currently runs the MCP/SSE server only. See the > [CHANGELOG](../CHANGELOG.md) for the roadmap. +> **However you register it, that server then gates your upgrades.** Every client +> above starts a long-lived `gflow mcp run` — one per session — and on Windows it +> holds the install open, so a package manager can abort part-way through +> `gflow update` and leave the environment broken. Stop the servers first: +> [USAGE § `gflow update`](USAGE.md#gflow-update). + --- ## 5. Security & Anti-Bot Mitigations @@ -447,7 +453,6 @@ web UI still works. | selector drift, *"… and carries no confirm"* | A cohort whose picker neither commits nor offers a confirm | As above — this is a new shape, not [#792](https://github.com/ffroliva/gflow-cli/issues/792) | | *"the Start chip did not bind"* | The search and the click both landed; the chip still did not take the thumbnail | **Not retryable** (`retryable: false`) — gflow refuses to submit rather than let it go out as text-to-video. File it with the verbatim detail. (Late server-side indexing is a *different* failure — "lists no asset named …" — and that path already retries internally) | | exit-27-equivalent, upload rejected, a dialog opened and no request left the page | Flow's **one-time** "rights to use this image" confirmation | Open the project on flow.google.com, upload any image **by hand**, accept it. gflow will not click it for you: it affirms that *you* hold the rights. Once per account, then uploads run unattended | -| exit-11-equivalent, missing `project` | Migrated host requires an existing project | Pass `project`; project creation is not ported | | exit-39-equivalent, *"This Google account cannot reach Flow"* | The account has no Flow access — it needs a Google AI Plus/Pro/Ultra or qualifying Workspace plan, an age-verified account and a supported region | **Stop. Do not retry, and do not call `gflow_auth_status` or suggest a re-login** — this is the one envelope where a retry is guaranteed futile, because the editor does not exist for this account. Relay the `remediation_hint` and its link verbatim; only the human can fix it. Note `auth status` will separately claim the Flow sign-in was not completed, which is [the same sentence with a second cause](#61-first-question-what-state-is-this-accounts-flow-in) | | exit-36-equivalent | The capability is not ported to the migrated host | See [CONFIGURATION § GFLOW_CLI_FLOW_HOST](CONFIGURATION.md#gflow_cli_flow_host) for exactly what is served there | | *"Profile is missing its browser-strategy marker"* (409, `retryable: false`, from `gflow_auth_status`) | The profile's browser-strategy marker is gone — typically after a failed first login | `gflow auth login --browser chrome`. It is **not** a network problem, whatever an older build told you | diff --git a/website/docs/USAGE.md b/website/docs/USAGE.md index 6bafd7809..97317e2c3 100644 --- a/website/docs/USAGE.md +++ b/website/docs/USAGE.md @@ -10,6 +10,10 @@ CLI command reference. For environment variables see [CONFIGURATION](CONFIGURATI gflow [OPTIONS] COMMAND [ARGS]... Commands: + docs Browse gflow's own documentation (offline, read-only). + (no args) List every topic and what it covers. + TOPIC Print one page as raw Markdown (pipes cleanly). + --search TERM Matching lines, with docs/FILE.md:LINE positions. auth Manage Google sessions for Flow. (no args) Show profile list, or trigger first login. login One-time interactive sign-in. @@ -72,6 +76,47 @@ emit a single parseable object on stdout instead of Rich tables. See Note: `--profile NAME` is **per-subcommand**, not global — pass it after the subcommand name (e.g. `gflow image t2i "..." --profile experiments`, not `gflow --profile experiments image t2i ...`). +## `gflow docs` + +The documentation, from the terminal. Read-only, offline, no account, no credits — the +pages ship inside the package, so this works on a machine that has never seen this +repository. + +```bash +gflow docs # every topic, with what it covers +gflow docs usage # print one page (raw Markdown — pipe it) +gflow docs --search "r2v duration" # find the line that answers a question +``` + +| Flag | Effect | +|---|---| +| `--search TERM` | Every line mentioning TERM, with `docs/FILE.md:LINE` next to it. Whitespace splits the query into terms that must **all** appear on the line | +| `--json` | Machine-readable, on all three forms | + +**A topic name is whatever you would type**: the slug (`user-guide`), the file name +(`USER_GUIDE.md`), either case, or any unambiguous prefix (`conf` → `configuration`). An +ambiguous prefix lists the candidates; an unknown name is matched against the topic +list with `difflib`, so a typo (`usge`) is still offered `usage`. + +**Use two words when one is common.** `--search duration` matches well over a hundred +lines; the table header carries the total, shows the first 20 and says how many it held +back. `--search "r2v duration"` narrows that to a handful with the host-specific rule +at the top. + +Curated answers rank first: the `**"How do I …?"** →` rows in +[INDEX](INDEX.md#topic-shortcuts) were each written to *be* an answer, so they come above +raw body-text matches for the same term. + +Piping works — the page is emitted as its own Markdown, not as rendered boxes: + +```bash +gflow docs configuration | grep GFLOW_CLI_STORAGE_URI +gflow docs --search "exit code" --json | jq -r '.matches[].path' +``` + +There is deliberately **no MCP twin** — see the note at the top of +`src/gflow_cli/cli_docs.py`. + ## `gflow auth` See [AUTHENTICATION § Commands](AUTHENTICATION.md#commands). @@ -190,11 +235,11 @@ Generate 1–4 images from one text prompt, or run a shell-friendly batch of 1 prompts through one Flow session/project. > **Migrated `flow.google.com` accounts (#639):** T2I is supported with Nano Banana 2 -> (`nano2`) and Nano Banana Pro (`nano-pro`), the four aspects measured there (`16:9`, -> `4:3`, `1:1`, `9:16`), and count 1–4. **`--project <id>` is required** — a fresh project -> can only be created through the labs gallery, so without it the run exits 11. The +> (`nano2`) and Nano Banana Pro (`nano-pro`), all five aspects (`16:9`, +> `4:3`, `1:1`, `3:4`, `9:16`), and count 1–4. Without `--project`, gflow creates a fresh project +> there first ([#864](https://github.com/ffroliva/gflow-cli/issues/864)). The > migrated page owns its reCAPTCHA + `ogiZ0b` submit. Imagen 4, Agent instructions, -> character/entity references, `3:4` and `image batch` remain unavailable on that host and +> character/entity references and `image batch` remain unavailable on that host and > fail before submit. ```text @@ -337,9 +382,9 @@ A 4-image run with `--out ./logos/` produces: Generate 1–4 images by blending a text prompt with one or more reference images. Same flag set as `t2i`, plus a required `--ref` (repeatable). > **Migrated `flow.google.com` accounts (#639):** local-file `--ref` values are supported -> and each uploaded media id is verified in the outgoing `ogiZ0b` body. **`--project <id>` -> is required here** (exit 11 without it). UUID refs, `@Name` / `--reference-entity`, Agent -> instructions, Imagen 4 and the `3:4` aspect remain unavailable on that host and fail +> and each uploaded media id is verified in the outgoing `ogiZ0b` body. Without `--project`, +> gflow creates one there first (#864). UUID refs, `@Name` / `--reference-entity`, Agent +> instructions and Imagen 4 remain unavailable on that host and fail > before submit rather than silently degrading to T2I. ```text @@ -605,12 +650,14 @@ Options: > **Two Flow frontends (#639).** Under the default `GFLOW_CLI_FLOW_HOST=auto`, `t2v` with > `--project <id>` runs on Flow's migrated `flow.google.com` host on every account; without -> `--project` an unmoved account falls back to the labs driver, and a moved account exits 11 -> (`--project` is required there — project creation is not ported). `i2v` with a local -> `--initial-frame` and no `--end-frame` runs there too (see [`gflow video i2v`](#gflow-video-i2v)), -> as does `r2v` from local `--ref` files (see [`gflow video r2v`](#gflow-video-r2v)); -> an end frame, a frame given by UUID or `@Name`, references given by `@Name` or -> `--reference-entity`. `image t2i` and local-file `image i2i` also run on a moved +> `--project`, gflow first creates a project — through labs.google where that route answers, +> otherwise on flow.google.com ([#864](https://github.com/ffroliva/gflow-cli/issues/864)) — +> and the run then routes exactly as if you had passed it. `i2v` with a local +> `--initial-frame` runs there too — **including with a local `--end-frame`** +> (start+end interpolation, #639; see [`gflow video i2v`](#gflow-video-i2v)) — as does +> `r2v` from local `--ref` files (see [`gflow video r2v`](#gflow-video-r2v)). An end frame +> or start frame given by UUID or `@Name`, and references given by `@Name` or +> `--reference-entity`, are not ported to the migrated composer yet. `image t2i` and local-file `image i2i` also run on a moved > account; UUID/entity/instruction/Imagen-4 image forms still exit 36. `flow.google.com` forces the migrated composer, > `labs.google` switches it off — see [CONFIGURATION § GFLOW_CLI_FLOW_HOST](CONFIGURATION.md#gflow_cli_flow_host). @@ -644,8 +691,8 @@ the editor's frame slot via the media dialog, then Flow fires > to submit unless the app's own > submit body carries that upload's media id with an image-to-video model key (exit 7 > otherwise: the labs #125 shape, where an unbound frame silently goes out as text-to-video). -> `--end-frame`, a UUID or `@Name` frame exit 36 there; an unmoved account keeps the labs -> driver for those. +> A UUID or `@Name` frame exits 36 there; a local `--end-frame` is ported (#639). An +> account served labs.google keeps the labs driver for the unported forms. > > What lands in the library is a **run-unique copy** — `hero.png` uploads as > `hero-a1b2c3d4.png` (#792). The picker is searched by display name, so identical names @@ -1455,6 +1502,7 @@ that privacy setting, so missing names are expected, not a defect. ## `gflow update` Upgrade gflow-cli in place, through the package manager that installed it. +**Stop every other `gflow` process first** — see below. ```text gflow update [--check] [--json] @@ -1519,9 +1567,30 @@ points at the venv's python — so `gflow update` reports it as upgraded with a note quoting the manager's exit code; the next update from another shell refreshes the launcher. -Restart any running `gflow serve` / MCP server afterwards; a long-lived process -keeps the old code until it restarts. There is deliberately no MCP twin of this -command: a server must not replace its own code underneath itself. +**Stop every other `gflow` process BEFORE you upgrade — not after.** The launcher +lock just above is the benign case: it is the `gflow.exe` *you* are running, and +the upgrade lands anyway. A **different** gflow, holding the install's contents +open, is not — there the manager can abort part-way and leave the environment +broken. The holder reported so far is **`gflow mcp run`**: an editor with gflow +registered as an MCP server starts one per session, and they outlive the command +that started them. Reported with a reproduction in +[#848](https://github.com/ffroliva/gflow-cli/issues/848#issuecomment-5704903340): +`uv tool install --force` failed with *"Access is denied"* on +`…\uv\tools\gflow-cli\Scripts` and left the install answering +`No module named 'gflow_cli'` until those processes were stopped and the install +re-run. + +Close the editor sessions (or stop the servers) first. On Windows, +`tasklist | findstr gflow` lists image names only — a server registered as +`uv run gflow …` shows up as `uv.exe`, not `gflow.exe`; elsewhere +`pgrep -fa gflow` prints the command line it matched, so you can see what you +are about to stop. `gflow update` cannot run this check for you: it is itself +one of those processes. If an upgrade does abort anyway, the exit-11 **forced +reinstall** above is the repair — run it from a shell with no gflow running. + +Restart any running `gflow serve` / MCP server afterwards too; a long-lived +process keeps the old code until it restarts. There is deliberately no MCP twin +of this command: a server must not replace its own code underneath itself. Every command also prints an **update banner** on stderr (a one-line notice when stderr is not a terminal) when a newer release is known — see @@ -1557,7 +1626,7 @@ gflow models --json ## JSON output (`--json`) The generation commands (`image t2i` / `image i2i`, `video t2v` / `i2v` / -`r2v`), `auth list`, and `gflow models` accept `--json` for machine-to-machine +`r2v`), `auth list`, `gflow models`, and `gflow docs` accept `--json` for machine-to-machine use. When set: - The command emits **one** parseable JSON object (or array, for `auth list`) @@ -1819,7 +1888,7 @@ shell scripts can branch on the failure mode without parsing stderr. | `33` | — (`gflow doctor` verdict) | Doctor found warn/fail findings — a successful diagnosis, not an error class | Review the report; see [`gflow doctor`](#gflow-doctor) | | `34` | `SyncPartialError` | `gflow data sync` failed on some projects but succeeded on others — completed writes stay committed | Retryable: re-run the same command; it resumes with what is still nameless (see [`gflow data sync`](#gflow-data-sync)) | | `35` | `ExtendUnavailableError` | No Veo extend model is orderable for this account and aspect — the extend family is tier-gated and there is no square variant. **Never auto-retry**: a tier gate does not clear on its own. | -| `36` | `FlowHostMigratedError` | Flow served the project from `flow.google.com` and the request could not be represented by the migrated composer, or `GFLOW_CLI_FLOW_HOST=labs.google` disabled it. Supported today: `video t2v`; local-file video i2v/r2v; `image t2i`; and local-file `image i2i`. Image UUID/entity/instruction/Imagen-4 forms, `image batch`, and the `3:4` image aspect remain unsupported. Not selector drift (23) | **Not retryable.** Use one of the supported forms — `--project` is required for images as well as video — or the REST surface (`gflow project list`, `gflow data …`); follow #639 for the remaining matrix | +| `36` | `FlowHostMigratedError` | Flow served the project from `flow.google.com` and the request could not be represented by the migrated composer, or `GFLOW_CLI_FLOW_HOST=labs.google` disabled it. Supported today: `video t2v`; local-file video i2v/r2v; `image t2i`; and local-file `image i2i`. Image UUID/entity/instruction/Imagen-4 forms and `image batch` remain unsupported. Not selector drift (23) | **Not retryable.** Use one of the supported forms, or the REST surface (`gflow project list`, `gflow data …`); follow #639 for the remaining matrix | | `37` | `InsufficientCreditsError` | The account's balance is short **for the model it asked for**, so Flow **replaced** the submit control with its `Insufficient credits warning` instead of disabling it. Short, not necessarily empty: measured 2026-09-07, an account holding **50** credits requesting `--model veo-quality` (**100**) rendered the warning. Explicitly **not** selector drift (23): reporting it as drift told users to file a frontend bug over a credit shortfall | Check the balance with `gflow credits user`, then pick a cheaper `--model` (`veo-lite` costs 10), top up, or wait for the allowance to reset. Nothing was submitted, so no credit was spent. `gflow image` draws on a separate daily quota and may still work | | `38` | `FlowAccountChooserError` | The post-migration hop landed on Google's account chooser and the profile's recorded account (`.gflow_account`) could not be selected automatically (row absent, click-through did not return to the editor, or `--account` mismatch) | **Not retryable**: run `gflow auth login --profile <name>` and complete the chooser manually, while signed in as the recorded account (re-run `gflow auth login` if the chooser offers a different session) | | `39` | `FlowAccessUnavailableError` | Flow loaded and routed to its own "you don't have access" screen (`<flow-pinhole-unavailable-screen>`): this Google account has no Flow entitlement. Detected by component, not by URL — the hop is client-side (`flow.google.com/` answers 200) and the path varies (`/unavailable`, `/u/8/unavailable`). Explicitly **not** auth expiry (3/8) and **not** selector drift (23): nothing expired and nothing drifted | **Not retryable, and signing in again cannot change it.** Flow needs an age-verified account in a supported region on a Google AI Plus/Pro/Ultra or qualifying Workspace plan — check which applies at [Google's eligibility page](https://support.google.com/flow/answer/16353333) and open https://flow.google.com in a browser on this account to confirm |