From bf731d946f30d255a3d607b3e748e884ca2d7729 Mon Sep 17 00:00:00 2001 From: Zakhar Pilipuk Date: Fri, 24 Jul 2026 13:32:04 +0000 Subject: [PATCH 1/2] Fix import order --- documentation/docs/overview.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/documentation/docs/overview.mdx b/documentation/docs/overview.mdx index 6c96105d..806dfbca 100644 --- a/documentation/docs/overview.mdx +++ b/documentation/docs/overview.mdx @@ -3,10 +3,10 @@ id: overview slug: / --- -import { CardSection, Card } from "../src/components/Cards"; import Link from "@docusaurus/Link"; -import TSLogo from "../static/img/ts-logo.svg"; +import { Card, CardSection } from "../src/components/Cards"; import PythonLogo from "../static/img/python-logo.svg"; +import TSLogo from "../static/img/ts-logo.svg"; # Build Apps with Reboot From 50224fa8bf255b930408b540d6d6c627ca818a70 Mon Sep 17 00:00:00 2001 From: Zakhar Pilipuk Date: Tue, 28 Jul 2026 10:16:18 +0000 Subject: [PATCH 2/2] Rework the app-building skills from measured agent behaviour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six headless builds of the same app, with every token in the transcript attributed to what put it in the context. A third of the first run's cost was the agent reading Reboot's own source — authorizers.py, the pydantic compiler, idempotency.py, aborted.py, the codegen template — to answer questions these skills didn't. That is the most expensive way to learn anything: the request carries the whole context and adds more source to it for every later turn. --- reboot/plugin/skills/chat-app/SKILL.md | 44 ++- .../chat-app/references/react-app-tsx.md | 28 +- reboot/plugin/skills/python/SKILL.md | 6 + .../python/references/auth-allow-deny.md | 7 +- .../references/auth-custom-predicates.md | 109 +++++- .../references/lifecycle-project-setup.md | 18 +- .../references/patterns-common-gotchas.md | 48 +++ .../python/references/patterns-idempotency.md | 44 +++ .../references/react-generated-client.md | 124 +++++++ .../references/testing-external-context.md | 32 ++ .../python/references/testing-harness.md | 80 +++++ .../references/testing-project-setup.md | 12 +- .../next/mypy-api-package-and-vite-host.md | 60 ++++ reboot/plugin/skills/web-app/SKILL.md | 327 +++++++++--------- .../skills/web-app/references/react-client.md | 200 +++++++++++ 15 files changed, 936 insertions(+), 203 deletions(-) create mode 100644 reboot/plugin/skills/python/references/react-generated-client.md create mode 100644 reboot/plugin/skills/upgrade/migrations/next/mypy-api-package-and-vite-host.md create mode 100644 reboot/plugin/skills/web-app/references/react-client.md diff --git a/reboot/plugin/skills/chat-app/SKILL.md b/reboot/plugin/skills/chat-app/SKILL.md index 60b3cfa6..5c7cefc0 100644 --- a/reboot/plugin/skills/chat-app/SKILL.md +++ b/reboot/plugin/skills/chat-app/SKILL.md @@ -54,7 +54,7 @@ README for the manual install, team auto-enable, and the Codex - Adding features, state, or UI to an existing Reboot AI Chat App - Modifying state model, methods, or React UI in a Reboot AI Chat App - Running an existing Reboot AI Chat App — e.g. at the start of a - new session. This needs no Plan or Build phase: load the + new session. This needs no design or build phase: load the [`run` skill](../run/SKILL.md), which detects the app type, starts the backend and frontend, and opens the setup wizard (from which the user can launch MCPJam on demand). @@ -155,19 +155,17 @@ they cover aren't restated inline below. | [`references/auth-custom-oauth-provider.md`](references/auth-custom-oauth-provider.md) | **Writing your own OAuth provider** when none of the shipped ones fits (self-hosted Keycloak, internal SSO, an IdP Auth0 can't broker): subclass `RegisteredOAuthProvider`, implement `authorization_url` + `exchange_code` → `ExchangeResult` (the user id must be **stable** — it becomes `context.auth.user_id`), the `validate()` / `mount_routes()` hooks, and `token_service_id` + `OAuthTokens` for `store_tokens=True` support. | | [`references/auth-store-tokens.md`](references/auth-store-tokens.md) | **Acting on the user's behalf** at an external service — the built-in `store_tokens=True` shortcut (works on any `oauth=` surface, web apps included): when the API belongs to your `Application(oauth=...)` identity provider, add extra OAuth `scopes=[...]` + `store_tokens=True` (needs `oauth_library()` + `ciphertext_library()` + `ordered_map_library()`) and the server captures its tokens. Reboot stores **the provider's own tokens only** (an `Auth0` sign-in stores an Auth0 token, not the upstream Google token). The full host-agnostic recipe — custom OAuth endpoints for any _other_ service, reading tokens back, calling the API **inside a `Workflow`**, refresh tokens, erasure — is [`python/references/auth-external-api-calls.md`](../python/references/auth-external-api-calls.md). | -## Workflow: Plan First, Then Build +## Workflow: Settle the Design, Then Build -**Always plan the design and get approval before writing code.** The -state model is the foundation — getting entities, field types, or -method types wrong means regenerating everything across 12+ files. +**Always settle the design before writing code.** The state model +is the foundation — getting entities, field types, or method types +wrong means regenerating everything across 12+ files. -### Plan Phase +### Design Phase 1. Analyze the user's description using the State Model Assessment below. -2. Begin a plan for the user to approve (in Claude Code, enter plan - mode; in Codex, present the plan and wait for the go-ahead). -3. Present the proposed design: +2. State the design you are about to build: - `User` type and its methods (the MCP front door for creating new application-type instances and locating existing ones). - Application types: state shape (fields, types, tags). @@ -179,20 +177,19 @@ method types wrong means regenerating everything across 12+ files. main user story — and most of them ending on a turn that renders a `UI()` component, not just a tool call (see "Example Prompts" under Key Framework Concepts). -4. Get user approval before writing any files. -5. Then execute the Step-by-Step Build Flow. +3. Then execute the Step-by-Step Build Flow. -For updates to existing apps, still plan: read current state, propose -changes, confirm, then modify. +For updates to existing apps, still work the design first: read +current state, state the changes, then modify. -### Writing the Plan for Human Review +### Writing the Design for a Human Reader -The plan is read by a **human who has not read the skill files**. -They are evaluating the design — entities, collections, methods, -auth — not verifying that you followed the skill. Write so the -plan stands on its own. +The design is read by a **human who has not read the skill files**. +They are judging the design — entities, collections, methods, +auth — not verifying that you followed the skill. Write so it +stands on its own. -**Don't quote skill-internal terms** when presenting the plan. +**Don't quote skill-internal terms** when presenting the design. They mean nothing outside this skill: - `Shape A` / `Shape B` / `Shape C` — name the actual data @@ -240,7 +237,7 @@ Nested model reasoning — GOOD: > own state actors because they have no lifecycle, methods, or > auth independent of the Person they belong to. -**Escape hatch.** When the precise type name _is_ what the user +**Escape hatch.** When the precise type name _is_ what the reader needs to see ("I'm proposing `OrderedMap` here, not `list[str]`"), name the type — but pair it with the plain-English reason in the same sentence. The rule is "no bare jargon", not "no technical @@ -545,7 +542,7 @@ triggers a `UI()` method — phrase that turn as a natural "show me example, the "…and show me the counter" / "show me the wins counter" turns are exactly this: they resolve to the `Counter` UI and render the live component, not just a text reply. When you write the set, look at -the method map from the plan: for each `UI()` method, make sure at +the method map from the design: for each `UI()` method, make sure at least one example drives the user to it. They live in `backend/src/example_prompts.py` and are passed to @@ -595,8 +592,7 @@ a worked set are in ## Step-by-Step Build Flow -**Only execute after plan approval. All commands run from the -application directory.** +**All commands run from the application directory.** 1. Create `.python-version`, `pyproject.toml`, `.rbtrc`, and `.mypy.ini` — see @@ -629,7 +625,7 @@ application directory.** 11. `cd frontend && npm run build`. 12. **Write and run backend unit tests covering each user-facing user story before handing the app off.** Enumerate the user - stories from the plan — every action the user should be able + stories from the design — every action the user should be able to _do_ through the MCP tool surface (e.g. "create a new todo list", "add an item and see it listed", "rename a list"). Write one test method per user story in diff --git a/reboot/plugin/skills/chat-app/references/react-app-tsx.md b/reboot/plugin/skills/chat-app/references/react-app-tsx.md index 1194a2f4..eb6f4dd6 100644 --- a/reboot/plugin/skills/chat-app/references/react-app-tsx.md +++ b/reboot/plugin/skills/chat-app/references/react-app-tsx.md @@ -7,9 +7,16 @@ tags: react, app-tsx, hooks, useType, css-module, snake-camel, app-tsx-example, ## React App.tsx — Generated Hooks and Component Patterns -`frontend/mcp//App.tsx` is the React component for one UI. The -generated `use()` hook returns reader subscriptions and -mutation functions: +`frontend/mcp//App.tsx` is the React component for one UI. + +> **The generated client contract is shared with web apps** — the +> `use()` overloads, the `UseApi` surface, the three +> fields a reader returns, why mutations resolve to +> `{ response, aborted }` instead of throwing, and the typed error +> classes are all in +> [`python/references/react-generated-client.md`](../../python/references/react-generated-client.md). +> Read that for the shapes; this file covers what is specific to a +> UI rendered inside an MCP host. ```tsx import { useCounter } from "@api//v1/_rbt_react"; @@ -17,12 +24,12 @@ import { useCounter } from "@api//v1/_rbt_react"; // useCounter() connects to the Counter state instance. const counter = useCounter(); -// Reader (WebSocket subscription, auto-updates): -const { response, isLoading } = counter.useGet(); +// Reader (a live subscription — pushed on every change): +const { response } = counter.useGet(); const value = response?.value ?? 0; -// Writer (direct call to Reboot backend): -await counter.increment({ amount: 1 }); +// Mutation (resolves to `{ response, aborted }` — it never throws): +const { aborted } = await counter.increment({ amount: 1 }); ``` For list state, the same pattern applies — use the generated hook for @@ -71,13 +78,6 @@ entity of the same Type), `useMcpToolData()` from `@reboot-dev/reboot-react` returns the raw `{ids, ...}` object the framework received. -## Naming Convention: snake_case → camelCase - -The generated React bindings convert Python snake_case field names -to TypeScript camelCase. Python `from_index` becomes TypeScript -`fromIndex`. Same for every snake_case field name on every Request -or Response Model. - ## Generated React Imports ```tsx diff --git a/reboot/plugin/skills/python/SKILL.md b/reboot/plugin/skills/python/SKILL.md index 6880cb7c..57491522 100644 --- a/reboot/plugin/skills/python/SKILL.md +++ b/reboot/plugin/skills/python/SKILL.md @@ -478,6 +478,12 @@ above lists the right ones grouped by task type. The full catalog: - `references/testing-harness.md` - `references/testing-external-context.md` +**Frontend** (shared by `web-app` and `chat-app`): + +- `references/react-generated-client.md` — what + `rbt generate --react=` emits: hook overloads, `UseApi`, + reader/mutation return shapes, typed errors, naming rules. + **Patterns** (`patterns-`): - `references/patterns-error-handling.md` diff --git a/reboot/plugin/skills/python/references/auth-allow-deny.md b/reboot/plugin/skills/python/references/auth-allow-deny.md index 92f1669b..8dbe1335 100644 --- a/reboot/plugin/skills/python/references/auth-allow-deny.md +++ b/reboot/plugin/skills/python/references/auth-allow-deny.md @@ -126,7 +126,6 @@ A rule's `execute` returns one of three outcomes: ## One Authorizer per Servicer -`def authorizer(self)` returns a single rule that applies to every -method on the Servicer. Per-method differentiation (e.g. allow reads -but gate writes) requires a custom authorizer subclass — see -`auth-custom-predicates.md`. +One rule covers every method on the Servicer; see +`servicer-authorizer.md` for what to do when methods need +different rules. diff --git a/reboot/plugin/skills/python/references/auth-custom-predicates.md b/reboot/plugin/skills/python/references/auth-custom-predicates.md index 4dcb8fdb..f0780cec 100644 --- a/reboot/plugin/skills/python/references/auth-custom-predicates.md +++ b/reboot/plugin/skills/python/references/auth-custom-predicates.md @@ -62,13 +62,101 @@ def authorizer(self): return allow_if(all=[has_verified_token, is_team_member]) ``` +## Annotate Predicates, or `mypy` Fails + +The examples above are unannotated for brevity, but a project that +runs `mypy` needs the annotations — without them `allow_if` reports +`Argument "any" ... has incompatible type "list[function]"; expected "Sequence[AuthorizerCallable[...]]"`, and a helper that returns a +rule reports `Need type annotation`. The shapes that type-check: + +Annotate `state` with the **pydantic state model from your API +definition** — the same `State` class you declared in +`api//v1/.py`. That is what the runtime passes: in a +pydantic app a predicate receives `.v1..TaskListState`. + +> **Not** `Authorizer.StateType` / `.RequestTypes`. The +> generated `Authorizer` class does expose those aliases, but +> they name the **protobuf** types (`_pb2.TaskList`), which is +> not what a pydantic app's predicate is handed. They type-check — +> protobuf and pydantic carry the same field names — while +> describing the wrong class, which is worse than `Any`. + +```python +from typing import Any + +import rbt.v1alpha1.errors_pb2 as errors +from reboot.aio.auth.authorizers import ( + Authorizer, + AuthorizerRule, + allow_if, + is_app_internal, +) +from reboot.aio.contexts import ReaderContext +from .v1. import TaskListState + + +def is_owner_or_member( + *, + context: ReaderContext, + state: TaskListState | None = None, + **kwargs: Any, +) -> Authorizer.Decision: + if context.auth is None or context.auth.user_id is None: + return errors.Unauthenticated() + if state is not None and state.owner_id == context.auth.user_id: + return errors.Ok() + return errors.PermissionDenied() + + +# A helper that returns a rule needs its return type spelled out. +def owner_or_member() -> AuthorizerRule[TaskListState, Any]: + return allow_if(any=[is_owner_or_member, is_app_internal]) +``` + +**Declare only the arguments the body reads.** The runtime passes +`context`, `state`, and `request` by keyword; a predicate that never +looks at `request` simply omits it and lets `**kwargs: Any` absorb +it. That is also why `**kwargs` is mandatory — it is what keeps the +predicate working when the runtime starts passing something new. + +When the body _does_ read `request`, declare it. One rule covers +every method on the Servicer, so what arrives is a union of all of +their request models — which is common, because restricting the +internal-only methods means telling the methods apart: + +```python +def task_list_access( + *, + context: ReaderContext, + state: TaskListState | None = None, + request: Any = None, + **kwargs: Any, +) -> Authorizer.Decision: + # Methods only app-internal code may call. + if isinstance(request, (CreateRequest, AcceptTaskRequest)): + return errors.Ok() if context.app_internal else errors.PermissionDenied() + ... +``` + +Annotate it `Any` and narrow with `isinstance`, or spell out the +union of the request models it actually distinguishes. + +**Check that the annotation is actually doing something.** These +types only bite if mypy can resolve the API package: `mypy_path` +must include the project-root `api/`, and the "don't check generated +code" stanza must name `.v1._rbt` rather than +`.v1.*` — a blanket ignore silences your own API module, and +then `TaskListState` is `Any` and a misspelled field passes a green +mypy run. See `lifecycle-project-setup.md`. + ## Predicate Signatures The runtime invokes a predicate with at minimum these keyword args: `context` (always a `ReaderContext`), `state` (the actor state, possibly -`None`), and `request` (the request message, possibly `None`). Always -declare them as keyword-only and include `**kwargs` so signature -extensions don't break the predicate. +`None`), and `request` (the request message, possibly `None`). Declare +the ones the body reads, keyword-only, and always include `**kwargs` +so the arguments you didn't declare — and any the runtime adds later +— land there harmlessly. ## Order Predicates by Cost in `all` @@ -85,6 +173,21 @@ return allow_if(all=[has_verified_token, expensive_team_check]) return allow_if(all=[expensive_team_check, has_verified_token]) ``` +## The Error Types a Predicate Returns + +They come from `rbt.v1alpha1.errors_pb2`; a predicate only ever +returns `Ok`, `Unauthenticated`, or `PermissionDenied`. The module +also carries the framework errors a call can abort with, which is +what a typed error union widens to: + +``` +Ok Unauthenticated PermissionDenied NotFound AlreadyExists +InvalidArgument FailedPrecondition OutOfRange ResourceExhausted +DeadlineExceeded Cancelled Unavailable Unimplemented Internal +Unknown DataLoss Aborted StateNotConstructed +StateAlreadyConstructed UnknownService UnknownTask InvalidMethod +``` + ## Distinguish `PermissionDenied` from `Unauthenticated` Return `Unauthenticated` when the call has no identity at all (or no diff --git a/reboot/plugin/skills/python/references/lifecycle-project-setup.md b/reboot/plugin/skills/python/references/lifecycle-project-setup.md index 1f86f2b7..df025f84 100644 --- a/reboot/plugin/skills/python/references/lifecycle-project-setup.md +++ b/reboot/plugin/skills/python/references/lifecycle-project-setup.md @@ -138,7 +138,7 @@ warn_unused_configs = True # Find modules in our source tree (and tests). Since `protoc` doesn't # generate `__init__.py` files, treat these as explicit package bases: # https://mypy.readthedocs.io/en/stable/running_mypy.html#mapping-file-paths-to-modules -mypy_path = backend/tests:backend/src:backend/api +mypy_path = backend/tests:backend/src:backend/api:api explicit_package_bases = True # Stricter than the default, but cheap to adhere to and high value. @@ -157,11 +157,25 @@ ignore_missing_imports = True # The generated `*_rbt.py` for your API package is not hand-written; # don't type-check it (you never edit it anyway). Repeat per package. -[mypy-.v1.*] +# Name the generated module specifically — a blanket `.v1.*` +# would also silence your own `api//v1/.py`, and with it +# every state and request model your code is annotated with. +[mypy-.v1._rbt] ignore_errors = True ignore_missing_imports = True ``` +**Both details in that file are load-bearing.** The project-root +`api/` entry in `mypy_path` is what lets mypy resolve the +hand-written pydantic API module, and the narrow ignore stanza is +what stops it being silenced again. Get either wrong and +`from .v1. import State` quietly resolves to `Any`: +mypy still reports "Success", but every annotation mentioning a +state or request model checks nothing, and a misspelled field on +`state` sails through to a test failure. A quick way to confirm the +config is live: add a bogus attribute access on a state model and +check that mypy reports `has no attribute`. + ## Always Type-Check What You Write After writing or changing any Python in `backend/`, run mypy from the diff --git a/reboot/plugin/skills/python/references/patterns-common-gotchas.md b/reboot/plugin/skills/python/references/patterns-common-gotchas.md index ae8d073b..878258df 100644 --- a/reboot/plugin/skills/python/references/patterns-common-gotchas.md +++ b/reboot/plugin/skills/python/references/patterns-common-gotchas.md @@ -240,3 +240,51 @@ been deployed can make it fail to boot ("Updated state or method definitions are not backwards compatible") and get its deploy rejected. Read `api-schema-evolution.md` for the rules you must follow before changing such an API. + +### 20. A `ref()` Belongs to One Context — `MixedContextsError` + +**Reusing a ref for many calls on the same context is fine** — hold +it in a local and call it as often as you like: + +```python +task_list = TaskList.ref(list_id) +await task_list.add_task(context, title="Milk") +await task_list.toggle_task(context, task_id=task_id) +snapshot = await task_list.get(context) # All fine. +``` + +What is not allowed is carrying that same ref object across to a +**different** context. `Type.ref(id)` binds to the first `Context` +it is used with (it carries that context's idempotency manager), so +a ref stashed on `self`, hoisted to a module constant, or shared +between two test contexts raises `MixedContextsError` — on readers +as well as mutations, and regardless of the id being identical: + +```python +# Wrong — one ref object, two contexts. +shared = TaskList.ref(list_id) +await shared.get(alice) +await shared.get(alice2) # MixedContextsError, even for the + # same user and the same list id. + +# Right — a fresh ref per context; `ref()` is cheap. +await TaskList.ref(list_id).get(alice) +await TaskList.ref(list_id).get(alice2) +``` + +The error names the fix directly: "Instead create a new +`WeakReference` for every `Context`." + +### 21. The State Type Named `User` Is Auto-Constructed + +A state type literally named `User` is special: when +`Application(oauth=...)` is configured, Reboot auto-constructs one +`User` actor per signed-in identity, whose state id **is** the +`context.auth.user_id`, on first access. Do not write a sign-up +method that creates it, do not pass an id to `useUser()` in the +browser, and do not construct it in tests — impersonating a user +with `await rbt.create_external_context_as(name, user_id)` is enough +for `User.ref(user_id)` to resolve. The auto-construction happens +only under `oauth=`; an app with a `User` type and no `oauth=` fails +to start. Other state types are constructed explicitly, by their +factory `create`. diff --git a/reboot/plugin/skills/python/references/patterns-idempotency.md b/reboot/plugin/skills/python/references/patterns-idempotency.md index 9c72ff50..6ad74830 100644 --- a/reboot/plugin/skills/python/references/patterns-idempotency.md +++ b/reboot/plugin/skills/python/references/patterns-idempotency.md @@ -72,3 +72,47 @@ Reboot may retry transactions internally. Don't write transaction code that depends on running exactly once — every action inside a transaction should be idempotent at the level of "the same input produces the same state change once committed". + +## Uncertain Mutations — `IdempotencyUncertainError` + +When a mutation call raises, the client has to decide whether the +mutation actually happened on the server. It can only be sure when +the exception is **definitively from the backend**: an `Aborted` +carrying an error the method _declared_. Those are recoverable — a +caller can catch one and the transaction still commits. + +Anything else (a transport failure, a cancellation, an undeclared +error) leaves the client genuinely unable to tell, so it marks the +context as having an **uncertain mutation**. The next mutation you +make from that same context _without_ an idempotency key then +fails with `IdempotencyUncertainError`: + +> Because we don't know if the mutation from calling `X` of state +> `'…'` failed or succeeded AND you've made some NON-IDEMPOTENT +> mutations we can't reliably determine whether or not the call to +> `Y` … is due to a retry which may cause an undesired mutation + +It is not complaining about the call it refused — it is refusing +because an _earlier_ call left the context uncertain. + +Two consequences worth knowing before you meet them: + +- **Asserting a declared error is safe.** A test that does + `with self.assertRaises(TaskList.AddTaskAborted)` on a method + that declares `QuotaExceededError` creates no uncertainty, and + the context stays usable for the assertions that follow. +- **Retrying a mutation yourself requires an idempotency key.** + Any hand-written retry loop, and any mutation issued after a + cancelled or transport-failed one, must carry + `.idempotently("alias")` or an explicit `key=`: + + ```python + await TaskList.ref(list_id).idempotently("Add the first task").add_task( + context, title="Milk", + ) + ``` + + The alias must be unique within the lifetime of the context — + reusing one is how you tell Reboot "this is the same logical + mutation", which is exactly right for a retry and exactly wrong + for two different additions. diff --git a/reboot/plugin/skills/python/references/react-generated-client.md b/reboot/plugin/skills/python/references/react-generated-client.md new file mode 100644 index 00000000..749b43d4 --- /dev/null +++ b/reboot/plugin/skills/python/references/react-generated-client.md @@ -0,0 +1,124 @@ +--- +title: The Generated React Client Contract +impact: HIGH +impactDescription: The exact hook, mutator, and error shapes `rbt generate --react=` emits — identical for web apps and MCP chat apps +tags: react, hooks, generated, codegen, errors, typescript +--- + +## The Generated React Client Contract + +`rbt generate --react=` emits the same client for every surface, +so this contract holds whether the component renders in a browser SPA +or inside an MCP host. What differs between surfaces — where the +files live, how the backend URL is found, how a user signs in — is in +the skill you are building with: +[`web-app/references/react-client.md`](../../web-app/references/react-client.md) +or +[`chat-app/references/react-scaffolding.md`](../../chat-app/references/react-scaffolding.md). + +**Do not read the generated `*_rbt_react.ts` to rediscover any of +this.** It runs to tens of thousands of lines, and every one you open +is re-sent on every later turn. + +## What Is Emitted, per State Type + +For a state type `Foo`: + +```ts +// Two overloads. With an id, the handle directly; without one, the +// default-id form (the signed-in user, or the id the MCP session or +// a URL parameter resolves to). +export function useFoo(args: { id: string }): UseFooApi; +export function useFoo(args?: undefined): { + foo: UseFooApi | undefined; // named after the state type + isLoading: boolean; +}; + +export interface UseFooApi { + state_id: string; // the resolved id + mutators: FooMutators; + idempotently: (args: { key: string }) => FooIdempotently; + + // One `use` per Reader method. `partialRequest` is + // optional and partial — every field has a default. + useBar(partialRequest?: Foo.PartialBarRequest): { + response: Foo.BarResponse | undefined; + isLoading: boolean; + aborted: FooBarAborted | undefined; + }; + + // One per mutation, callable straight off the handle. + baz( + partialRequest?: Foo.PartialBazRequest, + options?: { metadata?: any; idempotencyKey?: string }, + ): Promise>; +} + +// Per method, a typed error union you can switch on. +export type FooBazAbortedError = /* your declared errors | framework errors */; +export class FooBazAborted extends reboot_api.Aborted { /* .error, .message */ } +``` + +## Naming Rules + +Write the call before you read anything: + +- Python snake_case → TypeScript camelCase, for fields and methods + (`from_index` → `fromIndex`, `open_task_count` → `openTaskCount`). +- A mutation's argument type is `PartialRequest`; request and + response types are Zod-validated. +- A method's failure type is `Aborted`, and + `aborted.error.type` is the Python error class's name. + +## Readers Return Three Fields, Not Two + +`response`, `isLoading`, **and** `aborted`. A reader can abort — a +denied `authorizer()`, for instance — and that surfaces as `aborted` +rather than by throwing. + +Guard on `response !== undefined` before touching data: it is the +only one of the three that narrows `T | undefined`. Use `isLoading` +for connection state. They diverge — an aborted reader is +`!isLoading` with no `response`, a reconnect is `isLoading` with a +stale `response` — and transport disconnects auto-reconnect without +surfacing as `aborted`, so don't build an online/offline indicator +out of it. + +## Mutations Never Throw + +They resolve to `{ response, aborted }`. A `try/catch` around one +catches nothing and the failure disappears silently. Branch on +`aborted`: + +```tsx +const { aborted } = await foo.baz({ title }); +if (aborted !== undefined) { + // `aborted.error.type` is the Python error class's name. + return show(aborted); +} +``` + +## Hook IDs Must Be Real on Every Render + +An explicit-id hook is not SWR-style: there is no "pass `undefined` +to skip" mode. `id: ''` throws +`state ID must have a length of at least 1`, and `id: undefined` +throws inside `stateIdToRef`. Identity often resolves +asynchronously, so the fix is **not** a placeholder id — +`useFoo({ id: id || '__none__' })` makes every loading session +subscribe to the same shared actor. Don't mount the component until +the id is real: guard at the parent and pass a guaranteed-real id +down. + +## Live Updates Are Free + +Reader hooks are push-based subscriptions. When any session mutates +the state, every mounted reader re-renders with the new response — +no polling, no refetch call, no transport code of your own. + +The transport underneath is chosen for you: gRPC server-streaming +over `https:`, and a WebSocket multiplex otherwise, which is what a +local `http://` dev server uses. It matters only when you are +reading a network trace — and because cross-origin WebSocket frames +carry no cookies, which is why the session JWT rides in the request +payload rather than in a `Cookie` header. diff --git a/reboot/plugin/skills/python/references/testing-external-context.md b/reboot/plugin/skills/python/references/testing-external-context.md index 44f502ca..18ca33a0 100644 --- a/reboot/plugin/skills/python/references/testing-external-context.md +++ b/reboot/plugin/skills/python/references/testing-external-context.md @@ -139,6 +139,38 @@ with self.assertRaises(Aborted): await other_cart.get_cart(other_context) ``` +## Asserting a Live Update — `reactively()` + +A "another session sees the change without refreshing" story is +tested with `reactively()`, the same push-based subscription the +generated React hooks use. `Type.ref(id).reactively().(context)` +returns an **async iterator** that yields a fresh response on every +state change; `anext()` pulls the next one. Never poll in a loop +with `asyncio.sleep` — that tests the sleep, not the reactivity. + +```python +# The "other browser session" subscribes... +subscription = TaskList.ref(list_id).reactively().get(bob) +first = await asyncio.wait_for(anext(subscription), timeout=10) +self.assertEqual(first.tasks, []) + +# ...another session writes... +await TaskList.ref(list_id).add_task(alice, title="Milk") + +# ...and the subscription is pushed the update. +while True: + update = await asyncio.wait_for(anext(subscription), timeout=10) + if len(update.tasks) == 1: + break +self.assertEqual(update.tasks[0].title, "Milk") +``` + +Two details that make the difference between a passing and a +flaky test: always wrap `anext()` in `asyncio.wait_for` so a +missing update fails fast instead of hanging the suite, and loop +until the state you expect rather than asserting on the very next +yield — a subscription may deliver an intermediate snapshot first. + ## Waiting for Spawned Tasks and Workflows When a method spawns a task (via `schedule(...).method(context)` diff --git a/reboot/plugin/skills/python/references/testing-harness.md b/reboot/plugin/skills/python/references/testing-harness.md index 39a808b3..a447bd20 100644 --- a/reboot/plugin/skills/python/references/testing-harness.md +++ b/reboot/plugin/skills/python/references/testing-harness.md @@ -266,3 +266,83 @@ This is exactly why "write tests for each user story before handing the app off" is in the `chat-app` and `web-app` build flows: the tests catch contract bugs that a manual click-through won't surface for several minutes. + +## Asserting a Typed Error — and Keeping `mypy` Happy + +A method that declares `errors=[QuotaExceededError, ...]` raises +`.Aborted` whose `.error` is the typed error. Two +things trip people up: + +1. The generated `Aborted` type is per-method: + `TaskList.AddTaskAborted`, not a bare `Aborted`. +2. `.error` is typed as a **union** of your declared errors plus + every framework error (`Cancelled`, `PermissionDenied`, + `Unknown`, …). `mypy` therefore rejects `error.limit` with + `Item "PermissionDenied" of "QuotaExceededError | Cancelled | ..." has no attribute "limit"` until you narrow it. + +```python +with self.assertRaises(TaskList.AddTaskAborted) as caught: + await TaskList.ref(list_id).add_task(alice, title="one too many") + +error = caught.exception.error +assert isinstance(error, QuotaExceededError) # Narrows the union. +self.assertEqual(error.limit, 10) +``` + +`unittest`'s `assertIsInstance` checks at runtime but does **not** +narrow for `mypy`; a plain `assert isinstance(...)` does both. Use +the `assert` form, or pair the two. + +## Racing Two Mutations in One Test + +A concurrency test issues both calls at once and asserts exactly one +survives. Two rules make it work: + +- **One external context per concurrent caller.** Contexts are not + safe to use from two places at once, and a `ref()` is bound to + the context that first used it (`MixedContextsError`). Create a + second context for the same user id when a single user races + themselves from two sessions. +- **Gather with `return_exceptions=True`**, then partition — the + loser raises, and letting `gather` propagate it would hide the + winner. + +- **Set up a state where exactly one call can succeed.** Two + concurrent calls that are both individually legal both succeed — + that tests nothing. The test needs a rule that only one of them + can satisfy: the last slot under a quota, the last item in stock, + a balance that covers one of the two transfers. + +```python +# Precondition: the rule allows 10 open tasks, and 9 already exist, +# so exactly one of the two racing calls below can be allowed. +for i in range(9): + await TaskList.ref(list_a).add_task(self.alice, title=f"t{i}") + +alice2 = await self.rbt.create_external_context_as( + name=f"alice2-{self.id()}", user_id=ALICE, +) +results = await asyncio.gather( + TaskList.ref(list_a).add_task(self.alice, title="race-a"), + TaskList.ref(list_b).add_task(alice2, title="race-b"), + return_exceptions=True, +) +failures = [r for r in results if isinstance(r, BaseException)] +self.assertEqual(len(results) - len(failures), 1) +self.assertIsInstance(failures[0], TaskList.AddTaskAborted) + +# And the invariant actually held — not just "one call raised". +profile = await User.ref(ALICE).profile(self.alice) +self.assertEqual(profile.open_task_count, 10) +``` + +Assert the invariant, not only the exception. A test that checks +"one of them failed" passes even if the winner corrupted the +counter on the way through. + +Reboot serializes writers on the same actor and rolls transactions +back all-or-nothing, so routing the shared invariant (a counter, a +quota, a balance) through **one** actor is what makes "exactly one +wins" true. If the invariant is spread across two actors with no +transaction covering both, the race is genuinely lossy and no test +setup will fix it. diff --git a/reboot/plugin/skills/python/references/testing-project-setup.md b/reboot/plugin/skills/python/references/testing-project-setup.md index b25f5754..948bd427 100644 --- a/reboot/plugin/skills/python/references/testing-project-setup.md +++ b/reboot/plugin/skills/python/references/testing-project-setup.md @@ -61,11 +61,21 @@ needs a `pip install -e .`: pythonpath= src/ api/ + ../api/ ``` +**Three entries, not two.** `src/` and `api/` cover your servicers +and the generated `_rbt` modules, but tests also import the +hand-written API definition itself — the typed errors and models — +as `from .v1. import QuotaExceededError`, and that module +lives in the project-root `api/` directory, one level up from +`backend/`. Leave `../api/` out and the suite fails at import with +`ModuleNotFoundError: No module named '.v1.'`, which +looks like a codegen failure but is a path problem. + If the layout uses `backend/src` from the project root instead of running pytest from `backend/`, adjust the paths accordingly (e.g. -`pythonpath = backend/src backend/api`). +`pythonpath = backend/src backend/api api`). ## `conftest.py` — Only When Needed diff --git a/reboot/plugin/skills/upgrade/migrations/next/mypy-api-package-and-vite-host.md b/reboot/plugin/skills/upgrade/migrations/next/mypy-api-package-and-vite-host.md new file mode 100644 index 00000000..6fc8cfba --- /dev/null +++ b/reboot/plugin/skills/upgrade/migrations/next/mypy-api-package-and-vite-host.md @@ -0,0 +1,60 @@ +## `mypy` was skipping the API definition; the Vite dev server binds IPv6-only + +Two configuration files that earlier project templates generated are +subtly wrong. Neither failure is visible: the first reports success +while checking nothing, the second starts a server the browser cannot +reach. + +### 1. Make `mypy` actually check the API definition + +Look at the project-root `.mypy.ini`. Two things need to be true, and +in older projects neither is: + +- `mypy_path` must include the project-root `api/` directory — the + one holding the hand-written pydantic API definition, not just the + generated `backend/api/`. Append `:api` to the existing value, e.g. + `mypy_path = backend/tests:backend/src:backend/api:api`. +- The "don't check generated code" stanza must name the generated + module only. If the file has a blanket + `[mypy-.v1.*]`, narrow it to `[mypy-.v1._rbt]`, + where `` is the API definition's module name (the file under + `api//v1/`). Repeat per API package. + +Together these two mistakes made every import of a state or request +model — `from .v1. import State` — resolve to `Any`. +Any annotation mentioning one checked nothing, and a misspelled field +on `state` passed a green `mypy` run. + +To confirm the fix is live, temporarily add a bogus attribute access +on a state model (e.g. `state.no_such_field_xyz` in a servicer or +authorizer predicate) and run `mypy` from the project root: it must +report `has no attribute`. Remove the bogus line afterwards. + +**Expect newly-reported errors.** Code that was never type-checked is +now checked for the first time, so this can surface real mistakes. +Fix them rather than re-widening the ignore stanza. + +### 2. Let the browser reach the Vite dev server + +Applies to apps with a standalone web frontend (a `web/`, or whatever +directory `generate --react=` points into, served by Vite). + +If `vite.config.ts` has no `server.host`, add one: + +```ts +export default defineConfig({ + // ...existing plugins/resolve config... + server: { + // Listen on every interface. Vite's default is `localhost`, + // which on modern Node resolves to IPv6 `[::1]` only; a + // forwarded port (Codespaces, VS Code remote, a dev VM, a + // tunnel) connects over IPv4 `127.0.0.1` and gets connection + // refused. + host: true, + port: parseInt(process.env.PORT || "5173", 10), + }, +}); +``` + +Without it `npm run dev` prints a URL and logs no error, but the page +is unreachable from a browser on the other side of a forwarded port. diff --git a/reboot/plugin/skills/web-app/SKILL.md b/reboot/plugin/skills/web-app/SKILL.md index 7428d796..42e7e25c 100644 --- a/reboot/plugin/skills/web-app/SKILL.md +++ b/reboot/plugin/skills/web-app/SKILL.md @@ -87,6 +87,17 @@ as MCP). > (anything that is not a Reboot-minted access JWT) falls > through to yours. +The imports, so you don't have to go looking for them: + +```python +from reboot.aio.applications import Application +from reboot.aio.auth.oauth_providers import ( + Development, + Google, # or GitHub, Auth0, … + OAuthProviderByEnvironment, +) +``` + Recommended sequence: 1. **Early development (no provider chosen yet):** configure @@ -107,77 +118,51 @@ Recommended sequence: `python/references/servicer-authorizer.md`, `python/references/auth-allow-if.md`, and `python/references/auth-built-in-predicates.md`. The - provider-selection rules are identical to MCP chat apps — - load the + The providers and what each needs: + + All arguments are keyword-only. + + | provider | required arguments | + | --------------- | ----------------------------------------- | + | `Development()` | none — dev only, sign in as any identity | + | `Anonymous()` | none — every visitor is a fresh identity | + | `Google(...)` | `client_id=`, `client_secret=` | + | `GitHub(...)` | `client_id=`, `client_secret=` | + | `Auth0(...)` | `domain=`, `client_id=`, `client_secret=` | + | `Ory(...)` | `domain=`, `client_id=`, `client_secret=` | + + The registered providers (everything but `Development` and + `Anonymous`) also take `scopes=`, `claims=`, and + `store_tokens=`; register `/__/oauth/callback` as the redirect + URI with the provider. + + Add `claims=[...]` when you need identity fields such as the + user's email, and `store_tokens=True` only when you will call + that provider's own API as the user. **Choose deliberately + before real users exist**: `context.auth.user_id` is namespaced + per provider, so switching providers after launch strands every + existing user's state. Only reach for [chat-app/references/auth-oauth-providers.md](../chat-app/references/auth-oauth-providers.md) - reference for the per-provider details (client IDs, scopes, - `store_tokens=True`, requesting identity claims like the - user's email via `claims=`, the user-ID-namespace gotcha when - switching providers post-launch). In unit tests, keep + if you need to write a custom provider or debug a specific + provider's flow. In unit tests, keep `token_verifier=` exactly as in production — the test harness's OAuth server verifies the impersonation token minted by `await rbt.create_external_context_as(name, user_id)`, and a custom bearer a test constructs by hand still hits your IdP verifier; the authorizer rules run for real either way. + 3. **Public, unauthenticated endpoints** (health checks, public sign-up, public catalog reads): mark these explicitly with `allow()`. That's the one legitimate use. -### Feeding the user's identity into hooks — never fabricate an id - -With `Application(oauth=...)`, the signed-in user's own state -needs no id-threading at all: call the `User` hook with **no -arguments** and branch on its `{ user, isLoading }` shape (see -"Browser-side wiring (React)" below); the signed-in user's id — -for passing into backend calls or other components — is -`user.state_id`. - -An **explicit-id** call (`use({ id })`) needs a **real, -non-empty actor id on every render**. It is not SWR-style: there -is no "pass `null`/`undefined` to skip the subscription" mode. A -falsy id is a hard throw during render, not a paused hook — -`id: ''` throws `state ID must have a length of at least 1` and -`id: undefined` throws a `TypeError` inside `stateIdToRef`. Either -one crashes the component. - -The trap: browser identity resolves **asynchronously** (the -`/__/oauth/whoami` probe under `oauth=`, or an external IdP like -Auth0/Firebase under `token_verifier=`), so on the first renders -you have no user id yet. Do **not** dodge the throw by -fabricating a placeholder — `useUser({ id: userId || '__no-user__' })` -is wrong. An actor id is a **global key**, so every loading session -subscribes to the same shared `__no-user__` actor, it's one -missed write-guard away from cross-user state, and the placeholder -addresses nothing — it just silences the crash. - -Since an explicit-id hook can't be told "no id" and can't be -called conditionally (React's rules of hooks), the fix is to **not -mount the component that calls the hook until you have the real -id**. Guard at the parent and pass a guaranteed-real id down. With -`oauth=` that guard is the no-arguments `useUser()` pattern from -"Browser-side wiring (React)" below — inside the signed-in -subtree, `user.state_id` is guaranteed real. With an external IdP -the shape is the same: - -```tsx -function UserHome() { - const { user, isAuthenticated, isLoading } = useAuth0(); - if (isLoading) return ; - if (!isAuthenticated || !user?.sub) return ; - // From here, user.sub is guaranteed present and non-empty. - return ; -} - -function UserView({ userId }: { userId: string }) { - // Hook always runs, always with a real, per-user id. - const { create } = useUser({ id: userId }); - // ... -} -``` +### Feeding the user's identity into hooks -No placeholder, no fake actor, no `userId && create(...)` guards -scattered around mutations — the hook simply never runs until the -key is real. +With `Application(oauth=...)` the signed-in user's own state needs +no id-threading: call the `User` hook with **no arguments**. For +explicit-id hooks the rule is that the id must be real on every +render — never a placeholder — which is covered with the rest of +the hook mechanics in +[`references/react-client.md`](references/react-client.md). ### Calling external APIs on the user's behalf @@ -203,59 +188,68 @@ same recipe. ### Browser-side wiring (React) -Wrap your app in `` and branch on the -generated `useUser()` hook for the `User` state type — the same -hook MCP-embedded UIs use. Called with no id, `useUser()` returns -`{ user, isLoading }`: `isLoading` is true while the session probe -(`/__/oauth/whoami`) is in flight, then `user` is the handle once -signed in or `undefined` when signed out. Pass the resolved `user` -handle to your signed-in subtree and read its id from -`user.state_id`: - -```tsx -import { - RebootClientProvider, - useSignIn, - useSignOut, -} from "@reboot-dev/reboot-react"; -import { UseUserApi, useUser } from "./gen/your_api/v1/your_api_rbt_react"; - -function App() { - const { user, isLoading } = useUser(); - const signIn = useSignIn(); - const signOut = useSignOut(); - if (isLoading) { - return ; - } - if (user === undefined) { - return ; - } - return ( - <> - - - - ); -} - -// The signed-in subtree takes the `user` handle directly; read its -// id from `user.state_id` and its readers/mutators off the handle. -function YourSignedInApp({ user }: { user: UseUserApi }) { - const { response } = user.useWhoami(); - // ... -} - -export default () => ( - - - -); -``` +All of it — the provider (and the `url` it must be given), the +generated hook surface, sign-in/sign-out, reading typed errors, and +why a hook's `id` must be real on every render — is in +[`references/react-client.md`](references/react-client.md). Read it +at the frontend step; don't reconstruct it from memory here. + +## Which References to Read, and When + +Everything you read stays in the conversation and is re-sent on +every later turn, so **read each reference at the step that needs +it**, not all of them up front. The tiers below are in build order. + +> **Never read `chat-app/references/*` for a web app.** They cover +> the MCP surface — `UI()` artifacts, the MCPJam inspector, the +> nested `frontend/mcp//` Vite output, `mcp=Tool()` markers, +> popping a widget out into a web app. Reaching into them costs +> context and produces chat-app-shaped code (`mcp=None` on every +> method of an app with no MCP surface). The web equivalents are +> [`references/react-client.md`](references/react-client.md) and the +> `python` references named below. The single exception is +> [chat-app/references/auth-oauth-providers.md](../chat-app/references/auth-oauth-providers.md), +> which is surface-neutral: read it when you pick a real provider. + +**Tier 0 — before you write the API definition:** + +- `python/references/patterns-common-gotchas.md` — recurring trips + (`self.ref().state_id`, kwargs convention, a `ref()` belongs to + one context, the auto-constructed `User` type, etc.). +- `python/references/api-pydantic.md` — pydantic API rules (every + Field needs a zero-value default; non-Optional `Model`-typed + fields can't take defaults). +- `python/references/api-methods.md` — factory → context type + mapping (Reader/Writer/Transaction/Workflow). +- `python/references/state-collections.md` — when the app has any + "list of X" concept. + +**Tier 1 — before you write the servicer:** the +`python/references/servicer-*.md` file for each context type you +actually declared, plus `python/references/api-errors.md` if the +API declares typed errors, and `rpc-refs.md` / `rpc-calls.md`. + +**Tier 2 — before you write the frontend:** +[`references/react-client.md`](references/react-client.md) for the +web shell, backend URL, and sign-in, plus +`python/references/react-generated-client.md` for the generated +hook/mutation/error shapes. + +**Tier 3 — before you write tests:** the three +`python/references/testing-*.md` files, plus +`python/references/patterns-idempotency.md` — it explains +`IdempotencyUncertainError`, which is otherwise the one runtime +error whose cause is not in any reference you have read. + +**Tier 4 — before you run the app:** the +[`run` skill](../run/SKILL.md). -## Read These From `python` First +Read a reference **once**. If you find yourself grepping the +framework's installed source or a generated file to answer a +question, see "When the Skills Don't Answer It" below — do not +explore it in the main conversation. -Before scaffolding, load the references that cover the backend -mechanics. The patterns in this skill assume you've read them. +The rest of this section is what each reference covers. **Always relevant:** @@ -326,19 +320,44 @@ mechanics. The patterns in this skill assume you've read them. → read back + call inside a `Workflow`. Never a plain `str` token field. -## Workflow: Plan First, Then Build +**Browser frontend:** + +- [`references/react-client.md`](references/react-client.md) — the + `web/` shell (stock Vite config + the two `@reboot-dev` packages), + the backend URL (`VITE_REBOOT_URL` — the default detection + resolves to Vite's origin, not the backend's), the generated + `use()` hook surface, why mutations return + `{ response, aborted }` instead of throwing, and how a typed + backend error becomes a message the user sees. + +## Never Read Generated or Installed Source in the Main Thread + +`*_rbt.py`, `*_rbt_react.ts`, `site-packages/`, `node_modules/`, +and codegen templates run to tens of thousands of lines. Every one +you open is re-sent on every remaining turn, which makes reading +them the most expensive way in the system to learn a fact. + +The generated surfaces you actually need are written out in these +references — the React client in +[`references/react-client.md`](references/react-client.md), the +backend shapes in `python/references/`. Use them. + +If something genuinely isn't covered, bound the output hard: a +targeted `grep -n … | head -40`, or `sed -n ',p'` over +a known range. Never a whole generated file, never an unbounded +recursive grep. + +## Workflow: Settle the Design, Then Build -**Always plan the design and get approval before writing code.** The -state model is the foundation — getting entities, field types, or -method types wrong means regenerating everything across the project. +**Always settle the design before writing code.** The state model +is the foundation — getting entities, field types, or method types +wrong means regenerating everything across the project. -### Plan Phase +### Design Phase 1. Analyze the user's description using the State Model Assessment below. -2. Begin a plan for the user to approve (in Claude Code, enter plan - mode; in Codex, present the plan and wait for the go-ahead). -3. Present the proposed design: +2. State the design you are about to build: - Application types: state shape (fields, types, tags). - Method map: which operations, which method type (Reader/Writer/Transaction/Workflow). @@ -346,20 +365,19 @@ method types wrong means regenerating everything across the project. each page calls. - Auth: anonymous, logged-in, or per-user state? If per-user, declare a `User` type for owned data and route through it. -4. Get user approval before writing any files. -5. Then execute the Step-by-Step Build Flow. +3. Then execute the Step-by-Step Build Flow. -For updates to existing apps, still plan: read current state, -propose changes, confirm, then modify. +For updates to existing apps, still work the design first: read +current state, state the changes, then modify. -### Writing the Plan for Human Review +### Writing the Design for a Human Reader -The plan is read by a **human who has not read the skill files**. -They are evaluating the design — entities, collections, methods, +The design is read by a **human who has not read the skill files**. +They are judging the design — entities, collections, methods, routes, auth — not verifying that you followed the skill. Write -so the plan stands on its own. +so it stands on its own. -**Don't quote skill-internal terms** when presenting the plan. +**Don't quote skill-internal terms** when presenting the design. They mean nothing outside this skill: - `Shape A` / `Shape B` / `Shape C` — name the actual data @@ -408,7 +426,7 @@ Nested model — GOOD: > state actors because they have no lifecycle, methods, or auth > independent of the Document they belong to. -**Escape hatch.** When the precise type name _is_ what the user +**Escape hatch.** When the precise type name _is_ what the reader needs to see ("I'm proposing `OrderedMap` here, not `list[str]`"), name the type — but pair it with the plain-English reason in the same sentence. The rule is "no bare jargon", not "no technical @@ -480,11 +498,15 @@ Before writing code, analyze the user's request: │ └── /v1/ │ └── .py # API definition (pydantic) ├── backend/ -│ └── src/ -│ ├── main.py # Application entrypoint -│ └── servicers/ -│ └── .py # Servicer implementation +│ ├── .pytest.ini # pythonpath: src/ api/ ../api/ +│ ├── src/ +│ │ ├── main.py # Application entrypoint +│ │ └── servicers/ +│ │ └── .py # Servicer implementation +│ └── tests/ +│ └── _test.py # One test per user story └── web/ + ├── .env.development # VITE_REBOOT_URL=http://localhost:9991 ├── package.json ├── tsconfig.json ├── tsconfig.app.json @@ -511,8 +533,7 @@ Key differences from a `chat-app` layout: ## Step-by-Step Build Flow -**Only execute after plan approval. All commands run from the -application directory.** +**All commands run from the application directory.** 1. Create `.python-version`, `pyproject.toml`, `.rbtrc`, and `.mypy.ini` — same shape as in @@ -535,32 +556,25 @@ application directory.** 7. Initialize the React app at `web/` with your preferred tool (e.g. `npm create vite@latest web -- --template react-ts`) or a Reboot-provided template if one exists for plain web apps. + Read [`references/react-client.md`](references/react-client.md) + now — it has the `package.json` dependency set, the `dedupe` + entry the Vite config needs, and `web/.env.development` with + `VITE_REBOOT_URL`. 8. `cd web && npm install` and add the Reboot React client package(s) per your project's `package.json`. 9. `uv run rbt generate` again — the React bindings need `node_modules` to resolve types correctly. -10. Wire `main.tsx` with `RebootClientProvider`, then build `App.tsx` - and the page components, calling generated `use()` hooks - for reader subscriptions and mutations. Field-name conversion is - Python-snake → TypeScript-camel; request/response types are - Zod-validated. A reader hook returns both `isLoading` and - `response`: use `isLoading` (the stream's connection state) for - loading/disconnected indicators (`!isLoading`, debounced, is a - connected/disconnected badge) and `response !== undefined` to - guard data access (it's also the only one that narrows - `response`'s `T | undefined` type). They diverge: an aborted - reader is `!isLoading` with no `response`; a reconnect is - `isLoading` with stale `response`. Transport disconnects - auto-reconnect and do **not** surface via `aborted`, so don't - reach for `aborted` or a heartbeat for an online/offline badge. - When a hook's `id` comes from the authenticated user, guard the - component so it only mounts once the id is real — see "Feeding - the user's identity into hooks" above. Never fabricate a - placeholder id to get past the non-empty-id validation. +10. Build the frontend from + [`references/react-client.md`](references/react-client.md): the + provider and its `url`, the generated hook/mutator/error + declarations, sign-in, and typed errors are all written out + there. Write the calls from that reference and do **not** open + `web/src/api/**/*_rbt_react.ts` to check them — it is tens of + thousands of lines that then ride along on every later turn. 11. `cd web && npm run build` (sanity check the bundle). 12. **Write and run backend unit tests covering each user-facing user story before handing the app off.** Enumerate the user - stories from the plan — every action the user should be able + stories from the design — every action the user should be able to _do_ in the UI (e.g. "sign up and see my profile", "submit the form and see the result on the dashboard", "delete an item and have it disappear"). Write one test @@ -568,7 +582,10 @@ application directory.** `backend/tests/_test.py`, following the patterns in `python/references/testing-project-setup.md`, `python/references/testing-harness.md`, and - `python/references/testing-external-context.md`. Use one + `python/references/testing-external-context.md`. When a test fails + for a reason that looks like it is inside the framework, check + `python/references/patterns-idempotency.md` and + `patterns-common-gotchas.md` before reading `site-packages`. Use one `IsolatedAsyncioTestCase`, one external context per test (`name=f"test-{self.id()}"`), and `Service.ref(id).method(context, ...)` for all calls — diff --git a/reboot/plugin/skills/web-app/references/react-client.md b/reboot/plugin/skills/web-app/references/react-client.md new file mode 100644 index 00000000..cf05d6da --- /dev/null +++ b/reboot/plugin/skills/web-app/references/react-client.md @@ -0,0 +1,200 @@ +--- +title: Wire the Web SPA to the Reboot Backend +impact: HIGH +impactDescription: The browser shell, the backend URL, the generated hooks, and how a typed backend error reaches the user +tags: web-app, react, vite, hooks, errors, RebootClientProvider +--- + +## Wire the Web SPA to the Reboot Backend + +Everything the standalone browser frontend needs. This is the +web-app equivalent of the chat-app's scaffolding references — +**do not read those**: their Vite config, nested +`frontend/mcp//index.html` output, and `UI()` machinery are +MCP-host-specific and do not apply to a web app. + +## The `web/` Shell + +Stock Vite React-TS scaffolding (`npm create vite@latest web -- --template react-ts`), plus the two Reboot packages. Pin them to +the same version as the backend's `reboot` dependency: + +```json +{ + "name": "-web", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build" + }, + "dependencies": { + "@reboot-dev/reboot-api": "", + "@reboot-dev/reboot-react": "", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "zod": "^4.0.0" + } +} +``` + +`vite.config.ts` is the stock config with two additions, both +load-bearing: + +```ts +export default defineConfig({ + plugins: [react()], + // Two copies of `react` or `zod` — one from the app, one pulled + // through the Reboot packages — break hooks and schema identity + // checks at runtime. + resolve: { dedupe: ["react", "react-dom", "zod"] }, + server: { + // Listen on every interface. Vite's default is `localhost`, + // which on modern Node resolves to IPv6 `[::1]` only; a + // forwarded port (Codespaces, VS Code remote, a dev VM, a + // tunnel) connects over IPv4 `127.0.0.1` and gets connection + // refused, so the page is unreachable from the browser even + // though the dev server is healthy and logs no error. + host: true, + port: parseInt(process.env.PORT || "5173", 10), + }, +}); +``` + +Leaving `server.host` out is the single most common reason a +freshly built app "starts fine" and then won't open: `npm run dev` +prints a URL, the process is up, and the browser cannot reach it. + +## The Backend URL — Set It Explicitly in Dev + +`` with no `url` falls back to detection: +`window.REBOOT_URL`, then a `?rebootUrl=` query parameter, then +`window.location.origin`. In development the SPA is served by Vite +on `:5173` while the backend listens on `:9991`, so the fallback +resolves to the **wrong** origin — and when even that is +unavailable the client throws +`Could not detect Reboot server URL. Ensure the page is served from the Reboot server.` + +Pass it explicitly, from an env file: + +``` +# web/.env.development +VITE_REBOOT_URL=http://localhost:9991 +``` + +`import.meta.env` needs Vite's ambient types or `npm run build` +fails with `Property 'env' does not exist on type 'ImportMeta'`. +Stock `create vite` scaffolding includes the file; if you assembled +`web/` by hand, write it: + +```ts +// web/src/vite-env.d.ts +/// +``` + +```tsx +// web/src/main.tsx +const REBOOT_URL = + (import.meta.env.VITE_REBOOT_URL as string | undefined) ?? + window.location.origin; + +createRoot(document.getElementById("root")!).render( + + + +); +``` + +The `?? window.location.origin` keeps the production build working +when the backend serves the built assets from one origin. + +## The Generated Client + +The hook, mutator, and error declarations `rbt generate --react=` +emits are identical for every surface, so they live in one place: +[`python/references/react-generated-client.md`](../../python/references/react-generated-client.md). +Read it before writing components — it has the `useFoo` overloads, +`UseFooApi`, the three-field reader return, `ResponseOrAborted`, the +`Aborted` error classes, the snake→camel naming rules, +and why a hook id must be real on every render. Do **not** open +`web/src/api/**/*_rbt_react.ts` to rediscover them. + +What is web-app-specific: the client is created by the +`` above, and the signed-in user's +handle comes from the no-argument `useUser()` (see "Sign-in and +Sign-out" below). + +## Surfacing a Typed Error to the User + +`aborted.error` is a discriminated union — the errors the method +declared plus the framework's (`PermissionDenied`, `Unknown`, …) — +tagged by `error.type`, which is the Python error class's name. A +single translator keeps the switch in one place; every story with +a "shows a visible error" requirement routes through it: + +```ts +// web/src/errors.ts +export function friendlyError(aborted: { + error: { type: string } & Record; + message: string; +}): string { + switch (aborted.error.type) { + case "QuotaExceededError": + return `Limit reached (${String(aborted.error.limit)}).`; + case "UnknownUserError": + return `No user named "${String(aborted.error.username)}".`; + case "PermissionDenied": + return "You don't have access to do that."; + default: + return aborted.message || `Something went wrong.`; + } +} +``` + +The fields on each error case are exactly the fields declared on +the pydantic error model in the API definition, camelCased. The +frontend only _reports_; the backend already refused the operation. + +## Sign-in and Sign-out + +`useSignIn()` / `useSignOut()` from `@reboot-dev/reboot-react` drive +the built-in OAuth server mounted at `/__/oauth/*`; call the +returned function from a button. Session state lives in the +HttpOnly `rbt_session` cookie, so there is no token to store, and +`useUser()` reports the result (`user === undefined` when signed +out). + +The whole shape, and the one place the signed-in subtree gets its +guaranteed-real id: + +```tsx +import { + RebootClientProvider, + useSignIn, + useSignOut, +} from "@reboot-dev/reboot-react"; +import { UseUserApi, useUser } from "./api//v1/_rbt_react"; + +function App() { + const { user, isLoading } = useUser(); + const signIn = useSignIn(); + const signOut = useSignOut(); + // `isLoading` covers the `/__/oauth/whoami` session probe. + if (isLoading) return ; + if (user === undefined) { + return ; + } + return ( + <> + + + + ); +} + +// Mounted only once `user` exists, so `user.state_id` is real and +// every hook below it can be called with a genuine id. +function SignedIn({ user }: { user: UseUserApi }) { + const { response } = user.useProfile(); + // ... +} +```