Skip to content

WIP - #131

Draft
benh wants to merge 31 commits into
riley/reboot-inspectfrom
caller-static-analysis-2
Draft

WIP#131
benh wants to merge 31 commits into
riley/reboot-inspectfrom
caller-static-analysis-2

Conversation

@benh

@benh benh commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

No description provided.

rileysdev and others added 5 commits August 13, 2026 22:14
mypy 1.18 rejects `if x is Any:` as `comparison-overlap` when `x` is
declared as a union of concrete types. The comparison is correct at
runtime: `get_args` on an annotation such as `dict[str, Any]` hands
back the `typing.Any` object itself, so the converters meet it as a
value. Only the declared parameter types disagree, since they do not
mention `Any`.

Route the comparison through `is_annotation_any`, whose parameter is
typed `object` — the honest domain of what typing introspection
returns. The converters' declared unions stay as they are, and the
runtime behavior is identical: the same `is` comparison, one call
deeper.

Until now this error failed the build of every target whose closure
reaches `reboot/api.py` under a fresh mypy run, which is how it was
found: Bazel's remote cache had been serving stale mypy results, so
CI never re-ran mypy over these files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A `description=` was only set on `McpMethodOptions.description`, so a
Reader, Writer, Transaction or Workflow that was not also an MCP tool
didn't have it. It is now set on `MethodOptions.description` for
every method, and MCP tool and resource descriptions read from there.

`McpMethodOptions.description` is deprecated but still read as a
fallback, so protos that already set it keep their descriptions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first of several changes, split so that each can be reviewed on
its own and so the dashboard can be tried out while the rest is
written. It is not a documented feature yet: nothing opens a page by
itself, so seeing one means passing `--open-dashboard` or visiting
the URL.

`rbt dev run` now starts a second Reboot application alongside the
developer's, with its own state store, holding what the dashboard
needs. The companion watches the developer's `api/` directory and
records what those files declare, so the dashboard can describe an
application. It also serves the page itself.

The page reads that schema reactively and renders one section per
state type: its fields, and each method's kind, whether it constructs,
whether it is reachable over MCP, its signature and the errors it
raises. Dashboard state, such as which detail views are open and which
are closed, is saved in Reboot state, so it survives a hot reload and
an `rbt dev run` restart.

Auto-open is complete but off. `_AUTO_OPEN_DASHBOARD` is False, so
only `--open-dashboard` opens a page. We don't reopen the dashboard if
the developer already has it open, and we use the `Presence` library
to determine whether they do. Note that presence does not drain
through a DevPod workstation's port forward, which is filed
separately.

This will eventually supersede the inspect dashboard at `/__/inspect`,
which lists state instances and their values. It does not replace it
yet and both exist meanwhile: this describes an application's API, its
state types, their fields and their methods, and cannot yet show the
data behind them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A method can say what it does, but a state type is the sum of its
state and its methods, and its name alone does not say what it is
for. `Type` now takes a description, which the dashboard shows
beside the state type's name and file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The manylinux images build every CPython with `--disable-shared` and
delete even the static `libpython` archives. The `reboot-dev-reboot`
genrule links `reboot_native.node` with `-lpython3.10`, a flag
emitted by `python3.10-config --ldflags --embed`. That link has never
been able to succeed inside these images.

CI stayed green only while Bazel's remote cache served the genrule's
outputs. The first cache miss made every platform fail
deterministically. On x86_64 that miss came from a runner hardware
swap: it changed the `lscpu` portion of `the_environment.txt`, and
with it the whole cache scope.

Point `python`/`python3` at a python-build-standalone CPython 3.10,
which ships `libpython3.10.so`. It is the same build
`reboot/nodejs/prepare_environment.sh` downloads. `pip`/`pip3` stay
on the manylinux interpreter, whose layout `auditwheel` and the
wheel builds expect. `python3` and `pip` therefore deliberately name
different installations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@benh
benh force-pushed the caller-static-analysis-2 branch 3 times, most recently from 883f11f to 26736aa Compare August 17, 2026 01:38
Comment thread reboot/dashboard/servicers.py Outdated
Comment thread reboot/dashboard/servicer_analyzer.py Outdated
Comment thread reboot/dashboard/main.py Outdated
Comment thread reboot/dashboard/main.py Outdated
Comment thread reboot/dashboard/servicer_analyzer.py Outdated
Comment thread reboot/dashboard/servicer_analyzer.py Outdated
Comment thread reboot/dashboard/servicer_analyzer.py Outdated
@benh
benh force-pushed the caller-static-analysis-2 branch 8 times, most recently from 0c5757f to cb9462a Compare August 17, 2026 03:26
benh and others added 2 commits August 17, 2026 03:57
`rbt dashboard` needed `--api-directory`, naming a directory the
`.rbtrc` already names for `rbt generate`. Two places to say the same
thing is two places to change it, and nothing tells you when only one
of them moves -- the dashboard just watches a directory the rest of
the tooling has stopped using.

So it reads what `rbt generate` was told instead, through a new
`ArgumentParser.dot_rc_arguments`, which returns what the `.rbtrc`
gives any subcommand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPEVMhyDRxZEuH8eiykv99
Work that waits on nothing -- parsing, hashing, encoding -- never
gives the event loop a chance of its own, so a servicer doing it over
a collection holds its process for as long as the whole collection
takes, and everything else it serves waits that long.

`concurrently` is the wrong tool, because there is nothing to overlap.
Measured over twelve parses of a 45KB file, it left the loop unable to
answer for 24ms at a stretch -- 15ms even limited to one at a time,
since its tasks are scheduled together and the loop drains several
before looking at anything else -- and cost 30% more wall-clock in
task machinery.

An `asyncio.sleep(0)` in the loop measures best, at 6ms, but invites
the question of why it is there and not somewhere else. This answers
it: the yield falls out of how the work was grouped, which is a
decision the caller has to make anyway, and the collection bounds it
the way `concurrently`'s does.

It takes elements rather than awaitables, because nothing is being
run: the work stays in the caller's body, where it can go on mutating
whatever it likes. Note an `async for` alone will not do -- `await` on
something that resolves without suspending never reaches the event
loop at all, which is why the yield has to live in here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPEVMhyDRxZEuH8eiykv99
@benh
benh force-pushed the caller-static-analysis-2 branch 6 times, most recently from f63d480 to 49dde08 Compare August 17, 2026 05:40
The API files say which state types exist. They say nothing about
which file implements one, and the name does not say either --
`servicers.py` may implement several state types while being named
after none of them. What does say is the application:

    Application(servicers=[AccountServicer, BankServicer, ...])

so this reads the entry point, resolves each registered servicer back
to the file defining it, and asks that class what it services.

Read rather than imported. Importing an application means having its
generated code, its dependencies and its `sys.path`, and the dashboard
is meant to work before any of that exists -- the same reason the API
files are read the way they are.

Driven by the API rather than by the filesystem: the API is what says
which state types there are to look for, so a state type appearing or
disappearing is what sets this going. `until_changes` suspends the
workflow in between, so it wakes when the declarations move rather
than on a timer.

Recorded one state per state type, so that working out one state
type's implementation neither waits on nor overwrites another's. A
state type the application registers no servicer for is recorded as
such rather than left looking unanalyzed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPEVMhyDRxZEuH8eiykv99
@benh
benh force-pushed the caller-static-analysis-2 branch 2 times, most recently from 8a18a13 to d6cacd4 Compare August 17, 2026 06:21
Comment thread reboot/dashboard/implementation_watcher.py Outdated
Comment thread reboot/dashboard/implementation_watcher.py Outdated
@benh
benh force-pushed the caller-static-analysis-2 branch from d6cacd4 to 514cf11 Compare August 17, 2026 06:32
@rileysdev
rileysdev force-pushed the riley/reboot-inspect branch from 5f669a8 to 0a7c6b1 Compare August 18, 2026 22:25
@benh
benh force-pushed the caller-static-analysis-2 branch 6 times, most recently from 57d31a4 to 6c66384 Compare August 19, 2026 05:34
benh and others added 10 commits August 19, 2026 06:07
Scaffolding for recognizing a state type however it is reached, with
no new spelling recognized yet. What a file's imports bound each of
its names to becomes `Imports`, whose `try_resolve_module` answers which
module a dotted name refers into and what it refers to there; the
modules those imports may have Python load are its `may_load`,
which is what following the imports follows. Resolution asks about a
dotted name (`_path` turns `rbt.Shop` into one) rather than a
bare one, and a servicer's base is matched by shape -- `X.Servicer`,
or `X.singleton.Servicer` -- with `X` any dotted name. Only the
one spelling that already resolved does: a name imported from a
`_rbt` module directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPEVMhyDRxZEuH8eiykv99
A name imported from a module that is not generated is followed into
that module's own file, and on, until the chain ends in a `_rbt`
module -- so a state type re-exported through another of the
developer's files resolves. A circular import terminates: a file
already being followed is not followed again.

Following made a file's answer depend on other files' contents, so an
iteration now carries a `Files` value through everything it does:
`known` is what the previous iteration analyzed, `parsed` what this
one has parsed and not yet analyzed, `analyzed` what it has finished,
and `pending` the frontier: every file reached, parsed or kept, whose
imports are not yet followed -- entering once and leaving once,
handled. Each `File` records the files it depends on, by the digest
each had when it was read -- recorded even when resolution answered
"no", since a "no" depends on them just the same -- and a known file
is kept only while its own digest and every dependency's still match;
otherwise it is parsed and analyzed again. A file read mid-chain is
parsed once into `pending`, and being parsed this iteration it can
never serve a stale answer.

Analyzing is one thing, not a "processing" step and an "analysis"
step: `_analyze_file` carries one `Analysis` -- immutable, like
`Files` -- through every class and method in a file, resolving what
each class services, analyzing each method with `_analyze_method`,
and recording each method under its servicer with the method reset
for the next. The dependencies the analysis accumulates while
resolving names are the file's, harvested when the file finishes
into `Files`. Resolution reads other files through the `Analysis`
too; a chain of imports is followed by asking each followed file's
own imports, so whose imports a name resolves against is always the
file that bound it.

A chain is never followed into generated code by reading it: the
`_rbt` module name alone says what a name from one is.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPEVMhyDRxZEuH8eiykv99
A top-level `Alias = Name` binds the alias to whatever the name was
imported as, in written order, so an alias of an alias resolves too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPEVMhyDRxZEuH8eiykv99
`import shop.v1.shop_rbt as rbt` binds `rbt` to the module, and
`rbt.Shop` reaches the state type through it -- as does
`import a.b`, which binds `a`, a component at a time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPEVMhyDRxZEuH8eiykv99
`from shop.v1 import shop_rbt` imports a module as easily as a name,
so a dotted name whose head was imported that way is tried as a
module of where it came from: `shop_rbt.Shop` is
`shop.v1.shop_rbt`'s `Shop`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPEVMhyDRxZEuH8eiykv99
A relative import is resolved to a path where it is collected, since
that is the one place the importing file's own directory is known;
from there it is followed like any other module, spelled as a path,
with `os.sep` telling the two spellings apart. A name from a
generated module reached relatively is spelled back as dotted from
the root that holds it, so the same state type is named whichever way
it was imported.

An iteration follows them to their files too, so a servicer reachable
only through one is found -- with files deduped by their absolute path,
since a file can now be reached under two spellings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPEVMhyDRxZEuH8eiykv99
A name bound by no import may be any star-imported module's. The last
star import wins in Python, so they are searched last to first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPEVMhyDRxZEuH8eiykv99
The std library offers its state types this way: `from
reboot.std.collections.v1.sorted_map import SortedMap` is a plain
module re-exporting from generated code. A name now resolves through
the directories on `sys.path` as well -- the developer's own, since
the dashboard runs in their environment. Installed files are read for
what their names refer to and nothing else: never analyzed, never
watched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPEVMhyDRxZEuH8eiykv99
`_read` and `_try_find_file_of` make OS calls, and they were made
with the event loop held, so a slow disk stalled every dashboard
request for as long as the disk took. Both now go through `aiofiles`,
which runs the call in a thread -- the way file operations are done
everywhere else in the repo -- and everything between `files()` and
the two of them becomes `async` to carry the `await` down.

Parsing still holds the interpreter: `ast.parse` is CPU-bound, so no
thread frees the loop from it, and `cooperatively` already bounds it
to a file at a time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPEVMhyDRxZEuH8eiykv99
A save landing while an iteration reads produces a torn snapshot:
one file read before the save, another after. The watch is armed
before anything is read, so the save's event is already waiting when
the iteration finishes and the next one begins at once -- where a
file kept against a stale dependency digest fails its check and is
analyzed again. The digests recorded per dependency are what make
the tear detectable.

Installed packages are the exception: they are read but not watched,
so a `pip install` made while the dashboard runs is only noticed on
the next save under the roots. Watching the packages too may be
worth doing someday.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPEVMhyDRxZEuH8eiykv99
@benh
benh force-pushed the caller-static-analysis-2 branch from 6c66384 to 473bad0 Compare August 19, 2026 06:11
benh and others added 6 commits August 19, 2026 07:23
A call on anything that evaluates to a reference is a Reboot call.
The reference says which state type is called, the attribute names
the method, and the context handed as the first argument is what
makes it a call, so a call without the context is unsupported
rather than recorded. A call is a `ServicerInfo.Method.Call`,
recorded on the analysis in the order met and folded into the
method's entry under its servicer. A statement that is just an
expression is now evaluated too, since that is how most calls are
written.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPEVMhyDRxZEuH8eiykv99
`idempotently`, `per_workflow`, `per_iteration`, `always`,
`reactively` and `until` each return something the method can still
be called on, so a chain passes through them and what comes after
one of them names the method. Without this,
`ref.idempotently('x').deposit(context)` would record a call to a
method named `idempotently`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPEVMhyDRxZEuH8eiykv99
`.schedule(when=...)` runs the method later and `.spawn()` runs it
as a task, so a chain remembers meeting one of them and the call at
its end is recorded as reached that way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPEVMhyDRxZEuH8eiykv99
A method called on the state type itself, handed the context, is a
constructor: it makes the state and returns a reference to it with
the response. The call is recorded as such, and unpacking the pair,
as in `shop, _ = await Shop.open(context, 'a')`, binds the first
name to a reference to the state made, so calls through it resolve.
A state type reached some other way is still unsupported, since
whatever the code does with it is not followed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPEVMhyDRxZEuH8eiykv99
`Account.forall(ids)` names many existing states the way `ref`
names one, so what it evaluates to is a reference and a call
through it is recorded like any other.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPEVMhyDRxZEuH8eiykv99
`.read(context)` and `.write(context, ...)` on a reference reach the
state directly and name no method, so each is recorded as what it
is, with an empty method name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPEVMhyDRxZEuH8eiykv99
@benh
benh force-pushed the caller-static-analysis-2 branch from c0e5eb4 to 09aaad9 Compare August 19, 2026 07:26
benh and others added 3 commits August 19, 2026 07:30
The method's proto entry gains `unsupported`, so whoever reads the
calls can be told they may be incomplete instead of being left to
trust them. With that, everything a method's analysis finds now has
a home under its servicer, and the calls are proven to reach it all
the way through `analyze`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPEVMhyDRxZEuH8eiykv99
A call that hands the context to a function defined in the same
file is followed into it. The helper's body is analyzed with the
receiving parameter bound to the context and with its names kept
apart from the caller's, so the calls the helper makes are recorded
for the calling method. A helper is followed at most once for each
method, which is what stops a helper that reaches itself and keeps
its calls from being recorded more than once. A helper the file
does not define, or defines more than once, is still unsupported.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPEVMhyDRxZEuH8eiykv99
A helper in the same file is now followed even when it is not
handed the context, since what it returns may be a reference or the
context, and a name assigned the result is bound to it. A helper
returning a tuple is unpacked element by element, the way a
constructor's pair already was, and returns that disagree bind
nothing. A tuple expression itself now evaluates the same way, so a
reference in a tuple that nothing unpacks is still unsupported.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPEVMhyDRxZEuH8eiykv99
@benh
benh force-pushed the caller-static-analysis-2 branch from 2b42e72 to 3bf7daa Compare August 19, 2026 07:41
A call that hands the context first, on a receiver the analysis
cannot identify, is recorded as ambiguous. The method name is kept
and the state type is left empty, so the dashboard can say that a
method named `restock` is called on something, rather than saying
nothing at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPEVMhyDRxZEuH8eiykv99
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants