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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,4 @@
1
+ """Allow ``python -m dynos_client`` to run the CLI."""
2
+ from dynos_client.cli import main
3
+
4
+ main()
@@ -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
+ )