nervur 0.23.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTING.md +13 -0
- package/LICENSE +55 -202
- package/NOTICE +3 -8
- package/README.md +14 -156
- package/RELEASING.md +15 -0
- package/SECURITY.md +9 -0
- package/bin/nervur.js +6 -0
- package/package.json +26 -81
- package/AUTHORING.md +0 -935
- package/COMMAND.md +0 -179
- package/FACES.md +0 -280
- package/FACULTIES.md +0 -578
- package/GROUNDS.md +0 -315
- package/KIT-SPEC.md +0 -304
- package/WORLD.md +0 -195
- package/dist/app/app-ground.d.ts +0 -18
- package/dist/app/app-ground.js +0 -23
- package/dist/app/index.d.ts +0 -2
- package/dist/app/index.js +0 -5
- package/dist/app/native.d.ts +0 -11
- package/dist/app/native.js +0 -13
- package/dist/being/being.d.ts +0 -306
- package/dist/being/being.js +0 -20
- package/dist/being/covers.d.ts +0 -9
- package/dist/being/covers.js +0 -21
- package/dist/being/index.d.ts +0 -4
- package/dist/being/index.js +0 -6
- package/dist/being/need.d.ts +0 -49
- package/dist/being/need.js +0 -33
- package/dist/being/schema.d.ts +0 -100
- package/dist/being/schema.js +0 -132
- package/dist/being/table.d.ts +0 -48
- package/dist/being/table.js +0 -344
- package/dist/bench/bench-ground.d.ts +0 -73
- package/dist/bench/bench-ground.js +0 -153
- package/dist/bench/bench.d.ts +0 -101
- package/dist/bench/bench.js +0 -368
- package/dist/bench/fake-clock.d.ts +0 -17
- package/dist/bench/fake-clock.js +0 -34
- package/dist/bench/fake-memory.d.ts +0 -16
- package/dist/bench/fake-memory.js +0 -46
- package/dist/bench/fake-network.d.ts +0 -66
- package/dist/bench/fake-network.js +0 -222
- package/dist/bench/fake-unlock.d.ts +0 -6
- package/dist/bench/fake-unlock.js +0 -10
- package/dist/bench/index.d.ts +0 -3
- package/dist/bench/index.js +0 -5
- package/dist/bench/seeded.d.ts +0 -11
- package/dist/bench/seeded.js +0 -37
- package/dist/bench/settle.d.ts +0 -2
- package/dist/bench/settle.js +0 -49
- package/dist/bench/stewards.d.ts +0 -169
- package/dist/bench/stewards.js +0 -128
- package/dist/bodies/class-list.d.ts +0 -14
- package/dist/bodies/class-list.js +0 -33
- package/dist/bodies/joined-carry.d.ts +0 -29
- package/dist/bodies/joined-carry.js +0 -48
- package/dist/bodies/kept.d.ts +0 -44
- package/dist/bodies/kept.js +0 -91
- package/dist/bodies/noble-crypto.d.ts +0 -22
- package/dist/bodies/noble-crypto.js +0 -88
- package/dist/bodies/seed-keys.d.ts +0 -10
- package/dist/bodies/seed-keys.js +0 -42
- package/dist/bodies/strict-tools.d.ts +0 -10
- package/dist/bodies/strict-tools.js +0 -306
- package/dist/bodies/vouch.d.ts +0 -20
- package/dist/bodies/vouch.js +0 -71
- package/dist/bodies/web-carry.d.ts +0 -72
- package/dist/bodies/web-carry.js +0 -245
- package/dist/bodies/web-clock.d.ts +0 -12
- package/dist/bodies/web-clock.js +0 -33
- package/dist/browser/browser-ground.d.ts +0 -76
- package/dist/browser/browser-ground.js +0 -294
- package/dist/browser/index.d.ts +0 -4
- package/dist/browser/index.js +0 -7
- package/dist/browser/indexeddb-memory.d.ts +0 -17
- package/dist/browser/indexeddb-memory.js +0 -97
- package/dist/browser/locked-unlock.d.ts +0 -24
- package/dist/browser/locked-unlock.js +0 -85
- package/dist/browser/origin-classes.d.ts +0 -7
- package/dist/browser/origin-classes.js +0 -31
- package/dist/edge/durable-clock.d.ts +0 -19
- package/dist/edge/durable-clock.js +0 -61
- package/dist/edge/durable-memory.d.ts +0 -5
- package/dist/edge/durable-memory.js +0 -11
- package/dist/edge/durable.d.ts +0 -18
- package/dist/edge/durable.js +0 -24
- package/dist/edge/edge-ground.d.ts +0 -49
- package/dist/edge/edge-ground.js +0 -223
- package/dist/edge/index.d.ts +0 -7
- package/dist/edge/index.js +0 -9
- package/dist/edge/native-crypto.d.ts +0 -8
- package/dist/edge/native-crypto.js +0 -102
- package/dist/edge/secret-unlock.d.ts +0 -7
- package/dist/edge/secret-unlock.js +0 -13
- package/dist/edge/socket-carry.d.ts +0 -39
- package/dist/edge/socket-carry.js +0 -111
- package/dist/foundation.d.ts +0 -162
- package/dist/foundation.js +0 -4
- package/dist/ground/dock.d.ts +0 -1460
- package/dist/ground/dock.js +0 -1159
- package/dist/ground/ground.d.ts +0 -239
- package/dist/ground/ground.js +0 -787
- package/dist/ground/views.d.ts +0 -76
- package/dist/ground/views.js +0 -208
- package/dist/house/crossing.d.ts +0 -50
- package/dist/house/crossing.js +0 -153
- package/dist/house/house.d.ts +0 -88
- package/dist/house/house.js +0 -2069
- package/dist/house/rows-shape.d.ts +0 -110
- package/dist/house/rows-shape.js +0 -12
- package/dist/house/rows.d.ts +0 -27
- package/dist/house/rows.js +0 -185
- package/dist/index.d.ts +0 -33
- package/dist/index.js +0 -29
- package/dist/node/bridge.d.ts +0 -20
- package/dist/node/bridge.js +0 -284
- package/dist/node/cli.d.ts +0 -2
- package/dist/node/cli.js +0 -225
- package/dist/node/file-memory.d.ts +0 -13
- package/dist/node/file-memory.js +0 -86
- package/dist/node/folder-classes.d.ts +0 -5
- package/dist/node/folder-classes.js +0 -40
- package/dist/node/gone.d.ts +0 -2
- package/dist/node/gone.js +0 -7
- package/dist/node/hand.d.ts +0 -39
- package/dist/node/hand.js +0 -107
- package/dist/node/http.d.ts +0 -11
- package/dist/node/http.js +0 -90
- package/dist/node/index.d.ts +0 -11
- package/dist/node/index.js +0 -13
- package/dist/node/ledger-memory.d.ts +0 -17
- package/dist/node/ledger-memory.js +0 -152
- package/dist/node/lock.d.ts +0 -10
- package/dist/node/lock.js +0 -78
- package/dist/node/node-ground.d.ts +0 -26
- package/dist/node/node-ground.js +0 -228
- package/dist/node/notify.d.ts +0 -1
- package/dist/node/notify.js +0 -18
- package/dist/node/shell.d.ts +0 -11
- package/dist/node/shell.js +0 -35
- package/dist/node/sync-folder.d.ts +0 -6
- package/dist/node/sync-folder.js +0 -18
- package/dist/node/tcp-carry.d.ts +0 -42
- package/dist/node/tcp-carry.js +0 -193
- package/dist/node/unlock.d.ts +0 -17
- package/dist/node/unlock.js +0 -86
- package/dist/node/websocket.d.ts +0 -12
- package/dist/node/websocket.js +0 -134
- package/dist/quo/frame.d.ts +0 -33
- package/dist/quo/frame.js +0 -92
- package/dist/quo/index.d.ts +0 -1
- package/dist/quo/index.js +0 -5
- package/dist/quo/room.d.ts +0 -154
- package/dist/quo/room.js +0 -362
- package/dist/serve/index.d.ts +0 -45
- package/dist/serve/index.js +0 -71
package/FACULTIES.md
DELETED
|
@@ -1,578 +0,0 @@
|
|
|
1
|
-
# Writing a faculty
|
|
2
|
-
|
|
3
|
-
A faculty is anything a being calls that is not a being: a payment
|
|
4
|
-
provider, a mail sender, a model, a sensor, a relay on a Pi. This guide
|
|
5
|
-
teaches the craft whole. [Writing for nervur](AUTHORING.md) teaches beings and
|
|
6
|
-
grounds, and the words both guides use.
|
|
7
|
-
|
|
8
|
-
The example is a garage door. A relay on a Raspberry Pi pulses once to
|
|
9
|
-
toggle the door, so a pulse sent twice opens the door and closes it
|
|
10
|
-
again. The relay is a page of Python. The ground brings the rest: one
|
|
11
|
-
call id across a crash and a lost reply, one twin granted the relay, and
|
|
12
|
-
a phone that reaches the twin from anywhere. Every file here is a file
|
|
13
|
-
the package's own tests run.
|
|
14
|
-
|
|
15
|
-
## What a faculty owes
|
|
16
|
-
|
|
17
|
-
A faculty answers a blueprint: a name, and methods with their schemas.
|
|
18
|
-
A registry holds it by name as `{ takes?, up, install? }`. The ground
|
|
19
|
-
raises it by its `up` into a body, and hands the body to its houses as
|
|
20
|
-
an offer, with the kinds its entry grants.
|
|
21
|
-
|
|
22
|
-
```text
|
|
23
|
-
faculty = { takes?, install?, up }
|
|
24
|
-
takes = { args?: schema, secrets?: { [name]: what it holds } }
|
|
25
|
-
body = { blueprint?, object?, window?, handler?, registry?, down? }
|
|
26
|
-
offer = { blueprint, object, kinds?, window? }
|
|
27
|
-
```
|
|
28
|
-
|
|
29
|
-
- **It declares what it takes.** `takes.args` is a schema built with
|
|
30
|
-
`s` from `nervur/being`, which its entry's args meet. `takes.secrets`
|
|
31
|
-
names each secret its entry must name, with a line saying what it
|
|
32
|
-
holds. The hand refuses an entry that fails either, before it lands,
|
|
33
|
-
and `facultiesCatalog` shows it to whoever writes the entry.
|
|
34
|
-
- **The object has the blueprint's methods.** Each takes one args object
|
|
35
|
-
and a context, and answers `{ result }` or `{ error: { message } }`.
|
|
36
|
-
- **An error is final, and a throw is not.** An error reaches the being
|
|
37
|
-
as the answer. A throw, a crash or silence is a failure to answer, and
|
|
38
|
-
the house asks again.
|
|
39
|
-
- **The context holds the call id**, and `call({ token, args, id })`,
|
|
40
|
-
which calls a handle the faculty was handed.
|
|
41
|
-
|
|
42
|
-
The house checks the args against the offer's schema at each call, and
|
|
43
|
-
the answer against the being's need when it arrives. A being sees only
|
|
44
|
-
the methods her need names, and nothing else of the object.
|
|
45
|
-
|
|
46
|
-
### It acts once for each call id
|
|
47
|
-
|
|
48
|
-
A method marked `idempotent` is safe to repeat, so a being awaits it
|
|
49
|
-
during her ask. Every other method is an effect. The house writes an
|
|
50
|
-
effect in the same write as her cells, then sends it until it is
|
|
51
|
-
answered, with the same call id every time.
|
|
52
|
-
|
|
53
|
-
So one call arrives more than once. A reply lost on its way back brings
|
|
54
|
-
it again, and so does a program that died before it answered. A faculty
|
|
55
|
-
that changes the world keeps each call id with the answer it gave, in
|
|
56
|
-
the memory its `up` receives, where a restart and a move keep it. It
|
|
57
|
-
answers a call id it has seen with that answer, and acts once.
|
|
58
|
-
|
|
59
|
-
It keeps them for its `window`, seven days where the offer names none.
|
|
60
|
-
The house gives up on an effect at the window and tells the being, so a
|
|
61
|
-
retry never outlives the faculty's memory.
|
|
62
|
-
|
|
63
|
-
### It calls back by token
|
|
64
|
-
|
|
65
|
-
A being may hand a faculty a handle to one of her asks, under
|
|
66
|
-
`s.handle` in the args. It arrives as a token, a string of at least 128
|
|
67
|
-
random bits that answers this faculty alone. The faculty calls it with
|
|
68
|
-
`context.call({ token, args, id })` whenever the world moves. Its own
|
|
69
|
-
`id` makes that call run once, however often it is sent.
|
|
70
|
-
|
|
71
|
-
A handle may reach a whole occupant rather than one ask, as a door a
|
|
72
|
-
steward's `invite` minted does. A face holds such tokens, one for each
|
|
73
|
-
person it serves. `context.call({ token, method, args, id, after, home
|
|
74
|
-
})` asks as that occupant, and `after` makes a `readOnly` ask a watch.
|
|
75
|
-
`home` carries the answer's handles home as invitation bytes, which the
|
|
76
|
-
person's own house takes.
|
|
77
|
-
`context.describe({ token })` reads what that occupant may ask now. The
|
|
78
|
-
being behind the token declares a need the face covers, so the ground's
|
|
79
|
-
grant decides which classes a face reaches.
|
|
80
|
-
|
|
81
|
-
### The ground grants it
|
|
82
|
-
|
|
83
|
-
A faculty is the ground's, never a house's. Its grant has two steps,
|
|
84
|
-
both the ground's. A house receives the faculties its entry names.
|
|
85
|
-
Within it, the faculty's own entry lists in `kinds` the classes that may
|
|
86
|
-
hold the offer, and without it, every class whose need it covers holds
|
|
87
|
-
it.
|
|
88
|
-
|
|
89
|
-
So a raw faculty reaches one class. Limits, approvals and quotas are not
|
|
90
|
-
the faculty's. One being holds the raw faculty, and every other being
|
|
91
|
-
reaches her through a standing, so policy is written as a being.
|
|
92
|
-
|
|
93
|
-
### It lives in the ground
|
|
94
|
-
|
|
95
|
-
The object arrives living. The ground raises each faculty its entries
|
|
96
|
-
name, awaiting its `up`, and the house never starts, stops or restarts
|
|
97
|
-
it. Every being whose need it covers holds the same object. Its `up`
|
|
98
|
-
receives four things beside the faculty's name.
|
|
99
|
-
|
|
100
|
-
- **`args`** are its entry's, as the owner wrote them.
|
|
101
|
-
- **`secrets`** are the secrets its entry names, set through the hand
|
|
102
|
-
and kept sealed in a being of the ground's dock. No other code reads
|
|
103
|
-
them.
|
|
104
|
-
- **`memory`** is the faculty's own, a view of the ground's memory
|
|
105
|
-
sealed under a key the ground derives for it. Its call ids live there,
|
|
106
|
-
so they move with the ground.
|
|
107
|
-
- **`faculties`** holds the object of each body its entry names in
|
|
108
|
-
`faculties`, so one body calls another directly. It goes up after
|
|
109
|
-
each of them.
|
|
110
|
-
|
|
111
|
-
`install` receives the same, and does the slow work once for each entry.
|
|
112
|
-
A program's packages and a repository's checkout are installed there, so
|
|
113
|
-
going up again installs nothing.
|
|
114
|
-
|
|
115
|
-
A body answers four things beside its methods, each where it has one.
|
|
116
|
-
|
|
117
|
-
- **`handler`** answers HTTP on the ground's one listener. It takes a
|
|
118
|
-
`Request` and answers a `Response`, or `null` where the request is not
|
|
119
|
-
its own. A site, an API or an MCP server is a body with a handler.
|
|
120
|
-
- **`down`** lets go of what its `up` opened. The ground calls it when
|
|
121
|
-
the body is updated, restarted or removed, and when the ground stops,
|
|
122
|
-
bodies in the reverse of the order they stood.
|
|
123
|
-
- **`registry`** holds more faculties, for every entry that names this
|
|
124
|
-
body in `from`. A module of code, a git repository and a program's
|
|
125
|
-
catalogue are each a registry.
|
|
126
|
-
- **`window`** is how long it remembers a call id.
|
|
127
|
-
|
|
128
|
-
The hand changes a body. `faculties update` lands a new entry, and the
|
|
129
|
-
body goes down and up on it. `faculties restart` takes it down and up on
|
|
130
|
-
the same entry. Each house that names it opens again.
|
|
131
|
-
|
|
132
|
-
## Three ways to wrap a program
|
|
133
|
-
|
|
134
|
-
| Way | What the object is | What a crash takes |
|
|
135
|
-
| --- | --- | --- |
|
|
136
|
-
| in the ground | a JavaScript object, an addon or a WASM module | the ground and every house |
|
|
137
|
-
| beside it | a program over the bridge | the calls in flight to it |
|
|
138
|
-
| far away | a client of a socket or an HTTP service | the calls in flight to it |
|
|
139
|
-
|
|
140
|
-
## The bridge
|
|
141
|
-
|
|
142
|
-
`bridge({ command, args, env, cwd, memory })` from `nervur/node` starts
|
|
143
|
-
a program and answers a body. A NodeGround raises one by its faculty
|
|
144
|
-
`bridge`, from its entry alone. The program may be written in any
|
|
145
|
-
language. It speaks one JSON value a line on its standard streams.
|
|
146
|
-
|
|
147
|
-
```text
|
|
148
|
-
in { id, method, args, call } a call to the program
|
|
149
|
-
out { id, result } | { id, error } its answer
|
|
150
|
-
out { id, token, args, call } the program calls a handle
|
|
151
|
-
in { id, result } | { id, error } the house's answer to that
|
|
152
|
-
out { id, memory, args } the program reads or writes its memory
|
|
153
|
-
in { id, result } | { id, error } the ground's answer to that
|
|
154
|
-
```
|
|
155
|
-
|
|
156
|
-
- **It describes itself first.** Its first call is the method
|
|
157
|
-
`describe`, and it answers `{ blueprint, window }`. It cannot offer
|
|
158
|
-
what it does not declare.
|
|
159
|
-
- **Calls overlap.** Answers come in any order and are matched by `id`,
|
|
160
|
-
and each direction keeps ids of its own.
|
|
161
|
-
- **`call` is the call id.** On a call in, it is the house's. On a
|
|
162
|
-
handle the program calls, it is the program's own, and it must stay
|
|
163
|
-
the same every time that call is sent, in every life of the program.
|
|
164
|
-
A handle called with no `call` is refused.
|
|
165
|
-
- **It starts with an empty environment.** It holds only what `env`
|
|
166
|
-
names, so it never reads the ground's key. The faculty `bridge` names
|
|
167
|
-
there the secrets of its entry, each under its name.
|
|
168
|
-
- **It keeps its state in the faculty's memory.** A memory line names
|
|
169
|
-
`read`, `list` or `write` and the memory's args, every entry's bytes
|
|
170
|
-
in lowercase hex. A write names the versions it read in `expect`, and
|
|
171
|
-
answers `null` where another write moved them. Nothing it keeps rests
|
|
172
|
-
in its folder.
|
|
173
|
-
- **It is started again.** A program that exits fails every awaited call
|
|
174
|
-
in flight to it, and the house sends each effect again with its call
|
|
175
|
-
id. A program that keeps failing is started more slowly, up to a
|
|
176
|
-
minute apart.
|
|
177
|
-
- **Its blueprint is fixed while its faculty stands.** A program started
|
|
178
|
-
again that describes another blueprint is stopped, and the ground says
|
|
179
|
-
so. Every call to it then answers why, until its faculty stands again.
|
|
180
|
-
- **Its standard error goes to the ground's log**, and a line is at most
|
|
181
|
-
a mebibyte.
|
|
182
|
-
|
|
183
|
-
The bridge is not a sandbox. A program runs as the ground's user, and
|
|
184
|
-
`kinds` decides which classes reach it. A program you do not trust runs
|
|
185
|
-
as a user of its own. On Linux the entry's command becomes `sudo`, with
|
|
186
|
-
`-n -u relay` before the program, and one rule allows it and nothing
|
|
187
|
-
else:
|
|
188
|
-
|
|
189
|
-
```text
|
|
190
|
-
nervur ALL=(relay) NOPASSWD: /usr/bin/python3 /srv/garage/relay.py
|
|
191
|
-
```
|
|
192
|
-
|
|
193
|
-
## The relay, in Python
|
|
194
|
-
|
|
195
|
-
```python
|
|
196
|
-
#!/usr/bin/env python3
|
|
197
|
-
# The relay on a garage's Pi, as a faculty over the bridge. One pulse
|
|
198
|
-
# toggles the door, so a pulse sent twice would open it and close it
|
|
199
|
-
# again. It keeps each call id in the faculty's memory before it pulses,
|
|
200
|
-
# and answers an id it has seen with the answer it gave, so a pulse sent
|
|
201
|
-
# again never toggles twice. Its memory is sealed by the ground and
|
|
202
|
-
# travels with it; its folder holds only what the Pi's pin would show.
|
|
203
|
-
import json
|
|
204
|
-
import os
|
|
205
|
-
import sys
|
|
206
|
-
|
|
207
|
-
BLUEPRINT = {
|
|
208
|
-
'name': 'relay',
|
|
209
|
-
'methods': {
|
|
210
|
-
'pulse': {
|
|
211
|
-
'description': 'One pulse of the relay, which toggles the door.',
|
|
212
|
-
'args': {'type': 'object', 'properties': {}, 'required': [], 'additionalProperties': False},
|
|
213
|
-
'result': {'type': 'integer'},
|
|
214
|
-
},
|
|
215
|
-
},
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
waiting = []
|
|
219
|
-
asked = 0
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
def send(message):
|
|
223
|
-
sys.stdout.write(json.dumps(message) + '\n')
|
|
224
|
-
sys.stdout.flush()
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
def remember(op, args):
|
|
228
|
-
# One memory line, and the ground's answer to it; calls that arrive meanwhile wait their turn.
|
|
229
|
-
global asked
|
|
230
|
-
asked += 1
|
|
231
|
-
mine = 'memory-' + str(asked)
|
|
232
|
-
send({'id': mine, 'memory': op, 'args': args})
|
|
233
|
-
while True:
|
|
234
|
-
line = sys.stdin.readline()
|
|
235
|
-
if line == '':
|
|
236
|
-
sys.exit(0)
|
|
237
|
-
message = json.loads(line)
|
|
238
|
-
if message.get('id') != mine or 'method' in message:
|
|
239
|
-
waiting.append(message)
|
|
240
|
-
continue
|
|
241
|
-
if 'error' in message:
|
|
242
|
-
raise RuntimeError(message['error']['message'])
|
|
243
|
-
return message['result']
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
def seen():
|
|
247
|
-
read = remember('read', {'place': 'seen'})
|
|
248
|
-
return {call: int(bytes.fromhex(count).decode()) for call, count in read['entries'].items()}, read['version']
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
def keep(call, count, version):
|
|
252
|
-
landed = remember('write', {'writes': {'seen': {call: str(count).encode().hex()}}, 'expect': {'seen': version}})
|
|
253
|
-
if landed is None:
|
|
254
|
-
# Another life wrote first: this one ends, and the ground starts it again.
|
|
255
|
-
sys.exit(1)
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
def pulse(call):
|
|
259
|
-
# The Pi drives its pin here. This relay writes a line, so a test counts pulses.
|
|
260
|
-
with open('pulses', 'a') as relay:
|
|
261
|
-
relay.write(call + '\n')
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
def messages():
|
|
265
|
-
while True:
|
|
266
|
-
if waiting:
|
|
267
|
-
yield waiting.pop(0)
|
|
268
|
-
continue
|
|
269
|
-
line = sys.stdin.readline()
|
|
270
|
-
if line == '':
|
|
271
|
-
return
|
|
272
|
-
yield json.loads(line)
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
# Each life writes a line, so a test knows when the program started again.
|
|
276
|
-
with open('lives', 'a') as lives:
|
|
277
|
-
lives.write(str(os.getpid()) + '\n')
|
|
278
|
-
for message in messages():
|
|
279
|
-
if message['method'] == 'describe':
|
|
280
|
-
send({'id': message['id'], 'result': {'blueprint': BLUEPRINT, 'window': 7 * 86_400_000}})
|
|
281
|
-
continue
|
|
282
|
-
if message['method'] != 'pulse':
|
|
283
|
-
send({'id': message['id'], 'error': {'message': 'no such method'}})
|
|
284
|
-
continue
|
|
285
|
-
counts, version = seen()
|
|
286
|
-
if message['call'] in counts:
|
|
287
|
-
send({'id': message['id'], 'result': counts[message['call']]})
|
|
288
|
-
continue
|
|
289
|
-
count = len(counts) + 1
|
|
290
|
-
# The call id is kept before the pulse, so a crash between them never pulses twice.
|
|
291
|
-
keep(message['call'], count, version)
|
|
292
|
-
pulse(message['call'])
|
|
293
|
-
# A test writes `crash-after`: the program dies once after that pulse, before it answers.
|
|
294
|
-
if os.path.exists('crash-after') and count == int(open('crash-after').read()):
|
|
295
|
-
os.remove('crash-after')
|
|
296
|
-
os._exit(1)
|
|
297
|
-
send({'id': message['id'], 'result': count})
|
|
298
|
-
```
|
|
299
|
-
|
|
300
|
-
The call id is written to disk before the pin moves. A crash between the
|
|
301
|
-
write and the pulse loses one opening, and the call answers as if it
|
|
302
|
-
pulsed. The other order would pulse twice, and the door would close
|
|
303
|
-
again. For a door, a missed opening is the safe failure.
|
|
304
|
-
The blueprint is JSON Schema, in the subset `s` writes, so a need in
|
|
305
|
-
TypeScript and a program in any language describe one shape.
|
|
306
|
-
|
|
307
|
-
## The twin, and the remote
|
|
308
|
-
|
|
309
|
-
```ts
|
|
310
|
-
// The garage door's twin, on the Pi's ground, and the remote on a phone.
|
|
311
|
-
// The twin alone holds the relay, and keeps who opened the door and which
|
|
312
|
-
// pulse did it. The remote holds a standing on the twin and taps it by an
|
|
313
|
-
// effect, so a tap is sent until it is answered, and pulses once.
|
|
314
|
-
import { Being, need, s, type Args } from 'nervur/being';
|
|
315
|
-
|
|
316
|
-
/** The relay, as the Python program offers it: one pulse, answered with its count. */
|
|
317
|
-
export const Relay = need('relay', { pulse: { result: s.integer() } });
|
|
318
|
-
|
|
319
|
-
/** The twin, as the remote asks it. */
|
|
320
|
-
export const Door = need('garage', { open: {} });
|
|
321
|
-
|
|
322
|
-
export class Garage extends Being.of({
|
|
323
|
-
kind: 'org.example.garage',
|
|
324
|
-
description: 'The twin of a garage door: who opened it, and which pulse did.',
|
|
325
|
-
needs: { relay: Relay },
|
|
326
|
-
cells: { openers: [] as string[], pulses: [] as number[], failed: [] as string[] },
|
|
327
|
-
asks: {
|
|
328
|
-
open: {},
|
|
329
|
-
pulsed: { args: s.reply(Relay.pulse) },
|
|
330
|
-
log: { hints: { readOnly: true }, result: s.object({ openers: s.array(s.string()), pulses: s.array(s.integer()), failed: s.array(s.string()) }) },
|
|
331
|
-
},
|
|
332
|
-
}) {
|
|
333
|
-
open() {
|
|
334
|
-
this.cells.openers = [...this.cells.openers, this.asker.id];
|
|
335
|
-
this.relay.pulse({}, { reply: 'pulsed' });
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
pulsed({ result, error }: Args<Garage, 'pulsed'>) {
|
|
339
|
-
if (error === undefined) this.cells.pulses = [...this.cells.pulses, result];
|
|
340
|
-
else this.cells.failed = [...this.cells.failed, error.message];
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
log() {
|
|
344
|
-
return this.cells;
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
export class Remote extends Being.of({
|
|
349
|
-
kind: 'org.example.remote',
|
|
350
|
-
description: 'A garage remote on a phone: one tap, one opening.',
|
|
351
|
-
cells: { garage: '', heard: 0, failed: [] as string[] },
|
|
352
|
-
asks: {
|
|
353
|
-
accept: { args: s.object({ invitation: s.handle() }), hints: { idempotent: true } },
|
|
354
|
-
tap: {},
|
|
355
|
-
opened: { args: s.reply(Door.open) },
|
|
356
|
-
heard: { hints: { readOnly: true }, result: s.integer() },
|
|
357
|
-
},
|
|
358
|
-
}) {
|
|
359
|
-
accept({ invitation }: Args<Remote, 'accept'>) {
|
|
360
|
-
this.cells.garage = invitation;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
tap() {
|
|
364
|
-
this.held(this.cells.garage, Door).open({}, { reply: 'opened' });
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
opened({ error }: Args<Remote, 'opened'>) {
|
|
368
|
-
if (error === undefined) this.cells.heard += 1;
|
|
369
|
-
else this.cells.failed = [...this.cells.failed, error.message];
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
heard() {
|
|
373
|
-
return this.cells.heard;
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
```
|
|
377
|
-
|
|
378
|
-
`pulse` is not `idempotent`, so it is an effect. It leaves once `open`
|
|
379
|
-
has landed, and its answer comes back to `pulsed`. The twin never waits
|
|
380
|
-
on the relay, and the relay never waits on the twin. The remote asks the
|
|
381
|
-
twin by an effect too, so a tap made while the Pi is unreachable reaches
|
|
382
|
-
it later, once.
|
|
383
|
-
|
|
384
|
-
## The entry
|
|
385
|
-
|
|
386
|
-
The Pi's folder holds the program and a folder of classes for its
|
|
387
|
-
house, and no code of the ground's. The relay stands from its entry,
|
|
388
|
-
raised by the faculty `bridge` of the NodeGround's folder, and granted
|
|
389
|
-
to the twin's class alone.
|
|
390
|
-
|
|
391
|
-
```bash
|
|
392
|
-
npx nervur faculties add name=relay from=folder make=bridge args='{"command":"python3","args":["relay.py"]}' kinds='["org.example.garage"]'
|
|
393
|
-
```
|
|
394
|
-
|
|
395
|
-
The house is added once, and names the relay among the faculties it
|
|
396
|
-
receives.
|
|
397
|
-
|
|
398
|
-
```bash
|
|
399
|
-
npx nervur houses add name=garage classes='{"faculty":"folder","at":"classes"}' faculties='["relay"]'
|
|
400
|
-
```
|
|
401
|
-
|
|
402
|
-
Both entries rest sealed as cells of the ground's dock, so the ground
|
|
403
|
-
stands the relay again at every start.
|
|
404
|
-
|
|
405
|
-
## In JavaScript
|
|
406
|
-
|
|
407
|
-
`serve(need, methods)` from `nervur/serve` is the program's side of the
|
|
408
|
-
bridge in JavaScript. It answers `describe` from the need, and every
|
|
409
|
-
call with the method it names. A method receives its args and a context:
|
|
410
|
-
the call id, `call(token, args, id?)` for a handle it was handed, and
|
|
411
|
-
`memory`, the faculty's own, whose `read`, `list` and `write` speak the
|
|
412
|
-
memory lines. Where no `id` is given, the handle's call id is the call's
|
|
413
|
-
own id and its count within the call. A retry of the call calls back with the same
|
|
414
|
-
ids, so the handle acts once. A method that throws answers an error,
|
|
415
|
-
which is final. A program that must be asked again exits instead, and
|
|
416
|
-
the ground starts it again.
|
|
417
|
-
|
|
418
|
-
```ts
|
|
419
|
-
// A doorbell, as a faculty written in JavaScript and run over the bridge
|
|
420
|
-
// by `nervur/serve`. A being hands it the handle of an ask to call when
|
|
421
|
-
// the bell is pressed; a press calls that handle back through the house.
|
|
422
|
-
import { need, s } from 'nervur/being';
|
|
423
|
-
import { serve } from 'nervur/serve';
|
|
424
|
-
|
|
425
|
-
export const Doorbell = need('doorbell', {
|
|
426
|
-
watch: { args: s.object({ inbox: s.handle() }), hints: { idempotent: true } },
|
|
427
|
-
press: {},
|
|
428
|
-
});
|
|
429
|
-
|
|
430
|
-
let inbox: string | undefined;
|
|
431
|
-
|
|
432
|
-
serve(Doorbell, {
|
|
433
|
-
watch: ({ inbox: token }) => {
|
|
434
|
-
inbox = token as string;
|
|
435
|
-
return null;
|
|
436
|
-
},
|
|
437
|
-
press: async (_args, context) => {
|
|
438
|
-
if (inbox === undefined) throw new Error('no one watches the bell');
|
|
439
|
-
const answered = await context.call(inbox, {});
|
|
440
|
-
if ('error' in answered) throw new Error(answered.error.message);
|
|
441
|
-
return null;
|
|
442
|
-
},
|
|
443
|
-
});
|
|
444
|
-
```
|
|
445
|
-
|
|
446
|
-
Its entry raises it with the faculty `bridge`, its command `node` and
|
|
447
|
-
its args `["doorbell.js"]`. A faculty that needs no process of its own
|
|
448
|
-
is a plain object its `up` answers instead, as the shop's payments
|
|
449
|
-
are in [Writing for nervur](AUTHORING.md).
|
|
450
|
-
|
|
451
|
-
## Testing a faculty
|
|
452
|
-
|
|
453
|
-
A faculty is tested on BenchGround, the ground in memory from
|
|
454
|
-
`nervur/bench`, with the program itself behind the bridge. The test
|
|
455
|
-
hands the bench a registry whose `bridge` faculty bridges the program, as a
|
|
456
|
-
NodeGround's own does, and stands the relay through the ground's hand,
|
|
457
|
-
as an owner does. A `FakeNetwork` joins
|
|
458
|
-
grounds and loses what the test tells it to lose, and its one clock
|
|
459
|
-
moves them all. A test waits on what it can hear: a watch on a being's
|
|
460
|
-
answer, or a line the program writes. The clock moves only as far as a
|
|
461
|
-
retry is due.
|
|
462
|
-
|
|
463
|
-
The Pi's house and the phone's each have a steward the owner pilots
|
|
464
|
-
through the hand. It answers `bear`, and `offerFor`, which mints a paper
|
|
465
|
-
on one of its beings for an occupant, as the shop's steward does in
|
|
466
|
-
[Writing for nervur](AUTHORING.md). The phone's remote takes that paper
|
|
467
|
-
as its standing on the twin.
|
|
468
|
-
|
|
469
|
-
```ts
|
|
470
|
-
// garage.test.ts
|
|
471
|
-
import assert from 'node:assert/strict';
|
|
472
|
-
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, watch, writeFileSync } from 'node:fs';
|
|
473
|
-
import { tmpdir } from 'node:os';
|
|
474
|
-
import { join } from 'node:path';
|
|
475
|
-
import { test } from 'node:test';
|
|
476
|
-
import { fileURLToPath } from 'node:url';
|
|
477
|
-
import { BenchGround, FakeNetwork } from 'nervur/bench';
|
|
478
|
-
import { bridge } from 'nervur/node';
|
|
479
|
-
import * as phone from './phone.ts';
|
|
480
|
-
import * as pi from './pi.ts';
|
|
481
|
-
|
|
482
|
-
test('Each tap on the phone pulses the relay once, whatever fails between', { timeout: 30_000 }, async (t) => {
|
|
483
|
-
const state = mkdtempSync(join(tmpdir(), 'garage-'));
|
|
484
|
-
t.after(() => rmSync(state, { recursive: true, force: true }));
|
|
485
|
-
// Where the program runs, and where the Pi's pin shows each pulse.
|
|
486
|
-
const pin = join(state, 'relay');
|
|
487
|
-
mkdirSync(pin);
|
|
488
|
-
// The program dies once, after its second pulse and before it answers.
|
|
489
|
-
writeFileSync(join(pin, 'crash-after'), '2');
|
|
490
|
-
const network = new FakeNetwork();
|
|
491
|
-
|
|
492
|
-
// The Pi's ground, whose registry bridges the program as a NodeGround's does, and the door's twin.
|
|
493
|
-
const relay = fileURLToPath(new URL('./relay.py', import.meta.url));
|
|
494
|
-
const garage = await BenchGround.open({
|
|
495
|
-
network,
|
|
496
|
-
host: 'pi',
|
|
497
|
-
names: ['garage.local'],
|
|
498
|
-
modules: { pi },
|
|
499
|
-
registry: { faculties: { bridge: { up: ({ memory }) => bridge({ command: 'python3', args: [relay], cwd: pin, memory }) } } },
|
|
500
|
-
});
|
|
501
|
-
t.after(() => garage.down());
|
|
502
|
-
// The relay, granted to the twin's class alone.
|
|
503
|
-
await garage.hand({ method: 'facultiesAdd', args: { name: 'relay', make: 'bridge', kinds: ['org.example.garage'] } });
|
|
504
|
-
await garage.add('garage', 'pi', { faculties: ['relay'] });
|
|
505
|
-
await garage.ask({ house: 'garage', method: 'bear', args: { kind: 'org.example.garage', id: 'door' } });
|
|
506
|
-
|
|
507
|
-
// Alice's phone: a device, holding a standing on the twin.
|
|
508
|
-
const alice = await BenchGround.open({ network, host: 'phone', modules: { phone } });
|
|
509
|
-
t.after(() => alice.down());
|
|
510
|
-
await alice.add('phone', 'phone');
|
|
511
|
-
await alice.ask({ house: 'phone', method: 'bear', args: { kind: 'org.example.remote', id: 'remote' } });
|
|
512
|
-
const paper = await garage.ask({ house: 'garage', method: 'offerFor', args: { being: 'door', occupant: 'alice' } });
|
|
513
|
-
await alice.ask({ house: 'phone', id: 'remote', method: 'accept', args: { invitation: (paper as { result: { handle: string } }).result.handle } });
|
|
514
|
-
|
|
515
|
-
// A being's answer, watched until it is what the test waits for: each ask answers once it moves.
|
|
516
|
-
const watched = async <T>(ground: BenchGround, house: string, id: string, method: string, done: (seen: T) => boolean): Promise<T> => {
|
|
517
|
-
let seen = ((await ground.ask({ house, id, method })) as { result: T }).result;
|
|
518
|
-
while (!done(seen)) seen = ((await ground.ask({ house, id, method, after: { result: seen as never } })) as { result: T }).result;
|
|
519
|
-
return seen;
|
|
520
|
-
};
|
|
521
|
-
const pulsed = (count: number) => watched<{ pulses: number[] }>(garage, 'garage', 'door', 'log', (log) => log.pulses.length >= count);
|
|
522
|
-
const heard = (count: number) => watched<number>(alice, 'phone', 'remote', 'heard', (seen) => seen >= count);
|
|
523
|
-
// Resolves once the relay's program has started `count` times.
|
|
524
|
-
const lives = (count: number) =>
|
|
525
|
-
new Promise<void>((resolve) => {
|
|
526
|
-
const started = () => existsSync(join(pin, 'lives')) && readFileSync(join(pin, 'lives'), 'utf8').trim().split('\n').length >= count;
|
|
527
|
-
const watcher = watch(pin, () => {
|
|
528
|
-
if (!started()) return;
|
|
529
|
-
watcher.close();
|
|
530
|
-
resolve();
|
|
531
|
-
});
|
|
532
|
-
t.after(() => watcher.close());
|
|
533
|
-
if (!started()) return;
|
|
534
|
-
watcher.close();
|
|
535
|
-
resolve();
|
|
536
|
-
});
|
|
537
|
-
const tap = async () => assert.ok('result' in (await alice.ask({ house: 'phone', id: 'remote', method: 'tap' })));
|
|
538
|
-
|
|
539
|
-
await tap();
|
|
540
|
-
await pulsed(1);
|
|
541
|
-
await heard(1);
|
|
542
|
-
|
|
543
|
-
// The second pulse kills the program before it answers. Once it runs
|
|
544
|
-
// again, a second on the bench's clock sends the pulse again, with its
|
|
545
|
-
// call id, and the program answers the pulse it gave.
|
|
546
|
-
await tap();
|
|
547
|
-
await lives(2);
|
|
548
|
-
await network.elapse(1_000);
|
|
549
|
-
await pulsed(2);
|
|
550
|
-
await heard(2);
|
|
551
|
-
assert.ok(!existsSync(join(pin, 'crash-after')), 'the program died between its second pulse and its answer');
|
|
552
|
-
|
|
553
|
-
// The reply to the phone is lost, so a second later it asks again, and
|
|
554
|
-
// the twin answers from its call id.
|
|
555
|
-
network.loseNext();
|
|
556
|
-
await tap();
|
|
557
|
-
await pulsed(3);
|
|
558
|
-
await network.elapse(1_000);
|
|
559
|
-
await heard(3);
|
|
560
|
-
|
|
561
|
-
assert.ok(network.crossings.some(({ outcome }) => outcome === 'lost'), 'a reply to the phone was lost');
|
|
562
|
-
assert.equal(readFileSync(join(pin, 'pulses'), 'utf8').trim().split('\n').length, 3, 'three pulses, never four');
|
|
563
|
-
assert.deepEqual(await garage.ask({ house: 'garage', id: 'door', method: 'log' }), { result: { openers: ['alice', 'alice', 'alice'], pulses: [1, 2, 3], failed: [] } });
|
|
564
|
-
});
|
|
565
|
-
```
|
|
566
|
-
|
|
567
|
-
Three taps open the door three times. The second pulse's program dies
|
|
568
|
-
before it answers, and the house sends that call again to the program
|
|
569
|
-
started anew, which answers from what it kept. The third tap's reply to
|
|
570
|
-
the phone is lost, and the phone's house sends it again to the twin,
|
|
571
|
-
which answers from its own call ids. The relay pulses three times, never
|
|
572
|
-
four.
|
|
573
|
-
|
|
574
|
-
`pi.ts` and `phone.ts` are the two houses' folders, each a steward and
|
|
575
|
-
the class it holds. A faculty a test does not run is an object the test
|
|
576
|
-
writes, whose methods answer, throw, refuse or never answer as the test
|
|
577
|
-
needs. Where no offer covers a need of a class, bearing a being of it
|
|
578
|
-
fails with `no offer covers her need <member>`.
|