Repository navigation
2026 09 16 package restructure design
Design record for the cleanup round that follows #35. Written 2026-09-16, before any code moved.
main carries a breaking contract change (#34, #35) and the next thing that
happens is a version bump. That makes this the one moment where restructuring
costs nothing extra: callers are already migrating, so a package reorganisation
rides along invisibly instead of being a second disruption.
The brief, in Dr K's words: "look at the code and think how you would have done this from the beginning if you only knew all the requirements and details we have now" — plus a deep pass on every document and description.
Measured, not assumed:
-
29 modules, 12,815 lines, flat. No subpackages at all. The three largest
are
openapi.py(1,443),actions.py(1,377) andadmin.py(979). - Almost no duplicated text. A clone scan over the package found exactly one repeated six-line block, and it is an import preamble. So "share more code" is not about extracting copy-paste.
-
Four request wrappers doing one job:
routes._answer,files.answer,flowapi.answer,admin.guarded. Three ways to read a JSON body (auth.json_request,routes._body, rawrequest.json()). Five places that shapeerrors.status_for+errors.messageinto aJSONResponse. -
Data living in code.
openapi.pyis fifteen literal schema blobs beside fourteen helpers;admin.pyis a 700-lineregister()closure holding thirteen routes. -
A misnamed module.
hints.pybuilds MCP tool annotations — read-only, destructive, idempotent, open-world. It says nothing about hints in the sense of guidance, and that cost a direct question from Dr K, which is the proof.
- The tools are the source of the request schemas. The spec is derived from them
so the two surfaces cannot drift, and
test_openapi.pyasserts it. - The declared route tables (
ENDPOINTS,FLOW_ROUTES,FILE_ROUTES) already exist and already work. They get a shared mounter, not a redesign. - The behaviour. This refit changes no endpoint, no tool signature, no result shape. 1,150 tests pass unchanged, and not one assertion may be edited to make the restructure fit.
kubed/selenium_flow/
server.py main.py routes.py errors.py the app itself
core/ browser actions probe pointer js/ WebDriver and the page
session/ sessions store settings who is calling, what they hold
flows/ document library run api saved documents
http/ answer auth links files admin shared request machinery
mcp/ tools resources prompts skill apps annotations guidance
spec/ builder + schema data
The app stays on top (Dr K's call, and the right one): server.py composes
it, main.py starts it, routes.py is the app's own route table and the mount
every other tree hangs beneath, and errors.py is cross-cutting. Everything
below them is an independent component in its own room — which is also the test
of whether a room is real: if it cannot be described without naming the app, it
does not deserve a directory.
Each package holds three to eight files. The tree then says what the README says: one core, two surfaces over it.
Rejected — by surface (core/ + surfaces/http/ + surfaces/mcp/): one
more level on every import, buying a distinction the package names already make.
Rejected — minimal (split only the three big modules): leaves the flat surface that prompted the brief.
http/answer.py owns the whole shape: authorise, read the body, name the
session, run the call, turn a failure into JSON. It replaces all four wrappers
and both stray body readers.
async def answer(request, token, what, call, *, body=True) -> JSONResponseadmin.guarded becomes a thin decorator over the same primitives rather than a
parallel implementation — admin handlers return HTML and streams as well as
JSON, which is why it exists at all, and that difference stays explicit.
errors.as_response(exc, what) becomes the single place a failure becomes a
status and a message. Today five sites do it, and they already disagree about
logging.
http/mount.py takes a declared route table and mounts it under the prefix.
Top-level routes.py declares the app's own table and calls it, as
flows/api.py, http/files.py and http/admin.py do with theirs. Five modules
currently re-implement f"{prefix}/…" wiring around tables that already exist.
The published spec's operation descriptions are the MCP tool descriptions
("description": tool.description). That is why an HTTP caller reading
POST /browser/screenshot — 1,636 characters — is told "do not paste the image
back into your reply", and why nine operations carry keep_file,
session_files, list_secrets and execute_script in a JSON API document.
Decision: rewrite the tool descriptions to be surface-neutral, and move agent coaching into the skill, where agents already read it. One source survives, both surfaces get shorter and truer, and no transform layer is invented.
Considered and rejected: x-mcp extensions in the spec (Dr K's idea, and a
good one). FastMCP 4.0.3's from_openapi() takes an explicit mcp_names dict
and reads no x-mcp* extension, so such annotations would be a private
convention that nothing consumes. Producing text no reader reads is work with
no payoff. If an ecosystem consumer appears, this decision is cheap to revisit —
the prose will by then live in one place, which is what makes it revisitable.
Neutral does not mean vague. Every description still says what the call does, what it returns, and what the common mistake is. What leaves is the second person addressed at a model, and the names of tools that do not exist over HTTP.
Two separate mechanisms, currently tangled by one name.
Annotations (hints.py → mcp/annotations.py): unchanged in behaviour,
renamed for what it is. MCP defaults an unannotated tool to destructive, so
these are load-bearing, and the rename is the whole change.
Guidance (mcp/guidance.py, new): one shape for pointing a caller at the
reference that helps.
{"read": "skill://selenium-flow/references/FLOWS.md", "section": "...", "prompt": {...}}Today three emitters disagree: a failed flow run returns hint as an object, the
session status returns guidance as a bare string, and a blocked dialog returns
hint as prose. One shape, one key, three call sites.
And the gap worth closing: the skill is published correctly — a client
listing resources sees skill://selenium-flow/SKILL.md and all six references —
but nothing tells a client to read it. Only failures point at the skill, so an
agent finds the manual after it has already gone wrong. INSTRUCTIONS, the one
blurb every MCP client receives at connect time, never mentions it.
So: name the skill in INSTRUCTIONS, and emit guidance at the two non-failure
moments where it is actually useful — the first open_session of a session, and
the "no browser yet" refusal. Not on every result; the current failure-only
instinct is right, and spam would train clients to ignore it.
Findings already in hand, each verified rather than suspected:
-
scripts/generate_openapi.pysaysopenapi.yamlis "checked in so it can be reviewed in a pull request". It is gitignored and untracked;test_openapi.pyandquality.ymlboth correctly call it a build artifact. - Nine HTTP operations carry MCP-only vocabulary (above).
-
saga/Chapter_1_The_Flight_Plan.md's E5 table reads$ROUTE_PREFIX/admin,/files,/flows, which can be read as only/adminbeing mounted. - Every skill reference, the README, AGENTS.md, CONTRIBUTING.md and the hand-written wiki pages get read end to end against the shipped contract — not skimmed for keywords. The session/selector sweeps of #35 found stale wording three separate times, each after the previous sweep claimed to be done.
The standard: not overdone, not vague, not missing, and never describing a contract the code does not have.
-
Orphan session rows. Redis still holds pre-#34 records keyed
named:<name>, which the admin list renders as if they were sessions. A session name can never contain:, so the listing skips keys that are not valid session names. The live keys expire on their own; no migration. -
The packaging tripwire.
[tool.setuptools] packagesis a hand-written list. A subpackage missing from it ships a wheel with a module silently absent, and nothing tests this today. A test walks the source tree and asserts every package is declared. This is the single highest-risk item in the refit and it gets its guard first.
- The 1,150 existing tests are the behaviour guard and their assertions are frozen. Import lines change; expectations do not.
- Six string-form patch targets (
monkeypatch.setattr("kubed.selenium_flow.…")and onepatch(…)) break silently at runtime rather than at import, so they are enumerated and fixed deliberately:actions.ActionChains,browser.settled,browser.wait_for_clickable,browser.wait_for_element,sessions.http_request,browser.requests.delete. The other 100 monkeypatch calls are object-form and move with their imports. - Every new test is proved by breaking the thing it guards, confirming it fails, and restoring — the discipline that caught two vacuous guards in #35.
- The wheel is built and its contents asserted before the PR is opened, because a missing subpackage is invisible until an installed copy imports it.
- The packages-list test, first, so every later move is guarded.
- Mechanical moves and import updates. Tests green, no behaviour edits.
-
http/answer.py,errors.as_response, the route mounter. Tests green. -
spec/data split; descriptions rewritten neutral; redocly lint passes. -
annotations.pyrename,guidance.py, theINSTRUCTIONSpointer. - The documentation pass, then the saga.
- No endpoint, tool signature or result shape differs from
main. - No test assertion edited to accommodate a move.
-
pip wheelcontains every module the source tree has. - The published spec contains no MCP-only vocabulary.
-
INSTRUCTIONSnames the skill. - Every document read end to end, and every statement in it true of the shipped contract.
The action pages are generated from openapi.yaml, which is itself generated from the live MCP tool schemas — so they describe the server that shipped, not the one someone remembered. Prose belongs in wiki-notes/<tool>.md in the repo.
selenium-flow · MIT
Start here
Guides
Lifecycle
Going places
Doing things
Getting things out
Site data
Console and network