Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 52 additions & 98 deletions docs/operator-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,9 +204,9 @@ opens the database and reaches the venue.

If the deployment runs on a schedule (LaunchAgents, cron), a new build takes effect on the next
cycle with nothing to restart — each cycle is a fresh process. A **long-running** process is the
exception: a `keel tui` left open keeps the build it started with until you quit and relaunch it.
exception: a `keel serve` left running keeps the build it started with until you stop and restart it.

### Self-update: `keel update` and the console's update view
### Self-update: `keel update`

The four commands above — plus the per-database `keel migrate --db` step the updater also
runs (the four commands don't include it; it runs for every `keel*.db` with the new build,
Expand Down Expand Up @@ -272,18 +272,16 @@ run: the wheels would land in a venv that is not this deployment's), or an insta
is not the wheels. From a checkout, this section's four commands by hand
remain the procedure.

**The relaunch split.** On a verified success the **TUI relaunches itself** — it replaces its
own process with the new build's `keel` entry (`os.execv`, the terminal restored first, the
original invocation's arguments carried over VERBATIM after argv[0] — wrappers that exec
`keel --config X --db Y tui` relaunch with exactly those flags in that order; only a wrapper
invoked with no arguments at all relaunches as plain `keel tui`), because a console left
running would keep the replaced
binary it started with. A relaunch that cannot exec at all renders the manual `keel tui` start
and holds it for an Enter retry that re-installs nothing. The **CLI prints the command instead**
and does NOT relaunch anything:
`keel update` ends by telling you to run `keel tui` (or your deployment wrapper). A wrapper
invoked directly (`./keel-live tui`) relaunches through the venv's `keel` entry with the same
flags.
**Nothing relaunches itself, and there used to be one thing that did.** On a verified success
`keel update` prints what to restart and stops there. Until #541 the TUI replaced its own process
with the new build's `keel` entry (`os.execv`, the terminal restored first), because a curses
front-end left running keeps the build it started with and there was no other way to pick up a new
one without the operator noticing. That code went with the dashboard, and its fallback had already
become wrong: with no arguments to carry it rebuilt `keel tui`, a command that no longer exists.

A long-running `keel serve` has the same property -- it keeps the build it started with -- and
needs no execv to fix it: stop it and start it again, and the browser tab reconnects to whatever is
listening.

**The manual fallback is unchanged.** The four commands at the top of this section still work
and remain the documented procedure when uv is absent, the API is rate-limiting, or you simply
Expand Down Expand Up @@ -324,8 +322,7 @@ database — do not delete them while keel is running, and prefer `keel update`'
SQLite's own online-backup API) over copying the `.db` file by hand. Conversion happens on the
next connection and needs nothing from you.

**Which one am I looking at.** On any dashboard (`keel status`, `keel insights`, `keel tui`,
`keel serve`) the
**Which one am I looking at.** On any dashboard (`keel status`, `keel insights`, `keel serve`) the
`equity_state_mode` line names the account the equity, high-water mark and drawdown figures
describe, and `paper_cash_usdc` is printed in paper mode only. On the command line it is the
`--config`/`--db` pair — and `--db` is the one that bites, because `keel.db` is its default, so a
Expand All @@ -336,10 +333,9 @@ account. Live commands always carry both:
keel --config config.live-sandbox.yaml --db keel-live.db status
```

**The same view in a browser: `keel serve`.** `keel tui` needs a terminal, and there are two
places it cannot go — Windows, where CPython ships no `curses`, and a macOS app launched from
Finder, which has no controlling terminal at all. `keel serve` renders the same reports over
loopback HTTP instead:
**The same view in a browser: `keel serve`.** This is the only interactive surface keel has; the
curses dashboard it replaced was deleted at #541, for reasons the console section below records.
`keel serve` renders the same reports over loopback HTTP:

```bash
keel --config config.live-sandbox.yaml --db keel-live.db serve
Expand Down Expand Up @@ -684,84 +680,42 @@ Rewards item: account settings no rail can see, re-checked after any account cha
- **Trademark posture** — unchanged and stated where it lives: the README's standing
disclaimer covers Alpaca alongside every other venue, and nothing here duplicates it.

## The TUI console

`keel tui` (or any wrapper, e.g. `./keel-live tui`) opens the **operator console**: the dashboard is
still the landing screen, and `m` opens a menu tree over it — Profile, Trading, Rules, Compliance,
Data, Research, Account, Help — covering every operational read and write the CLI knows (the
setup-only writes are deliberately absent: `rules seed` is bootstrap, and schema migration rides
along every database open rather than being a menu action). The console
is **thin by construction**: each entry dispatches to the same `keel/commands/*` service layer the
CLI commands call, and an architectural test (`tests/commands/test_console_thinness.py`) pins that
the TUI layer contains no business logic — no sizing, screening, gating or reporting math, no
`Decimal` arithmetic beyond display, and no broker construction outside the service seams. If a
feature is missing, the fix lands in the service layer and both front-ends get it.

**Profile switching and the live guard.** The Profile menu lists every deployment as its config+db
**pair** — the same pairs the table above pins — and switching rebinds both halves everywhere, in
one action: every screen, banner and read answers about the new deployment on the next paint.
Selecting **LIVE** asks an explicit y/N at the terminal first; declining keeps the binding exactly
where it was, and no key path can rebind around that confirm (the one guarded entry point is
pinned by test). The switch rebinds the **console only** — a `keel agent` process keeps the pair
its own command line gave it, so pointing the console at live never changes what a running agent
trades. Binding a deployment directly through the CLI's `--config`/`--db` flags remains the
wrappers' documented path.

**The session banner.** Every screen's header names the active deployment (LIVE styled
unmistakably) and the market session state with the venue clock — OPEN/CLOSED with the recorded
next open/close, `24/7` for always-open venues, and **CLOCK UNAVAILABLE** rendered fail-loud when
the recorded clock is absent or stale, exactly as `fetch --check` treats it. The banner reads the
recorded session state; there is no TUI-side calendar.

**The typed contracts: seven of the CLI's own, two the console adds.** Seven actions run the
CLI's own typed prompt in-console, word for word (curses suspends around it so the prompt
renders at the terminal): `resume`, `resume-entries`, `record-flow`, `reset-hwm`,
`withdrawals attest --enabled`, `autonomy on`, and `update` (the self-update run — the same
gate `keel update` demands; see "Self-update" under "Deploying a new version") — each the same
`_require_interactive_confirmation` gate the CLI command runs, demanding a typed `yes` and
failing closed off a TTY. Two more typed prompts are **ceremony the console adds on top of
an ungated CLI action** — deliberately *stricter* than the CLI, not identical to it: asset
`attest` makes you type the **asset code** back (the CLI's `keel assets attest` is not
gated — an attestation only ever admits to a list rail 1 still enforces per-trade), and the
retry flow's `rules promote --force` demands a typed `yes` quoting the CLI's own force
warning (the CLI's `--force` is a bare flag). Both are built on the same shared
typed-confirmation gate as the CLI's six. Every typed prompt **cannot be pre-filled, piped
or bypassed**; a wrong phrase or a decline writes nothing. `kill` is the deliberate
exception: **one key, no confirmation**, its own CLI contract — engaging the halt is the
safe direction — and the console adds no ceremony to it. The whole ceremony map (every
state-mutating console action → typed-phrase / confirm-step / ARMED+Enter /
ungated-by-design, each with its refusal proof) is pinned as a table-driven suite,
`tests/commands/test_console_ceremony.py`, so a newly added mutating action without a
classified ceremony row fails the tests.

**ARMED and blocking surfaces.** The runs that do real work — one agent cycle, one monitor poll,
fetch and its check/repair variants, one simulate — open **ARMED**: nothing runs until Enter,
which is the confirm step. While a run executes the screen freezes (it can take minutes, exactly
like the CLI) and the result is held on screen afterwards. **Ctrl-C exits the console
gracefully, discards held results, and the in-flight run does not complete** — as every frozen
screen states; the interrupt propagates out of the run itself (the loop's failure handlers
catch `Exception` only), which is what restores the terminal cleanly. The one entry that can
place orders, the agent cycle, goes through `agent.run_once` with the CLI's own
order-confirmation gate — there is no TUI-originated order path.

**Venues and help.** The Profile menu's **Venues** entry browses every installed adapter and its
declared capabilities — the same payload `keel brokers list` prints, one service, both
front-ends — with the selected adapter highlighted; no key presence is read or implied, and no
secret is ever shown. `?` on any screen opens that screen's own "what am I looking at" help, and
the Help menu holds the glossary (one source, `docs/glossary.md`, the fiqh terms anchored to
`docs/fiqh-basis.md`), every screen's rows consolidated, and the per-rule-parameter help rendered
from the rule classes themselves.

**Safety design notes.** Re-entering any sub-menu resets its cursor to the top — a remembered
row is a loaded one (leave Trading with the cursor on kill and a replayed Enter would engage the
halt with no ceremony). The Account menu's pnl (the FIFO report over imported transactions, with
an honest empty state until `keel db import` has loaded any) and versions (the deploy check)
entries are read-only views; the branch's **one** write path is the update entry (issue #415) —
an ARMED view whose run demands the CLI's own typed gate, and which on a verified success
relaunches the console on the new build ("Self-update" under "Deploying a new version" is its
procedure). The console runs no loop of its own and schedules
nothing: it is a front-end over the same services, and closing it never stops a deployment's own
scheduled cycles.
## The operator console, in a browser

`keel serve` opens keel's **operator console**: a local web page showing Status, Setup, Activity,
Insights, Rules, Venues and Gates, over the same `keel/commands/*` service layer the CLI commands
call. An architectural test (`tests/commands/test_console_thinness.py`) pins that thinness -- the
front-end renders and dispatches, and every behaviour comes from the services.

```bash
keel --config config.live-sandbox.yaml --db keel-live.db serve
```

It binds loopback and prints a URL carrying a one-time token for that run. The token is never
written to disk, so stopping the server invalidates it.

**`keel tui` was the console until #541, and it is gone.** It needed a terminal, and there were two
places it could not go: Windows, where CPython ships no `curses`, and a macOS app launched from
Finder, which has no controlling terminal at all -- both of them platforms a desktop release
targets. The menu tree it carried (Profile, Trading, Rules, Compliance, Data, Research, Account,
Help) went with it, along with roughly 24,000 lines of code and tests.

**On a headless host, forward the port rather than reaching for a terminal UI:**

```bash
ssh -L 8765:127.0.0.1:8765 your-host
```

The browser at the reading end gets the full interface, encrypted by SSH, and `http://127.0.0.1`
is still a secure context there -- which is what makes the installable app work. `keel status`
covers the rest from a plain shell.

**What the browser cannot do, and that is deliberate.** Every capability-increasing action --
arming autonomy, releasing the kill-switch, clearing a consecutive-loss halt, re-seeding the
drawdown high-water mark, declaring a deposit or withdrawal, attesting withdrawal capability, and
replacing the binary -- is a CLI command behind a typed confirmation at an interactive terminal.
`keel capabilities` lists all seven with the gate covering each. The server implements no verb
that would reach one, so this is a property of the server rather than of what the page draws.

## How much money moves

Expand Down
62 changes: 19 additions & 43 deletions keel/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,24 @@
`tests/test_capabilities.py`, which fails in BOTH directions -- an undeclared gate call site fails,
and a declared entry whose gate has disappeared fails.

**Why one entry per call site and not per action.** `autonomy on` appears twice, because the CLI
and the TUI each gate it in their own front-end, and an auditor asking "can the TUI arm autonomy
without a terminal?" needs to see the TUI's own row. Rows that mirror a CLI action carry
`mirrors`, so the duplication reads as deliberate rather than as an inventory that double-counts.
**One entry per call site, and since #541 there is one call site per action.** `autonomy on` used
to appear twice -- the CLI and the TUI each gated it in their own front-end -- and four of the
eleven rows were a TUI or console form of a CLI action, carrying `mirrors` so the duplication read
as deliberate. #541 deleted the TUI and the console layer that was reachable only from inside it,
and those four rows went with their call sites. `mirrors` stays on the dataclass: it describes a
shape this registry must be able to express the moment a second front-end gates anything again.

**What that leaves is worth stating plainly: every capability increase in this build is a CLI
command run by a person at a terminal.** The browser can perform none of them. That is not a
property of what the client draws -- "a client that hides a button is not a gate" -- it is a
property of the server, which implements no verb that would reach one.

**And one bypass is deliberately absent, as it always was.** `keel rules promote --force` skips
the backtest and every gate check, and it is not here, because this registry is an inventory of
GATE CALL SITES and that flag has none: `keel/commands/rules.py` records the reasoning -- "the
CLI's `--force` is a flag the operator already typed at a terminal". The console's retry flow DID
run a typed gate before calling with `force=True`, which is why a force-promote row existed at
all; it described the console's ceremony, not the CLI's flag, and it went with the console.

**The gate vocabulary is a tuple of one today.** `TTY` is the whole model: a human at a terminal,
evidenced by `sys.stdin.isatty()`, with no env-var or flag seam because any such seam would be
Expand Down Expand Up @@ -161,48 +175,10 @@ def key(self) -> tuple[str, str]:
"other one at once, which is why it is gated identically"
),
),
Capability(
module="keel.commands.tui",
function="_confirm_arm_autonomy",
surface="tui",
invocation="the dashboard's autonomy action",
increases="the same as `keel autonomy on`, reached from the live dashboard",
mirrors=("keel.commands.autonomy", "autonomy_on_gate"),
),
Capability(
module="keel.commands.trading_console",
function="_clis_typed_gate",
surface="console",
invocation="the Trading menu's halt-release actions",
increases=(
"the same halt releases the CLI offers, reached from the console -- the wording and "
"the gate are imported from their one home so the two front-ends cannot drift into "
"two ceremonies for one bypass"
),
mirrors=("keel.cli", "resume"),
),
Capability(
module="keel.commands.compliance_console",
function="clis_typed_withdrawals_gate",
surface="console",
invocation="the Compliance menu's withdrawal attestation",
increases="the same as `keel withdrawals attest --enabled`, reached from the console",
mirrors=("keel.commands.withdrawals", "withdrawals_attest"),
),
Capability(
module="keel.commands.strategy_console",
function="clis_typed_promote_force_gate",
surface="console",
invocation="the Strategy menu's force-promote",
increases=(
"a rule is promoted BYPASSING the backtest and promotion gate -- the console's form "
"of `keel rules promote --force`"
),
mirrors=None,
),
)



def gate_named(name: str) -> Gate:
for gate in GATES:
if gate.name == name:
Expand Down
32 changes: 17 additions & 15 deletions keel/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,6 @@
)
from keel.commands.trading import record_flow as record_declared_flow
from keel.commands.trials import trials_group
from keel.commands.tui import tui_cmd
from keel.commands.update import update_cmd
from keel.commands.versions import versions_cmd
from keel.commands.withdrawals import withdrawals_group
Expand Down Expand Up @@ -1326,20 +1325,23 @@ def simulate(
cli.add_command(doctor_cmd)


# -- tui (live, full-screen operator dashboard, with a help menu and a few gated actions) --------

# `keel status` was built as the substrate for this: `tui_cmd` is a curses view over the same
# `gather_status` report, defined in `keel.commands.tui` and registered here.
cli.add_command(tui_cmd)


# -- serve (the same read surface in a browser, for machines and users a TUI cannot reach) -------

# `curses` does not exist in CPython on Windows and a macOS app launched from Finder has no
# controlling terminal at all, so the TUI is unreachable on both of the platforms a desktop
# release targets. `keel serve` renders the same reports over loopback HTTP instead: read-only by
# construction (GET and HEAD are the only verbs it implements), and pinned by the same thinness
# test as the console layer. Defined in `keel.commands.serve` over `keel.web`.
# -- serve (the interactive surface -- there is one, and it is a browser) ------------------------

# **`keel tui` was here, and #541 deleted it.** `curses` does not exist in CPython on Windows and
# a macOS app launched from Finder has no controlling terminal at all, so the TUI was unreachable
# on both of the platforms a desktop release targets -- and `windows-curses`, which papered over
# the first, is unmaintained. The console layer went with it: it was reachable only from inside
# the dashboard, so keeping it would have shipped fourteen thousand lines nothing could open.
#
# `keel serve` renders the same reports over loopback HTTP: `keel status` was built as the
# substrate for both, and `gather_status` is still the one report behind them. Defined in
# `keel.commands.serve` over `keel.web`.
#
# What did NOT move is every capability-increasing action. All seven that remain are CLI commands
# behind `_require_interactive_confirmation`'s typed gate, and the browser can perform none of
# them -- see `keel/capabilities.py`, which shrank from eleven surfaces to seven when the
# dashboard's autonomy action and the console's three gated actions went. Each of those four
# mirrored a CLI command that is still here.
cli.add_command(serve_cmd)


Expand Down
Loading
Loading