Add a protocol operation¶
The web site, the embeddable elements and our own service all talk to
the Python engine through one protocol of JSON messages: a front end
sends a request such as step or analyse and gets exactly one reply.
This page is for a contributor who needs a new kind of request: it goes
layer by layer from the Python session code to the page that uses the
reply, says which file each layer lives in, which test guards it, and
what a reviewer checks before it is merged. It assumes you know the
session protocol reference and the
development loop.
The worked example throughout is analyse (protocol 1.3): "analyse the
open session's position with the game's own solvers", which the
teaching pages use to draw Minesweeper's mine probabilities and Connect
Four's column values live as a person plays.
The layers¶
| Layer | Where | analyse |
|---|---|---|
| The operation itself, pure Python | src/grounded_decision/session/ |
Analyst.find(game).analyse(session) in session/lessons/__init__.py |
| Request and reply types | session/protocol.py |
AnalyseRequest, AnalysisReply |
| Dispatch | Endpoint in session/protocol.py |
a branch of Endpoint._answer |
| Schema, fixtures, TypeScript types | generated by make protocol |
protocol.schema.json, fixtures/analyse.json, generated/types.ts |
| Version | PROTOCOL_VERSION in session/protocol.py |
1.3.0 |
| Client | web/packages/engine/src/engine.ts |
Engine.analyse() |
| Our own service | src/grounded_decision/service/ |
nothing (section 7) |
| Model and page | web/packages/models, web/apps/site |
LiveAnalysis.svelte |
1. Write the operation as plain Python¶
Put the work in a class of the session layer, beside what it uses
(Session, AuditSession in session/auditing.py, ComparisonSession
in session/tournament.py, Demos in session/demos.py), not in the
protocol module. It takes and returns frozen dataclasses, and it is
pure: the session layer may not read a clock, start a thread, open a
file or a socket, or catch a signal, because the same code runs inside
Pyodide in a Web Worker. tests/test_session_protocol.py
(test_session_modules_import_no_io_threads_signals_or_clock and
test_session_runs_with_signals_and_subprocesses_blocked_and_no_clock)
fails if it does.
analyse is Analyst: it finds the game's analyse(game, state) in
session/lessons/GAME.py and runs it on the session's current state,
returning an Analysis (cell values, tags, routes, facts) or None
for a game or position without one.
Test it directly first, without the protocol
(tests/test_session_lessons.py checks every game's analysis this way).
2. Declare the request and the reply¶
In session/protocol.py, a request and a reply are frozen, slotted
dataclasses with a literal type and an optional id that the reply
repeats:
@dataclass(frozen=True, slots=True)
class AnalyseRequest:
"""Analyse the open session's position with the game's own solvers (protocol 1.3).
Attributes:
type: ``analyse``.
id: Repeated in the reply.
"""
type: Literal["analyse"] = "analyse"
id: int | None = None
@dataclass(frozen=True, slots=True)
class AnalysisReply:
"""The open session's position, analysed (protocol 1.3).
Attributes:
type: ``analysis``.
id: The request's id, or null.
index: The frame analysed (moves played to reach it).
analysis: The analysis, or null where the game has none for this
position.
"""
id: int | None
index: int
analysis: Analysis | None
type: Literal["analysis"] = "analysis"
The rules the wire (session/wire.py) enforces when the schema is
built:
- Field types are only
str,int,float,bool,None, unions of those withNone,Literalstrings, string enumerations,tuple[X, ...],Mapping[str, X]and other frozen dataclasses. Anything else fails when the schema is generated (test_unsupported_types_fail_loudly,test_every_message_type_has_a_schema). - Every field is documented in the class's
Attributes:section: the text becomes the field's description in the JSON Schema and the TypeScript types. - A field without a default is required. Give every new field of an existing type a default, or the change is breaking (section 5).
- A request whose size is chosen by the sender gets a bound, checked in
__post_init__with aValueError(which becomes amessageerror):StepRequest.countis at mostMAX_STEPS_PER_REQUEST,CompareRequest.gamesat mostMAX_GAMES_PER_REQUEST,AuditRequest.movesat mostMAX_MOVES_PER_REQUEST.
Then register both types: add the request to the Request union and
REQUESTS, the reply to the Reply union and REPLIES (keep
ErrorReply last), and the request's type to Endpoint.TYPES.
3. Answer it in the Endpoint¶
Endpoint.handle decodes a message into its request type, calls
_answer, and always returns a reply: exceptions become an
ErrorReply with a code, so a transport only ever moves JSON.
| Raised | Error code | Meaning for the front end |
|---|---|---|
WireError (and ValueError while decoding) |
message |
the message is malformed; do not retry it |
PlayError, DemoError |
spec |
the spec names something the game does not offer |
SessionError |
session |
not now (no session open, a person must move, the game is over) |
| anything else | internal |
a bug; the reply names the exception |
analyse needs an open session, so its branch comes after the
session is None check (which raises SessionError):
if isinstance(request, AnalyseRequest):
analyst = Analyst.find(session.spec.game)
analysis = None if analyst is None else analyst.analyse(session)
return AnalysisReply(request.id, session.frame.index, analysis)
An operation that keeps state between requests (a comparison, an
audit) holds it as a field of Endpoint, opened again when the spec
changes (Endpoint._compare, Endpoint._audit).
Tests in tests/test_session_protocol.py or the module's own test file
(tests/test_session_compare_audit.py::test_analyse_needs_a_session_and_follows_it):
the reply's type and id, every error code the operation can produce, and
that it follows the session after more moves.
4. Regenerate the files¶
python -m grounded_decision.session (session/artefacts.py) writes
the JSON Schema twice (in the package and in
web/packages/protocol/generated/), the design tokens and the golden
fixtures; with make web-setup done it then writes
web/packages/protocol/src/generated/types.ts from the schema. Never
edit those files by hand. Add a scripted conversation that uses the new
request to CONVERSATIONS in session/artefacts.py, as analyse has:
(
Conversation(
"analyse",
"A position analysed (protocol 1.3): Minesweeper's mine probabilities, "
"proved-safe and proved-mine cells, after a person's first click.",
(
_open("minesweeper", agents=["you"], seed=6),
_submit("reveal E5", 2),
{"type": "analyse", "id": 3},
),
),
)
and add the fixture's file name to the list in
web/packages/protocol/src/validator.test.ts, which validates every
request and reply of every fixture strictly with the TypeScript
validator. tests/test_session_protocol.py::test_generated_file_is_current
and test_no_stale_fixtures fail while a generated file is out of date;
Vitest's codegen.test.ts does the same for types.ts.
5. Bump the protocol version¶
PROTOCOL_VERSION is the protocol's own semantic version.
src/grounded_decision/session/protocol.baseline.schema.json records the
schema of the last released version, and
test_the_schema_is_compatible_with_the_released_baseline sorts every
difference (SchemaChanges in session/compatibility.py):
| Change | Kind | Bump |
|---|---|---|
| A new message, type, optional field or enumeration value | additive | minor |
| A new required field in a reply | additive | minor |
| A new required field in a request; removing anything; changing a field's type | breaking | major |
| Descriptions only | none | none |
A new operation is a minor version (1.3.0 added compare, audit and
analyse). Say which version added it in the request's docstring and
in the request table of the protocol reference.
At a protocol release, record the new baseline:
6. The client: the engine package¶
The browser runs the same Endpoint in Pyodide: EngineHost
(web/packages/engine/src/host.ts) passes JSON text to
Endpoint().handle_text and back, so the worker needs no change. Add
the request to the RequestBody union in
web/packages/engine/src/engine.ts and a method to Engine that names
the reply type it expects:
/** The open session's position, analysed by the game's own solvers (protocol 1.3). */
analyse(): Promise<AnalysisReply> {
return this.request({ type: 'analyse' }, 'analysis');
}
Engine.request assigns the id, validates the reply against the schema
and turns an error reply into an EngineError. The WebSocket
transport (socket.ts, our own service) carries the same text, so it
needs no change either. Test the method in
web/packages/engine/src/engine.test.ts against a fake transport.
7. Our own service: usually nothing¶
grounded-decision serve answers every protocol message with the
package's own Endpoint (service/connection.py, ProtocolConnection),
one per WebSocket and one per POST /api/ request, so a new operation
is served as soon as the package has it. Two things may need a line:
- Work limits. If the request lets a sender ask for an amount of
work (
count,moves,games,seeds), the service caps it below the protocol's own maximum: add a setting toServiceLimits(service/config.py, with its range and itsSettingrow, so it has a flag and aGD_SERVICE_*variable) and a branch toWorkLimits.check(service/limits.py), which refuses with the protocol's own error before the engine runs.analyseasks for a fixed amount of work and needs none. - Time. Every request already runs under
request_timeout; an operation that may run long should take a bounded amount of work per request instead, asauditandcomparedo.
tests/test_service.py checks that the service's replies equal the
Endpoint's (test_websocket_replies_equal_the_endpoints); add a case
to test_too_much_work_is_refused_before_the_engine_runs for a new
limit.
8. The model and the page¶
A page never builds requests itself: a DOM-free class in
web/packages/models (or the component that owns the live engine)
calls the Engine method and turns the reply into what the page draws.
LiveAnalysis.svelte (web/apps/site/src/lib/components/) calls
engine().analyse() after every move and hands the Analysis to
AnalysisLayer.svelte; Add a web page covers the
rest. An end-to-end test in web/tests/e2e/ drives the page against
the built site; the teaching pages that use analyse are opened, checked
for errors and run through axe in phase5.spec.ts.
Definition of done¶
- [ ] The operation is a pure method of a session-layer class, tested without the protocol.
- [ ] Request and reply dataclasses, documented field by field, bounded where the sender picks the work; added to
Request/REQUESTS,Reply/REPLIESandEndpoint.TYPES. - [ ]
Endpoint._answerbranch; every error code it can produce is tested. - [ ] A conversation in
CONVERSATIONS,make protocolrun, the fixture listed invalidator.test.ts. - [ ]
PROTOCOL_VERSIONbumped (minor for an addition) and the request table indocs/reference/protocol.mdupdated. - [ ]
Enginemethod andRequestBodyentry, with a unit test. - [ ] A
WorkLimitsentry and setting if the sender chooses the amount of work. - [ ] The model or component that uses it, and an end-to-end test.
- [ ] CHANGELOG entry;
make check WEB=1andmake web-e2egreen.