dynos-client 0.1.2__tar.gz
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.
- dynos_client-0.1.2/PKG-INFO +263 -0
- dynos_client-0.1.2/README.md +251 -0
- dynos_client-0.1.2/pyproject.toml +18 -0
- dynos_client-0.1.2/setup.cfg +4 -0
- dynos_client-0.1.2/src/dynos_client/__init__.py +17 -0
- dynos_client-0.1.2/src/dynos_client/__main__.py +4 -0
- dynos_client-0.1.2/src/dynos_client/action.py +70 -0
- dynos_client-0.1.2/src/dynos_client/action_context.py +56 -0
- dynos_client-0.1.2/src/dynos_client/auth_cli.py +128 -0
- dynos_client-0.1.2/src/dynos_client/call.py +579 -0
- dynos_client-0.1.2/src/dynos_client/cli.py +149 -0
- dynos_client-0.1.2/src/dynos_client/domain.py +10 -0
- dynos_client-0.1.2/src/dynos_client/expr_parser.py +118 -0
- dynos_client-0.1.2/src/dynos_client/hash_cli.py +116 -0
- dynos_client-0.1.2/src/dynos_client/mission.py +261 -0
- dynos_client-0.1.2/src/dynos_client/remote_orchestrator.py +605 -0
- dynos_client-0.1.2/src/dynos_client/resolve_runnable.py +45 -0
- dynos_client-0.1.2/src/dynos_client/servant.py +194 -0
- dynos_client-0.1.2/src/dynos_client/session_cli.py +481 -0
- dynos_client-0.1.2/src/dynos_client/session_manager.py +303 -0
- dynos_client-0.1.2/src/dynos_client.egg-info/PKG-INFO +263 -0
- dynos_client-0.1.2/src/dynos_client.egg-info/SOURCES.txt +24 -0
- dynos_client-0.1.2/src/dynos_client.egg-info/dependency_links.txt +1 -0
- dynos_client-0.1.2/src/dynos_client.egg-info/entry_points.txt +2 -0
- dynos_client-0.1.2/src/dynos_client.egg-info/requires.txt +4 -0
- dynos_client-0.1.2/src/dynos_client.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: dynos-client
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Client and CLI for orchestrating DYNOS backends remotely
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.8
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: dynos-core>=0.1.2
|
|
9
|
+
Requires-Dist: requests
|
|
10
|
+
Requires-Dist: typer
|
|
11
|
+
Requires-Dist: bcrypt>=4.0
|
|
12
|
+
|
|
13
|
+
# dynos-client
|
|
14
|
+
|
|
15
|
+
The HTTP client and command-line tool for talking to a DYNOS planning backend.
|
|
16
|
+
You install this on your laptop or server, point it at a backend, and drive the
|
|
17
|
+
planner from Python or the shell. The backend does the planning; this package
|
|
18
|
+
gives you the network interface.
|
|
19
|
+
|
|
20
|
+
You usually combine it with a domain package (e.g. `dynos-sentry-domain`)
|
|
21
|
+
that gives you the actual nouns and verbs your goals reference. `dynos-client`
|
|
22
|
+
ships only the infrastructure: HTTP, CLI, mission builder, session-gateway
|
|
23
|
+
authentication.
|
|
24
|
+
|
|
25
|
+
`dynos-client` depends on `dynos-core`, as do the domain files. The domain
|
|
26
|
+
files also depend on this package. Specific robot missions or extensions,
|
|
27
|
+
such as the adaptive sampling demo, depend on robot implementations.
|
|
28
|
+
|
|
29
|
+
Most users start here. `pip install dynos-client` plus a domain package and you
|
|
30
|
+
can talk to a hosted backend immediately. You do not need to install the
|
|
31
|
+
backend itself.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install dynos-client
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Pulls in `dynos-core`, `requests`, `typer`, and `bcrypt`. Registers a `dynos` console script.
|
|
40
|
+
|
|
41
|
+
## Sessions and the backend
|
|
42
|
+
|
|
43
|
+
DYNOS backends run in two modes. Direct mode is a single process with no auth.
|
|
44
|
+
This is fine for local development against a mock, or on-vehicle work.
|
|
45
|
+
Session-gateway mode is multi-tenant, and most likely how you're be developing:
|
|
46
|
+
a TLS endpoint accepts password logins, issues bearer tokens, and routes each
|
|
47
|
+
user's traffic to a private subprocess (their session) with its own world
|
|
48
|
+
model. The hosted backend at `https://api.dynosplan.com` is in this mode.
|
|
49
|
+
|
|
50
|
+
You authenticate once:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
dynos login
|
|
54
|
+
# optionally, you can set the source. The default is the webpage, but this might be an on-ship server:
|
|
55
|
+
# dynos login --to https://api.dynosplan.com
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
That writes `~/.dynos/config.json` (mode 0o600). Every subsequent `dynos call`,
|
|
59
|
+
`dynos session`, and `dynos connect` reads the cached URL, token, and default
|
|
60
|
+
session, so the `--to` flag is rarely needed after the first login.
|
|
61
|
+
|
|
62
|
+
A session is a private process that holds *your* world model. Create one before
|
|
63
|
+
issuing goals:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
dynos session create
|
|
67
|
+
dynos call health # confirms the gateway is reachable
|
|
68
|
+
dynos session info # confirms a session is selected
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`dynos session swap <name>` switches the default; `dynos session stop` ends the
|
|
72
|
+
current session. The CLI never silently picks a session for you. If your
|
|
73
|
+
default session has expired or been stopped, commands error out with a hint to
|
|
74
|
+
run `swap` or `create`.
|
|
75
|
+
|
|
76
|
+
## CLI map
|
|
77
|
+
|
|
78
|
+
| Command | Purpose |
|
|
79
|
+
|-------------------------------------|------------------------------------------------------------------------------------------------------------------------|
|
|
80
|
+
| `dynos login` / `logout` / `whoami` | Authenticate against a session gateway and inspect the cache. |
|
|
81
|
+
| `dynos call <subcmd>` | Talk to a running backend: `health`, `goal`, `plan`, `execute`, `state`, `objects`, `trigger`, `reset`, `geojson`, ... |
|
|
82
|
+
| `dynos session <subcmd>` | Manage gateway sessions: `create`, `list`, `info`, `swap`, `extend`, `reset`, `stop`. |
|
|
83
|
+
| `dynos connect <module>:<Class>` | Run as a *servant* — the backend dispatches actions over HTTP and your local code executes them. |
|
|
84
|
+
| `dynos hash-password` | Produce a bcrypt hash for the gateway's admin user table. You can send this to the server admin (me) to change your pw |
|
|
85
|
+
|
|
86
|
+
Run any of them with `--help` for full options.
|
|
87
|
+
|
|
88
|
+
## 60-second example
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from dynos_client import RemoteOrchestrator
|
|
92
|
+
|
|
93
|
+
orch = RemoteOrchestrator.from_config() # reads ~/.dynos/config.json
|
|
94
|
+
print(orch.health())
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
The shell equivalent:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
dynos call health
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Both go to the same endpoint. The Python form is the one you reach for when
|
|
104
|
+
missions get longer than the CLI's 30-second HTTP timeout (see "Long-running
|
|
105
|
+
plans" below).
|
|
106
|
+
|
|
107
|
+
## Goal vs. trigger
|
|
108
|
+
|
|
109
|
+
There are two ways to make the backend do something, and they are not
|
|
110
|
+
interchangeable.
|
|
111
|
+
|
|
112
|
+
`dynos call execute` (preferred). You set a goal, which is a desired symbolic
|
|
113
|
+
state. The planner derives the prerequisite sequence. If something fails, the
|
|
114
|
+
planner replans from the current state. This is what the system is designed
|
|
115
|
+
for.
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
dynos call goal "full_coverage_of(site_alpha)" "phase_ascending()"
|
|
119
|
+
dynos call plan # optional: print the action sequence the planner picked
|
|
120
|
+
dynos call execute
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
`dynos call trigger` (debug only). Fires one transition directly, with no
|
|
124
|
+
planner involvement and no precondition check. Convenient for confirming a
|
|
125
|
+
custom `@Action` is wired up; not safe for anything that moves the vehicle. A
|
|
126
|
+
careless `trigger` can leave the symbolic world in a state the planner doesn't
|
|
127
|
+
trust (e.g. recording `at(wp_end)` without descent ever happening).
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
dynos call trigger "resample_zone(source_zone=site_alpha)" # OK for debug
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
If you find yourself wanting to chain `trigger` calls, you actually want a
|
|
134
|
+
goal. Let the planner sequence them.
|
|
135
|
+
|
|
136
|
+
## Missions
|
|
137
|
+
|
|
138
|
+
A `Mission` chains plan blocks and action blocks. Each block runs to completion
|
|
139
|
+
before the next starts; the planner replans at every block boundary using the
|
|
140
|
+
state after the previous block.
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
from dynos_client import RemoteOrchestrator, Mission
|
|
144
|
+
from dynos_sentry.sentry import Zone, full_coverage_of
|
|
145
|
+
|
|
146
|
+
orch = RemoteOrchestrator.from_config(timeout_s=3600)
|
|
147
|
+
|
|
148
|
+
area_north = Zone("area_north", coordinate_frame="geographic",
|
|
149
|
+
vertices=[[-70.67, 41.525], [-70.66, 41.525], [-70.66, 41.53], [-70.67, 41.53]],
|
|
150
|
+
altitude=70.0, speed=0.8, coverage_width=170.0, robot_width=0.5)
|
|
151
|
+
area_south = Zone("area_south", coordinate_frame="geographic",
|
|
152
|
+
vertices=[[-70.67, 41.52], [-70.66, 41.52], [-70.66, 41.525], [-70.67, 41.525]],
|
|
153
|
+
altitude=70.0, speed=0.8, coverage_width=170.0, robot_width=0.5)
|
|
154
|
+
orch.create_object(area_north)
|
|
155
|
+
orch.create_object(area_south)
|
|
156
|
+
|
|
157
|
+
mission = Mission(name="two_surveys")
|
|
158
|
+
mission.add_plan([full_coverage_of(area_north)])
|
|
159
|
+
mission.add_plan([full_coverage_of(area_south)])
|
|
160
|
+
results = orch.execute_blocks(mission)
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Mix action blocks (run a single transition immediately, no planning) with plan
|
|
164
|
+
blocks (set a goal, planner derives the sequence):
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
from dynos_sentry.sentry import full_coverage_of
|
|
168
|
+
|
|
169
|
+
mission = Mission(name="custom_sequence")
|
|
170
|
+
mission.add_action(takeover_control) # primitive, no planning
|
|
171
|
+
mission.add_plan([full_coverage_of(area_north)]) # planned
|
|
172
|
+
mission.add_plan([full_coverage_of(area_south)])
|
|
173
|
+
mission.add_plan([phase_ascending()])
|
|
174
|
+
results = orch.execute_blocks(mission)
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
A single plan block can carry multiple goals; the planner finds one plan that
|
|
178
|
+
achieves all of them. A single action block instructs a specific thing to
|
|
179
|
+
occur, which circumvents a lot of the replanning possibilities (you strip out
|
|
180
|
+
the context), but sometimes things need to be hard-coded in a mission so it's
|
|
181
|
+
available. Prefer @Script for hard sequences, because these can be associated
|
|
182
|
+
with transitions like actions do (see later).
|
|
183
|
+
|
|
184
|
+
## Servants and `dynos connect`
|
|
185
|
+
|
|
186
|
+
When the action's implementation lives on your machine — your ML model, a
|
|
187
|
+
sensor handler, a custom controller — you run a *servant*. The backend keeps
|
|
188
|
+
planning; your code executes the assignments it dispatches. See
|
|
189
|
+
`dynos-adaptive-resampling`'s README for a full worked example.
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
dynos connect my_package.runnable:MyNode
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
A Runnable is a small class with three static methods (`register`,
|
|
196
|
+
`get_start_state`, `get_start_goal`); the servant loop polls the backend for
|
|
197
|
+
assignments and posts results back. `dynos connect` reads the same cached login
|
|
198
|
+
as `dynos call`.
|
|
199
|
+
|
|
200
|
+
## Custom actions in a client-only env
|
|
201
|
+
|
|
202
|
+
`@Action` and `@Script` (the decorators that bind a Python method to a
|
|
203
|
+
`Transition`) live in the DYNOS backend, not in `dynos-client`. If your code
|
|
204
|
+
needs to import them but should also run in environments where the backend
|
|
205
|
+
isn't installed (CI, tests, a public release), use the conditional-import
|
|
206
|
+
recipe:
|
|
207
|
+
|
|
208
|
+
```python
|
|
209
|
+
try:
|
|
210
|
+
from dynos.databases.typed_action import Action, Script
|
|
211
|
+
except ImportError:
|
|
212
|
+
def Action(*a, **kw):
|
|
213
|
+
return (lambda f: f) if not (a and callable(a[0])) else a[0]
|
|
214
|
+
Script = Action
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
This degrades the decorators to no-ops in client-only environments; the class
|
|
218
|
+
still loads, registration is a no-op, and the action never fires (because no
|
|
219
|
+
backend is there to dispatch it).
|
|
220
|
+
|
|
221
|
+
## Long-running plans
|
|
222
|
+
|
|
223
|
+
`dynos call execute` is synchronous: the backend holds the HTTP response open
|
|
224
|
+
until every step in the plan has finished. The CLI defaults to a 30-second
|
|
225
|
+
client-side timeout, so for any plan that takes longer your terminal will print
|
|
226
|
+
`Read timed out` even though the backend is still happily executing. This times
|
|
227
|
+
out the client, not the plan.
|
|
228
|
+
|
|
229
|
+
Two workarounds:
|
|
230
|
+
|
|
231
|
+
1. Use Python with a longer timeout. `RemoteOrchestrator.from_config(timeout_s=3600)`.
|
|
232
|
+
2. Don't watch, open a second terminal and poll. `dynos call state`, `dynos call goal`, `dynos call trace`, `dynos call log --wall`.
|
|
233
|
+
|
|
234
|
+
## Configuration
|
|
235
|
+
|
|
236
|
+
`~/.dynos/config.json` (mode 0o600) stores:
|
|
237
|
+
|
|
238
|
+
| Key | Source |
|
|
239
|
+
|-------------------|---------------------------------------|
|
|
240
|
+
| `backend_url` | `dynos login --to ...` |
|
|
241
|
+
| `token` | `dynos login` (gateway-issued bearer) |
|
|
242
|
+
| `username` | `dynos login` |
|
|
243
|
+
| `role` | `dynos login` (`user`, `admin`) |
|
|
244
|
+
| `default_session` | `dynos session create` / `swap` |
|
|
245
|
+
|
|
246
|
+
Set `DYNOS_CONFIG_PATH=/some/other/file.json` to override the location (handy for tests; the public CLI honours the env var).
|
|
247
|
+
|
|
248
|
+
## Public API
|
|
249
|
+
|
|
250
|
+
| Symbol | Purpose |
|
|
251
|
+
|----------------------------------|----------------------------------------------------------------------|
|
|
252
|
+
| `RemoteOrchestrator` | The HTTP client; mirrors the backend's `Orchestrator` Python API. |
|
|
253
|
+
| `RemoteAuthExpired` | Raised when the cached token is rejected; catch and reauthenticate. |
|
|
254
|
+
| `Mission` | Builder for compound goal sequences. |
|
|
255
|
+
| `PlanBlock` | Plan-derived block within a `Mission` (one or more goals → planner). |
|
|
256
|
+
| `ActionBlock` / `AnyActionBlock` | Single-action block within a `Mission` (no planning). |
|
|
257
|
+
|
|
258
|
+
## Next
|
|
259
|
+
|
|
260
|
+
Install `dynos-sentry-domain` to get `Zone`, `Coordinate`, `full_coverage_of`,
|
|
261
|
+
and the rest of the goal vocabulary. The walkthrough at `user_guide.md` ties
|
|
262
|
+
everything together end-to-end. A simple toy domain (warehouse) is also
|
|
263
|
+
available.
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# dynos-client
|
|
2
|
+
|
|
3
|
+
The HTTP client and command-line tool for talking to a DYNOS planning backend.
|
|
4
|
+
You install this on your laptop or server, point it at a backend, and drive the
|
|
5
|
+
planner from Python or the shell. The backend does the planning; this package
|
|
6
|
+
gives you the network interface.
|
|
7
|
+
|
|
8
|
+
You usually combine it with a domain package (e.g. `dynos-sentry-domain`)
|
|
9
|
+
that gives you the actual nouns and verbs your goals reference. `dynos-client`
|
|
10
|
+
ships only the infrastructure: HTTP, CLI, mission builder, session-gateway
|
|
11
|
+
authentication.
|
|
12
|
+
|
|
13
|
+
`dynos-client` depends on `dynos-core`, as do the domain files. The domain
|
|
14
|
+
files also depend on this package. Specific robot missions or extensions,
|
|
15
|
+
such as the adaptive sampling demo, depend on robot implementations.
|
|
16
|
+
|
|
17
|
+
Most users start here. `pip install dynos-client` plus a domain package and you
|
|
18
|
+
can talk to a hosted backend immediately. You do not need to install the
|
|
19
|
+
backend itself.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install dynos-client
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Pulls in `dynos-core`, `requests`, `typer`, and `bcrypt`. Registers a `dynos` console script.
|
|
28
|
+
|
|
29
|
+
## Sessions and the backend
|
|
30
|
+
|
|
31
|
+
DYNOS backends run in two modes. Direct mode is a single process with no auth.
|
|
32
|
+
This is fine for local development against a mock, or on-vehicle work.
|
|
33
|
+
Session-gateway mode is multi-tenant, and most likely how you're be developing:
|
|
34
|
+
a TLS endpoint accepts password logins, issues bearer tokens, and routes each
|
|
35
|
+
user's traffic to a private subprocess (their session) with its own world
|
|
36
|
+
model. The hosted backend at `https://api.dynosplan.com` is in this mode.
|
|
37
|
+
|
|
38
|
+
You authenticate once:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
dynos login
|
|
42
|
+
# optionally, you can set the source. The default is the webpage, but this might be an on-ship server:
|
|
43
|
+
# dynos login --to https://api.dynosplan.com
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
That writes `~/.dynos/config.json` (mode 0o600). Every subsequent `dynos call`,
|
|
47
|
+
`dynos session`, and `dynos connect` reads the cached URL, token, and default
|
|
48
|
+
session, so the `--to` flag is rarely needed after the first login.
|
|
49
|
+
|
|
50
|
+
A session is a private process that holds *your* world model. Create one before
|
|
51
|
+
issuing goals:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
dynos session create
|
|
55
|
+
dynos call health # confirms the gateway is reachable
|
|
56
|
+
dynos session info # confirms a session is selected
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`dynos session swap <name>` switches the default; `dynos session stop` ends the
|
|
60
|
+
current session. The CLI never silently picks a session for you. If your
|
|
61
|
+
default session has expired or been stopped, commands error out with a hint to
|
|
62
|
+
run `swap` or `create`.
|
|
63
|
+
|
|
64
|
+
## CLI map
|
|
65
|
+
|
|
66
|
+
| Command | Purpose |
|
|
67
|
+
|-------------------------------------|------------------------------------------------------------------------------------------------------------------------|
|
|
68
|
+
| `dynos login` / `logout` / `whoami` | Authenticate against a session gateway and inspect the cache. |
|
|
69
|
+
| `dynos call <subcmd>` | Talk to a running backend: `health`, `goal`, `plan`, `execute`, `state`, `objects`, `trigger`, `reset`, `geojson`, ... |
|
|
70
|
+
| `dynos session <subcmd>` | Manage gateway sessions: `create`, `list`, `info`, `swap`, `extend`, `reset`, `stop`. |
|
|
71
|
+
| `dynos connect <module>:<Class>` | Run as a *servant* — the backend dispatches actions over HTTP and your local code executes them. |
|
|
72
|
+
| `dynos hash-password` | Produce a bcrypt hash for the gateway's admin user table. You can send this to the server admin (me) to change your pw |
|
|
73
|
+
|
|
74
|
+
Run any of them with `--help` for full options.
|
|
75
|
+
|
|
76
|
+
## 60-second example
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from dynos_client import RemoteOrchestrator
|
|
80
|
+
|
|
81
|
+
orch = RemoteOrchestrator.from_config() # reads ~/.dynos/config.json
|
|
82
|
+
print(orch.health())
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The shell equivalent:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
dynos call health
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Both go to the same endpoint. The Python form is the one you reach for when
|
|
92
|
+
missions get longer than the CLI's 30-second HTTP timeout (see "Long-running
|
|
93
|
+
plans" below).
|
|
94
|
+
|
|
95
|
+
## Goal vs. trigger
|
|
96
|
+
|
|
97
|
+
There are two ways to make the backend do something, and they are not
|
|
98
|
+
interchangeable.
|
|
99
|
+
|
|
100
|
+
`dynos call execute` (preferred). You set a goal, which is a desired symbolic
|
|
101
|
+
state. The planner derives the prerequisite sequence. If something fails, the
|
|
102
|
+
planner replans from the current state. This is what the system is designed
|
|
103
|
+
for.
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
dynos call goal "full_coverage_of(site_alpha)" "phase_ascending()"
|
|
107
|
+
dynos call plan # optional: print the action sequence the planner picked
|
|
108
|
+
dynos call execute
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`dynos call trigger` (debug only). Fires one transition directly, with no
|
|
112
|
+
planner involvement and no precondition check. Convenient for confirming a
|
|
113
|
+
custom `@Action` is wired up; not safe for anything that moves the vehicle. A
|
|
114
|
+
careless `trigger` can leave the symbolic world in a state the planner doesn't
|
|
115
|
+
trust (e.g. recording `at(wp_end)` without descent ever happening).
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
dynos call trigger "resample_zone(source_zone=site_alpha)" # OK for debug
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
If you find yourself wanting to chain `trigger` calls, you actually want a
|
|
122
|
+
goal. Let the planner sequence them.
|
|
123
|
+
|
|
124
|
+
## Missions
|
|
125
|
+
|
|
126
|
+
A `Mission` chains plan blocks and action blocks. Each block runs to completion
|
|
127
|
+
before the next starts; the planner replans at every block boundary using the
|
|
128
|
+
state after the previous block.
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
from dynos_client import RemoteOrchestrator, Mission
|
|
132
|
+
from dynos_sentry.sentry import Zone, full_coverage_of
|
|
133
|
+
|
|
134
|
+
orch = RemoteOrchestrator.from_config(timeout_s=3600)
|
|
135
|
+
|
|
136
|
+
area_north = Zone("area_north", coordinate_frame="geographic",
|
|
137
|
+
vertices=[[-70.67, 41.525], [-70.66, 41.525], [-70.66, 41.53], [-70.67, 41.53]],
|
|
138
|
+
altitude=70.0, speed=0.8, coverage_width=170.0, robot_width=0.5)
|
|
139
|
+
area_south = Zone("area_south", coordinate_frame="geographic",
|
|
140
|
+
vertices=[[-70.67, 41.52], [-70.66, 41.52], [-70.66, 41.525], [-70.67, 41.525]],
|
|
141
|
+
altitude=70.0, speed=0.8, coverage_width=170.0, robot_width=0.5)
|
|
142
|
+
orch.create_object(area_north)
|
|
143
|
+
orch.create_object(area_south)
|
|
144
|
+
|
|
145
|
+
mission = Mission(name="two_surveys")
|
|
146
|
+
mission.add_plan([full_coverage_of(area_north)])
|
|
147
|
+
mission.add_plan([full_coverage_of(area_south)])
|
|
148
|
+
results = orch.execute_blocks(mission)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Mix action blocks (run a single transition immediately, no planning) with plan
|
|
152
|
+
blocks (set a goal, planner derives the sequence):
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
from dynos_sentry.sentry import full_coverage_of
|
|
156
|
+
|
|
157
|
+
mission = Mission(name="custom_sequence")
|
|
158
|
+
mission.add_action(takeover_control) # primitive, no planning
|
|
159
|
+
mission.add_plan([full_coverage_of(area_north)]) # planned
|
|
160
|
+
mission.add_plan([full_coverage_of(area_south)])
|
|
161
|
+
mission.add_plan([phase_ascending()])
|
|
162
|
+
results = orch.execute_blocks(mission)
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
A single plan block can carry multiple goals; the planner finds one plan that
|
|
166
|
+
achieves all of them. A single action block instructs a specific thing to
|
|
167
|
+
occur, which circumvents a lot of the replanning possibilities (you strip out
|
|
168
|
+
the context), but sometimes things need to be hard-coded in a mission so it's
|
|
169
|
+
available. Prefer @Script for hard sequences, because these can be associated
|
|
170
|
+
with transitions like actions do (see later).
|
|
171
|
+
|
|
172
|
+
## Servants and `dynos connect`
|
|
173
|
+
|
|
174
|
+
When the action's implementation lives on your machine — your ML model, a
|
|
175
|
+
sensor handler, a custom controller — you run a *servant*. The backend keeps
|
|
176
|
+
planning; your code executes the assignments it dispatches. See
|
|
177
|
+
`dynos-adaptive-resampling`'s README for a full worked example.
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
dynos connect my_package.runnable:MyNode
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
A Runnable is a small class with three static methods (`register`,
|
|
184
|
+
`get_start_state`, `get_start_goal`); the servant loop polls the backend for
|
|
185
|
+
assignments and posts results back. `dynos connect` reads the same cached login
|
|
186
|
+
as `dynos call`.
|
|
187
|
+
|
|
188
|
+
## Custom actions in a client-only env
|
|
189
|
+
|
|
190
|
+
`@Action` and `@Script` (the decorators that bind a Python method to a
|
|
191
|
+
`Transition`) live in the DYNOS backend, not in `dynos-client`. If your code
|
|
192
|
+
needs to import them but should also run in environments where the backend
|
|
193
|
+
isn't installed (CI, tests, a public release), use the conditional-import
|
|
194
|
+
recipe:
|
|
195
|
+
|
|
196
|
+
```python
|
|
197
|
+
try:
|
|
198
|
+
from dynos.databases.typed_action import Action, Script
|
|
199
|
+
except ImportError:
|
|
200
|
+
def Action(*a, **kw):
|
|
201
|
+
return (lambda f: f) if not (a and callable(a[0])) else a[0]
|
|
202
|
+
Script = Action
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
This degrades the decorators to no-ops in client-only environments; the class
|
|
206
|
+
still loads, registration is a no-op, and the action never fires (because no
|
|
207
|
+
backend is there to dispatch it).
|
|
208
|
+
|
|
209
|
+
## Long-running plans
|
|
210
|
+
|
|
211
|
+
`dynos call execute` is synchronous: the backend holds the HTTP response open
|
|
212
|
+
until every step in the plan has finished. The CLI defaults to a 30-second
|
|
213
|
+
client-side timeout, so for any plan that takes longer your terminal will print
|
|
214
|
+
`Read timed out` even though the backend is still happily executing. This times
|
|
215
|
+
out the client, not the plan.
|
|
216
|
+
|
|
217
|
+
Two workarounds:
|
|
218
|
+
|
|
219
|
+
1. Use Python with a longer timeout. `RemoteOrchestrator.from_config(timeout_s=3600)`.
|
|
220
|
+
2. Don't watch, open a second terminal and poll. `dynos call state`, `dynos call goal`, `dynos call trace`, `dynos call log --wall`.
|
|
221
|
+
|
|
222
|
+
## Configuration
|
|
223
|
+
|
|
224
|
+
`~/.dynos/config.json` (mode 0o600) stores:
|
|
225
|
+
|
|
226
|
+
| Key | Source |
|
|
227
|
+
|-------------------|---------------------------------------|
|
|
228
|
+
| `backend_url` | `dynos login --to ...` |
|
|
229
|
+
| `token` | `dynos login` (gateway-issued bearer) |
|
|
230
|
+
| `username` | `dynos login` |
|
|
231
|
+
| `role` | `dynos login` (`user`, `admin`) |
|
|
232
|
+
| `default_session` | `dynos session create` / `swap` |
|
|
233
|
+
|
|
234
|
+
Set `DYNOS_CONFIG_PATH=/some/other/file.json` to override the location (handy for tests; the public CLI honours the env var).
|
|
235
|
+
|
|
236
|
+
## Public API
|
|
237
|
+
|
|
238
|
+
| Symbol | Purpose |
|
|
239
|
+
|----------------------------------|----------------------------------------------------------------------|
|
|
240
|
+
| `RemoteOrchestrator` | The HTTP client; mirrors the backend's `Orchestrator` Python API. |
|
|
241
|
+
| `RemoteAuthExpired` | Raised when the cached token is rejected; catch and reauthenticate. |
|
|
242
|
+
| `Mission` | Builder for compound goal sequences. |
|
|
243
|
+
| `PlanBlock` | Plan-derived block within a `Mission` (one or more goals → planner). |
|
|
244
|
+
| `ActionBlock` / `AnyActionBlock` | Single-action block within a `Mission` (no planning). |
|
|
245
|
+
|
|
246
|
+
## Next
|
|
247
|
+
|
|
248
|
+
Install `dynos-sentry-domain` to get `Zone`, `Coordinate`, `full_coverage_of`,
|
|
249
|
+
and the rest of the goal vocabulary. The walkthrough at `user_guide.md` ties
|
|
250
|
+
everything together end-to-end. A simple toy domain (warehouse) is also
|
|
251
|
+
available.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "dynos-client"
|
|
7
|
+
version = "0.1.2"
|
|
8
|
+
description = "Client and CLI for orchestrating DYNOS backends remotely"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "Apache-2.0" }
|
|
11
|
+
requires-python = ">=3.8"
|
|
12
|
+
dependencies = ["dynos-core>=0.1.2", "requests", "typer", "bcrypt>=4.0"]
|
|
13
|
+
|
|
14
|
+
[project.scripts]
|
|
15
|
+
dynos = "dynos_client.cli:main"
|
|
16
|
+
|
|
17
|
+
[tool.setuptools.packages.find]
|
|
18
|
+
where = ["src"]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""dynos-client: distributable Python client for DYNOS.
|
|
2
|
+
|
|
3
|
+
Infrastructure package: HTTP client, mission builder, CLI entry point.
|
|
4
|
+
No domain vocabulary (that lives in per-platform packages like dynos-sentry-domain).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dynos_client.remote_orchestrator import RemoteAuthExpired, RemoteOrchestrator
|
|
8
|
+
from dynos_client.mission import Mission, AnyActionBlock, ActionBlock, PlanBlock
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"RemoteAuthExpired",
|
|
12
|
+
"RemoteOrchestrator",
|
|
13
|
+
"Mission",
|
|
14
|
+
"AnyActionBlock",
|
|
15
|
+
"ActionBlock",
|
|
16
|
+
"PlanBlock",
|
|
17
|
+
]
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Client-side @Action and @Script decorators.
|
|
2
|
+
|
|
3
|
+
Lightweight fallbacks that store transition metadata on decorated
|
|
4
|
+
methods so ``dynos connect`` can discover transitions without the
|
|
5
|
+
backend. When the backend IS installed, its @Action takes precedence.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import inspect
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def Action(transition_or_func=None, *, transition=None, **_):
|
|
12
|
+
"""Decorate a method as an action bound to a transition."""
|
|
13
|
+
if transition_or_func is None or not callable(transition_or_func):
|
|
14
|
+
t = transition_or_func or transition
|
|
15
|
+
|
|
16
|
+
def decorator(func):
|
|
17
|
+
func._client_transition_name = getattr(t, "name", None)
|
|
18
|
+
func._client_params_type = getattr(t, "params_type", None)
|
|
19
|
+
return func
|
|
20
|
+
|
|
21
|
+
return decorator
|
|
22
|
+
transition_or_func._client_transition_name = None
|
|
23
|
+
transition_or_func._client_params_type = None
|
|
24
|
+
return transition_or_func
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
Script = Action
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_transition_names(cls):
|
|
31
|
+
"""Scan a class for @Action-decorated methods and return transition names.
|
|
32
|
+
|
|
33
|
+
Works with both client-side and backend decorators.
|
|
34
|
+
"""
|
|
35
|
+
names = []
|
|
36
|
+
for _, member in inspect.getmembers(cls):
|
|
37
|
+
t_name = getattr(member, "_client_transition_name", None)
|
|
38
|
+
if t_name is not None:
|
|
39
|
+
names.append(t_name)
|
|
40
|
+
continue
|
|
41
|
+
# Backend ActionCallback objects
|
|
42
|
+
if hasattr(member, "transition") and hasattr(member, "func"):
|
|
43
|
+
t = getattr(member, "transition", None)
|
|
44
|
+
if t is not None and hasattr(t, "name"):
|
|
45
|
+
names.append(t.name)
|
|
46
|
+
return names
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_action_methods(cls):
|
|
50
|
+
"""Return ``[(transition_name, method_name, params_type), ...]`` for *cls*.
|
|
51
|
+
|
|
52
|
+
Used by ``RemoteOrchestrator.register_class`` to build the local
|
|
53
|
+
dispatch table. Handles both the client decorator
|
|
54
|
+
(``_client_transition_name`` / ``_client_params_type`` attrs) and
|
|
55
|
+
the backend ``ActionCallback`` shape (``.transition`` / ``.func``).
|
|
56
|
+
"""
|
|
57
|
+
out = []
|
|
58
|
+
for member_name, member in inspect.getmembers(cls):
|
|
59
|
+
# Client decorator: attribute markers on the function itself.
|
|
60
|
+
t_name = getattr(member, "_client_transition_name", None)
|
|
61
|
+
if t_name is not None:
|
|
62
|
+
params_type = getattr(member, "_client_params_type", None)
|
|
63
|
+
out.append((t_name, member_name, params_type))
|
|
64
|
+
continue
|
|
65
|
+
# Backend ActionCallback: holds a Transition object and the func.
|
|
66
|
+
if hasattr(member, "transition") and hasattr(member, "func"):
|
|
67
|
+
t = getattr(member, "transition", None)
|
|
68
|
+
if t is not None and hasattr(t, "name"):
|
|
69
|
+
out.append((t.name, member_name, getattr(t, "params_type", None)))
|
|
70
|
+
return out
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Minimal client-side ``ActionContext`` for servant-dispatched actions.
|
|
2
|
+
|
|
3
|
+
The backend's ``ActionContext`` (``src/dynos/symbols/types.py::ActionContext``)
|
|
4
|
+
is a richer Protocol with hardware/sensor/cancellation hooks. Those rely on
|
|
5
|
+
backend infrastructure that doesn't exist on a remote servant machine, so
|
|
6
|
+
this module only implements the slice user ``@Action`` methods actually need
|
|
7
|
+
when dispatched via ``dynos connect``: a structured logger, the audit ID, and
|
|
8
|
+
the transition name.
|
|
9
|
+
|
|
10
|
+
If a user's ``@Action`` reaches for ``ctx.report_progress`` /
|
|
11
|
+
``ctx.check_cancelled`` / ``ctx.read_sensor`` / ``ctx.call_hardware`` from
|
|
12
|
+
the servant side, an ``AttributeError`` is the right outcome; the action
|
|
13
|
+
is using backend-only machinery that isn't available remotely.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class _ClientActionLogger:
|
|
22
|
+
"""Adapter wrapping a stdlib ``logging.Logger`` to match the backend's
|
|
23
|
+
``ActionLogger`` Protocol surface (``info``/``debug``/``warn``/``error``,
|
|
24
|
+
arbitrary kwargs accepted and dropped)."""
|
|
25
|
+
|
|
26
|
+
_log: logging.Logger
|
|
27
|
+
|
|
28
|
+
def info(self, msg: str, **_kw: object) -> None:
|
|
29
|
+
self._log.info(msg)
|
|
30
|
+
|
|
31
|
+
def debug(self, msg: str, **_kw: object) -> None:
|
|
32
|
+
self._log.debug(msg)
|
|
33
|
+
|
|
34
|
+
def warn(self, msg: str, **_kw: object) -> None:
|
|
35
|
+
self._log.warning(msg)
|
|
36
|
+
|
|
37
|
+
warning = warn
|
|
38
|
+
|
|
39
|
+
def error(self, msg: str, **_kw: object) -> None:
|
|
40
|
+
self._log.error(msg)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class ClientActionContext:
|
|
45
|
+
"""Concrete ``ActionContext`` for client-side ``@Action`` dispatch.
|
|
46
|
+
|
|
47
|
+
Implements the ``log``, ``audit_id``, ``transition_name`` slice of the
|
|
48
|
+
backend ``ActionContext`` Protocol. Constructed per-call by
|
|
49
|
+
``RemoteOrchestrator.trigger_action``.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
transition_name: str
|
|
53
|
+
audit_id: str
|
|
54
|
+
log: _ClientActionLogger = field(
|
|
55
|
+
default_factory=lambda: _ClientActionLogger(logging.getLogger("dynos.servant"))
|
|
56
|
+
)
|