feat(setup): market data as a background job, so the last mechanical step can be a button - #467
Merged
Merged
Conversation
…step can be a button
This was the one step left in `NOT_AUTOMATED_YET`, with the reason recorded there: "a request that
blocks that long is not a button, it is a background job with progress and cancellation -- so it
stays `keel fetch` until there is somewhere for such a job to live". This builds that somewhere.
`keel/commands/jobs.py` is deliberately the smallest thing that works: EXACTLY ONE SLOT. Not a
queue, not a pool. Two concurrent fetches write candles to one SQLite database and race each
other; a setup flow has no use for concurrency; and "is something running?" with one answer is a
question a page can render honestly. A second start is REFUSED and says so, rather than being
silently dropped or silently queued -- which look identical to a user watching a page that is not
changing.
A FAILED JOB STAYS VISIBLE. Not cleared on read, not cleared by time. The entire point of running
something in the background is that nobody was watching when it broke, so the failure has to still
be there when they look; it is replaced only when the next job starts. The error recorded is the
exception's type and message, never a traceback -- a traceback in a browser page is a stack of
file paths from someone else's machine.
The progress is a bounded tail of the same lines the CLI prints. `run_fetch` needed no adapting at
all: its `echo` parameter is already documented as the progress stream, and its `build_client` is
already a lazy factory, so nothing constructs a broker until the fetch needs one. Blank separator
lines are dropped -- in a bounded tail they would push real output out of view.
The page auto-refreshes ONLY while something is running. A finished page that kept reloading would
fight a reader; a running one that did not would be a progress display that never progresses. The
zero-JS meta refresh remains the only mechanism available to a page that ships no scripts. And the
step's button disappears while its job runs: offering it again would invite a second start that
the slot refuses anyway, which reads as the page ignoring the click.
VERIFIED END TO END against a live server, on the realistic first-run mistake -- starting a fetch
with no credentials configured:
POST /setup/market_data -> 303, immediately
job panel -> market_data · failed · AuthenticationError: Unauthenticated request to private
endpoint. If you wish to access private endpoints, you must provide your API key
and secret ... MISSING BTC-USD ONE_DAY
refresh -> present while running, absent once finished
That is the venue's own error, in front of the operator, with the progress that preceded it -- and
the checklist directly above it already names the credential step.
MYPY CAUGHT A BUG NO TEST WOULD HAVE. The fetch closure imported `keel.strategy.freshness`; the
module is `keel.data.freshness`. Every import in that closure is lazy and runs only on the
background thread, so the suite was green and the job would have died with a ModuleNotFoundError
the first time anyone pressed the button.
4083 passed, 3 skipped (12 new). ruff clean repo-wide; mypy clean over keel + packages.
`NOT_AUTOMATED_YET` is now empty, which is a fine value for it -- and it stays, because the next
step someone decides not to automate should have somewhere to say why.
Refs #437, #18.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
eaitbrahim
added a commit
that referenced
this pull request
Aug 20, 2026
…rned out to be the right one
Two JUDGEMENT steps live in the PAPER stage -- attesting an asset, promoting a rule -- so the rule
I wrote two changes ago ("no judgement step may ever be an action") did not make anything safer.
It made a terminal-free paper deployment impossible, which is the entire point of the milestone.
This fixes the rule, not the goal.
THE INVARIANT MOVES TO WHERE IT ALWAYS BELONGED: the capability registry. Not one of the eleven
actions in `keel/capabilities.py` may be reachable from `keel/web/`, and that was already scanned
and pinned independently. `StepKind` was a poor proxy for it, tried twice -- "MECHANICAL only",
then "MECHANICAL or OPERATOR_INPUT" -- and wrong both times. The test now asserts by IDENTITY:
every action's step is checked against the eleven invocations, and `withdrawals attest --enabled`
is named as the judgement step that consequently has no form. That is the line the code already
drew; taste had nothing to do with it.
The step-kind rule becomes the secondary policy it should always have been: **a wizard may record
what the operator supplies; it may never supply it.** An action over a judgement step must declare
inputs, every one required, with nothing pre-filled and no select opening on a valid option.
THAT LAST CLAUSE IS NOT PEDANTRY. `pays_yield` as a checkbox would open unticked, and unticked is
`no` -- the PERMISSIVE answer, since a yield-bearing asset fails the screen (KB §28.4). A form
whose default answer is the compliant one attests on the operator's behalf. So it is a select that
opens on a disabled "choose…" and is `required`: it cannot be submitted without someone answering.
`backing` is the same shape.
Exposing an attestation is safe for a reason its own CLI command states: "an attestation cannot
itself place an order or raise a cap, and the screen it feeds only ever ADMITS to a list that
`guards.py` rail 1 still enforces per-trade". It is not one of the eleven and the CLI imposes no
ceremony on it beyond the mandatory source.
PROMOTION HAS NO FORCE FIELD, and that is load-bearing rather than an omission.
`attempt_promotion`'s docstring is explicit: force "carries no gate HERE ... the O3 contract is the
front-end's to keep, never the service's to assume." The console keeps that contract with a typed
terminal confirmation. A browser cannot, so this front-end simply has no force path -- and
force-promote is itself one of the eleven. `force=False` is hard-wired and pinned by test.
Promotion re-runs a backtest, so it runs as a job for the same reason a fetch does.
The backing choices are literals to keep `keel.compliance.screen` off the CLI's import path; that
duplication is pinned against `KNOWN_BACKINGS`, because a backing kind added there and missing
here would be silently un-attestable from the browser.
VERIFIED END TO END against a live server: three assets attested from the browser and recorded
with exactly the supplied sector, backing, yield answer, source and attester; a submission with a
blank source recorded NOTHING; the checklist then names the assets still outstanding; and
`withdrawals attest --enabled` still shows its CLI command rather than a form.
4087 passed, 3 skipped (5 new). ruff clean repo-wide; mypy clean over keel + packages.
Stacked on #467 -- it builds directly on that PR's `Action`/`ActionInput` and job runner.
Refs #437, #436, #18.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
eaitbrahim
added a commit
that referenced
this pull request
Aug 20, 2026
…rned out to be the right one (#469) Two JUDGEMENT steps live in the PAPER stage -- attesting an asset, promoting a rule -- so the rule I wrote two changes ago ("no judgement step may ever be an action") did not make anything safer. It made a terminal-free paper deployment impossible, which is the entire point of the milestone. This fixes the rule, not the goal. THE INVARIANT MOVES TO WHERE IT ALWAYS BELONGED: the capability registry. Not one of the eleven actions in `keel/capabilities.py` may be reachable from `keel/web/`, and that was already scanned and pinned independently. `StepKind` was a poor proxy for it, tried twice -- "MECHANICAL only", then "MECHANICAL or OPERATOR_INPUT" -- and wrong both times. The test now asserts by IDENTITY: every action's step is checked against the eleven invocations, and `withdrawals attest --enabled` is named as the judgement step that consequently has no form. That is the line the code already drew; taste had nothing to do with it. The step-kind rule becomes the secondary policy it should always have been: **a wizard may record what the operator supplies; it may never supply it.** An action over a judgement step must declare inputs, every one required, with nothing pre-filled and no select opening on a valid option. THAT LAST CLAUSE IS NOT PEDANTRY. `pays_yield` as a checkbox would open unticked, and unticked is `no` -- the PERMISSIVE answer, since a yield-bearing asset fails the screen (KB §28.4). A form whose default answer is the compliant one attests on the operator's behalf. So it is a select that opens on a disabled "choose…" and is `required`: it cannot be submitted without someone answering. `backing` is the same shape. Exposing an attestation is safe for a reason its own CLI command states: "an attestation cannot itself place an order or raise a cap, and the screen it feeds only ever ADMITS to a list that `guards.py` rail 1 still enforces per-trade". It is not one of the eleven and the CLI imposes no ceremony on it beyond the mandatory source. PROMOTION HAS NO FORCE FIELD, and that is load-bearing rather than an omission. `attempt_promotion`'s docstring is explicit: force "carries no gate HERE ... the O3 contract is the front-end's to keep, never the service's to assume." The console keeps that contract with a typed terminal confirmation. A browser cannot, so this front-end simply has no force path -- and force-promote is itself one of the eleven. `force=False` is hard-wired and pinned by test. Promotion re-runs a backtest, so it runs as a job for the same reason a fetch does. The backing choices are literals to keep `keel.compliance.screen` off the CLI's import path; that duplication is pinned against `KNOWN_BACKINGS`, because a backing kind added there and missing here would be silently un-attestable from the browser. VERIFIED END TO END against a live server: three assets attested from the browser and recorded with exactly the supplied sector, backing, yield answer, source and attester; a submission with a blank source recorded NOTHING; the checklist then names the assets still outstanding; and `withdrawals attest --enabled` still shows its CLI command rather than a form. 4087 passed, 3 skipped (5 new). ruff clean repo-wide; mypy clean over keel + packages. Stacked on #467 -- it builds directly on that PR's `Action`/`ActionInput` and job runner. Refs #437, #436, #18. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Refs #437 (D4). Milestone: Desktop distribution (#18).
The last step left in
NOT_AUTOMATED_YET, with the reason recorded there:This builds that somewhere.
NOT_AUTOMATED_YETis now empty — and it stays, because the next step someone decides not to automate should have a place to say why.Exactly one slot, deliberately
Not a queue, not a pool.
A second start is refused and says so, rather than silently dropped or silently queued — both of which look identical to a user watching a page that is not changing.
A failed job stays visible
Not cleared on read, not cleared by time. The entire point of running something in the background is that nobody was watching when it broke, so the failure has to still be there when they look. It is replaced only when the next job starts.
The recorded error is the exception's type and message, never a traceback — a traceback in a browser page is a stack of file paths from someone else's machine.
run_fetchneeded no adaptingIts
echoparameter is already documented as the progress stream and emits the same lines the CLI prints; itsbuild_clientis already a lazy factory, so nothing constructs a broker until the fetch needs one. The job module knows nothing about fetching.Blank separator lines are dropped — in a bounded tail they would push real output out of view.
Refresh only while running
A finished page that kept reloading would fight a reader; a running one that did not would be a progress display that never progresses. The zero-JS meta refresh is still the only mechanism available to a page that ships no scripts.
The step's button also disappears while its job runs: offering it again would invite a second start that the slot refuses anyway, which reads as the page ignoring the click.
Verified end to end, on the realistic first-run mistake
Starting a fetch with no credentials configured:
That is the venue's own error, in front of the operator, with the progress that preceded it — and the checklist directly above it already names the credential step.
mypy caught a bug no test would have
The fetch closure imported
keel.strategy.freshness; the module iskeel.data.freshness. Every import in that closure is lazy and runs only on the background thread, so the suite was green and the job would have died with aModuleNotFoundErrorthe first time anyone pressed the button.Verification
4083 passed, 3 skipped (12 new).
ruff checkclean repo-wide,mypyclean overkeel+packages.Where D4 stands
Every mechanical step of a first run is now reachable from the browser, with no command typed: config, database, rule library, credential, market data. What remains outstanding is by design — the judgement steps (asset attestations, promoting a rule) and the off-venue ones, which #437 says a wizard must present rather than perform.
🤖 Generated with Claude Code