dosync 0.4.1__py3-none-any.whl

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.
dosync/cli.py ADDED
@@ -0,0 +1,61 @@
1
+ """Console entry points for an installed DoSync.
2
+
3
+ `pip install dosync` should give you a hub you can actually run — not a library
4
+ you still have to wire up. This module is the thin layer that makes that true.
5
+ """
6
+ import argparse
7
+ import os
8
+ import sys
9
+
10
+
11
+ def hub(argv=None) -> None:
12
+ """Run the DoSync hub (console script: `dosync-hub`).
13
+
14
+ Deliberately thin: every real setting is already an environment variable
15
+ read by the application itself, because a deployment configures a hub
16
+ through its environment (systemd, Docker, compose), not through flags that
17
+ would then exist in two places and disagree. The flags below are only the
18
+ ones you need before the application starts — where to listen.
19
+ """
20
+ parser = argparse.ArgumentParser(
21
+ prog="dosync-hub",
22
+ description="Run the DoSync hub.",
23
+ epilog=(
24
+ "Configuration is by environment variable, e.g.:\n"
25
+ " DOSYNC_DB=/var/lib/dosync/dosync.db database path\n"
26
+ " DOSYNC_AUTH=true require bearer auth\n"
27
+ " DOSYNC_TOKEN=<token> the bearer token\n"
28
+ " DOSYNC_POLICIES=/etc/dosync/policies.json deployment policies\n"
29
+ " DOSYNC_CERTIFY=true certification mode (in-memory DB)\n"
30
+ "\nSee the deployment guide for the full list."
31
+ ),
32
+ formatter_class=argparse.RawDescriptionHelpFormatter,
33
+ )
34
+ parser.add_argument("--host", default=os.environ.get("DOSYNC_HOST", "127.0.0.1"),
35
+ help="interface to bind (default: 127.0.0.1 — loopback. "
36
+ "Use 0.0.0.0 to accept connections from the network)")
37
+ parser.add_argument("--port", type=int, default=int(os.environ.get("DOSYNC_PORT", "47200")),
38
+ help="port to listen on (default: 47200)")
39
+ parser.add_argument("--reload", action="store_true",
40
+ help="auto-reload on code changes (development only)")
41
+ parser.add_argument("--log-level", default="info",
42
+ choices=["critical", "error", "warning", "info", "debug", "trace"])
43
+ args = parser.parse_args(argv)
44
+
45
+ try:
46
+ import uvicorn
47
+ except ImportError: # pragma: no cover - install-time problem, not runtime
48
+ sys.exit("uvicorn is required to run the hub: pip install 'dosync'")
49
+
50
+ # Publish the real values so the application can report them accurately: the
51
+ # startup log used to announce a hardcoded 47200 no matter where the hub was
52
+ # actually listening.
53
+ os.environ["DOSYNC_PORT"] = str(args.port)
54
+ os.environ["DOSYNC_HOST"] = args.host
55
+
56
+ uvicorn.run("dosync.server:app", host=args.host, port=args.port,
57
+ reload=args.reload, log_level=args.log_level)
58
+
59
+
60
+ if __name__ == "__main__": # pragma: no cover
61
+ hub()
@@ -0,0 +1,306 @@
1
+ """
2
+ DoSync — Composite operations (a coordinated sequence of atomic operations).
3
+ ============================================================================
4
+
5
+ `operations.py` models a SINGLE long-running action: one device, one action, one
6
+ state machine (a drone's `go_to`, an oven's `bake`). That is the ATOM.
7
+
8
+ This module models the MOLECULE: a high-level goal that decomposes into an ORDERED
9
+ SEQUENCE of atomic operations, coordinated continuously by the hub. "Inspect the
10
+ area" is not one movement — it is take_off, then go_to waypoint 1, then 2, 3, 4,
11
+ then return_home, then land. A brain directing a body to walk to a point is not one
12
+ instruction; it is a continuous stream of coordinated sub-actions, each one shaped
13
+ by what the previous one actually produced.
14
+
15
+ DESIGN (validated by a multi-pass expert panel incl. a drone manufacturer, a pilot,
16
+ and two systems professors):
17
+
18
+ * This is a SEPARATE TYPE from Operation, not an extension of it. The panel
19
+ rejected stuffing a waypoint list into the atomic Operation: an atom is
20
+ deliberately atomic (one device, one action), and a composite has a state
21
+ machine of a DIFFERENT LEVEL — `planning -> in_transit -> returning ->
22
+ completed/aborted` — with concepts (returning, abort-the-whole-sequence) that
23
+ only make sense for a SET, never for a single atom. Two genuinely different
24
+ state machines justify two types; it is not duplication.
25
+
26
+ * GENERIC, not drone-specific. The panel was explicit: `inspect_area` is the
27
+ INTENT (drone domain); the structure that executes it is generic. A robotic arm
28
+ (reach, grip, move, release), a 3D printer (preheat, print, cool), a rover —
29
+ all are coordinated sequences of atomic operations. The name belongs to the
30
+ abstraction level, not the domain. So: CompositeOperation, not Mission.
31
+
32
+ * The atom stays PURE. The atomic Operation and its reconciler are untouched.
33
+ A CompositeOperation REFERENCES its sub-operations by their operation_id (for
34
+ hierarchical audit-log traceability: "composite started -> [waypoint 1: op_abc
35
+ completed] -> ... -> composite completed"), but never reaches inside their state
36
+ machine. It coordinates; the atom executes.
37
+
38
+ * It REUSES the carpentry of operations.py (the StateTransition record, the
39
+ to_dict/from_dict discipline that faithfully preserves time_in_state) but has
40
+ its OWN, simpler, mission-level state machine. Same tools, different machine.
41
+
42
+ * SILENCE IS NOT SUCCESS — inherited. A composite only advances when its current
43
+ sub-operation produces a positive signal (the atom reaches `completed`). It
44
+ never advances on a timer. This is the whole point of the closed loop: the
45
+ brain reacts to what the body actually did, waypoint by waypoint.
46
+ """
47
+
48
+ from __future__ import annotations
49
+
50
+ import time
51
+ import uuid
52
+ from dataclasses import dataclass, field
53
+ from enum import Enum
54
+
55
+ from .operations import StateTransition # reuse the atomic transition record
56
+
57
+
58
+ class CompositeState(str, Enum):
59
+ """Mission-level lifecycle states. Deliberately distinct from (and simpler than)
60
+ the atomic OperationState — these describe a SEQUENCE, not a single action.
61
+
62
+ Inherits from str so values serialize cleanly to JSON / SQLite, consistent with
63
+ the rest of DoSync's enums.
64
+ """
65
+
66
+ PLANNING = "planning" # composed, sub-operations not yet started
67
+ IN_TRANSIT = "in_transit" # executing the ordered steps (the main phase)
68
+ RETURNING = "returning" # executing the final return-to-base step
69
+ COMPLETED = "completed" # all steps finished successfully (positive signal)
70
+ ABORTED = "aborted" # stopped early — a guard fired, or a step failed.
71
+ # On abort, remaining steps are cancelled and the
72
+ # safe default (return home) is what RETURNING is
73
+ # for; a composite that cannot even return goes
74
+ # straight to ABORTED.
75
+ FAILED = "failed" # could not complete and could not safely return
76
+
77
+
78
+ # States in which a composite is finished and will not transition further.
79
+ COMPOSITE_TERMINAL_STATES: frozenset[CompositeState] = frozenset({
80
+ CompositeState.COMPLETED,
81
+ CompositeState.ABORTED,
82
+ CompositeState.FAILED,
83
+ })
84
+
85
+ # Legal mission-level transitions.
86
+ # planning -> in_transit (start) | aborted (cancelled before takeoff) | failed
87
+ # in_transit -> returning (steps done, head home) | aborted (guard/failure mid-run)
88
+ # | failed (cannot continue nor return)
89
+ # returning -> completed (home reached) | failed (could not get home)
90
+ # terminals: no outgoing transitions
91
+ _ALLOWED_COMPOSITE_TRANSITIONS: dict[CompositeState, frozenset[CompositeState]] = {
92
+ CompositeState.PLANNING: frozenset({
93
+ CompositeState.IN_TRANSIT,
94
+ CompositeState.ABORTED,
95
+ CompositeState.FAILED,
96
+ }),
97
+ CompositeState.IN_TRANSIT: frozenset({
98
+ CompositeState.RETURNING,
99
+ CompositeState.ABORTED,
100
+ CompositeState.FAILED,
101
+ }),
102
+ CompositeState.RETURNING: frozenset({
103
+ CompositeState.COMPLETED,
104
+ CompositeState.ABORTED, # return interrupted (e.g. human took control)
105
+ CompositeState.FAILED,
106
+ }),
107
+ CompositeState.COMPLETED: frozenset(),
108
+ CompositeState.ABORTED: frozenset(),
109
+ CompositeState.FAILED: frozenset(),
110
+ }
111
+
112
+
113
+ class InvalidCompositeTransition(Exception):
114
+ """Raised when a mission-level state transition is not permitted."""
115
+
116
+
117
+ @dataclass
118
+ class CompositeStep:
119
+ """One step in a composite: a single atomic action to dispatch, plus the
120
+ operation_id of the atomic Operation once it is created (for hierarchical
121
+ traceability). `kind` labels the step's role in the sequence so the supervisor
122
+ and the audit log can reason about it without parsing params.
123
+ """
124
+ device_id: str
125
+ action: str # e.g. "take_off", "go_to", "return_home"
126
+ params: dict = field(default_factory=dict)
127
+ kind: str = "step" # "takeoff" | "waypoint" | "return" | "step"
128
+ operation_id: str | None = None # set when the atomic op is created
129
+ done: bool = False # set when this step reaches a terminal atom state
130
+
131
+ def to_dict(self) -> dict:
132
+ return {
133
+ "device_id": self.device_id,
134
+ "action": self.action,
135
+ "params": self.params,
136
+ "kind": self.kind,
137
+ "operation_id": self.operation_id,
138
+ "done": self.done,
139
+ }
140
+
141
+ @classmethod
142
+ def from_dict(cls, d: dict) -> "CompositeStep":
143
+ return cls(
144
+ device_id=d["device_id"],
145
+ action=d["action"],
146
+ params=d.get("params", {}),
147
+ kind=d.get("kind", "step"),
148
+ operation_id=d.get("operation_id"),
149
+ done=d.get("done", False),
150
+ )
151
+
152
+
153
+ @dataclass
154
+ class CompositeOperation:
155
+ """A high-level operation coordinating an ordered sequence of atomic operations.
156
+
157
+ The hub creates one when a composing intent (e.g. inspect_area) resolves into a
158
+ multi-step plan. The supervisor (separate component) drives it forward: it
159
+ dispatches the current step, waits for that step's atomic operation to reach a
160
+ positive terminal state, advances the index, and repeats — reacting in real time
161
+ to telemetry, never on a timer.
162
+ """
163
+ device_id: str # the primary device (e.g. the drone)
164
+ intent: str # the originating intent (e.g. "inspect_area")
165
+ steps: list[CompositeStep]
166
+ composite_id: str = field(default_factory=lambda: f"comp_{uuid.uuid4().hex[:12]}")
167
+ state: CompositeState = CompositeState.PLANNING
168
+ current_index: int = 0 # which step is active
169
+ created_at: float = field(default_factory=time.time)
170
+ state_entered_at: float = field(default_factory=time.time)
171
+ history: list[StateTransition] = field(default_factory=list)
172
+ context: dict = field(default_factory=dict) # e.g. geofence center/radius, altitude
173
+
174
+ def __post_init__(self):
175
+ if isinstance(self.state, str):
176
+ self.state = CompositeState(self.state)
177
+ if not self.history:
178
+ # Reuse the atomic StateTransition record; from_state None = initial entry.
179
+ self.history.append(StateTransition(
180
+ from_state=None, to_state=self.state, # type: ignore[arg-type]
181
+ at=self.created_at, reason="composed",
182
+ ))
183
+
184
+ # ── State ────────────────────────────────────────────────────────────────
185
+ @property
186
+ def is_terminal(self) -> bool:
187
+ return self.state in COMPOSITE_TERMINAL_STATES
188
+
189
+ def time_in_state(self, now: float | None = None) -> float:
190
+ """Seconds spent in the current mission-level state. First-class data for a
191
+ guard/supervisor to reason about (this module never acts on it itself)."""
192
+ return (now if now is not None else time.time()) - self.state_entered_at
193
+
194
+ def can_transition_to(self, target: CompositeState) -> bool:
195
+ if isinstance(target, str):
196
+ target = CompositeState(target)
197
+ return target in _ALLOWED_COMPOSITE_TRANSITIONS.get(self.state, frozenset())
198
+
199
+ def transition_to(self, target: CompositeState, reason: str = "",
200
+ now: float | None = None) -> StateTransition:
201
+ """Advance the mission-level state, recording the transition. Raises
202
+ InvalidCompositeTransition on an illegal move — a finished mission does not
203
+ resume, and an illegal jump is never silently accepted."""
204
+ if isinstance(target, str):
205
+ target = CompositeState(target)
206
+ ts = now if now is not None else time.time()
207
+
208
+ if self.is_terminal:
209
+ raise InvalidCompositeTransition(
210
+ f"composite {self.composite_id} is terminal ({self.state.value}); "
211
+ f"cannot transition to {target.value}."
212
+ )
213
+ if target not in _ALLOWED_COMPOSITE_TRANSITIONS.get(self.state, frozenset()):
214
+ raise InvalidCompositeTransition(
215
+ f"illegal composite transition {self.state.value} -> {target.value} "
216
+ f"for {self.composite_id}."
217
+ )
218
+
219
+ transition = StateTransition(from_state=self.state, to_state=target, # type: ignore[arg-type]
220
+ at=ts, reason=reason)
221
+ self.history.append(transition)
222
+ self.state = target
223
+ self.state_entered_at = ts
224
+ return transition
225
+
226
+ # ── Step coordination (the supervisor uses these; they hold no policy) ─────
227
+ @property
228
+ def current_step(self) -> CompositeStep | None:
229
+ """The step currently being executed, or None if the index is past the end."""
230
+ if 0 <= self.current_index < len(self.steps):
231
+ return self.steps[self.current_index]
232
+ return None
233
+
234
+ @property
235
+ def remaining_steps(self) -> list[CompositeStep]:
236
+ """Steps not yet started (from current_index onward). Used on abort to know
237
+ what must be cancelled."""
238
+ return self.steps[self.current_index:]
239
+
240
+ def advance(self) -> CompositeStep | None:
241
+ """Mark the current step done and move to the next. Returns the new current
242
+ step, or None if the sequence is exhausted. Pure bookkeeping — the supervisor
243
+ decides WHETHER to advance (only on a positive signal); this just moves the
244
+ cursor."""
245
+ step = self.current_step
246
+ if step is not None:
247
+ step.done = True
248
+ self.current_index += 1
249
+ return self.current_step
250
+
251
+ @property
252
+ def all_steps_done(self) -> bool:
253
+ return all(s.done for s in self.steps) if self.steps else True
254
+
255
+ # ── Persistence (reuses the to_dict/from_dict discipline of operations.py) ──
256
+ def to_dict(self) -> dict:
257
+ return {
258
+ "composite_id": self.composite_id,
259
+ "device_id": self.device_id,
260
+ "intent": self.intent,
261
+ "state": self.state.value,
262
+ "current_index": self.current_index,
263
+ "created_at": self.created_at,
264
+ "state_entered_at": self.state_entered_at,
265
+ "context": self.context,
266
+ "steps": [s.to_dict() for s in self.steps],
267
+ "history": [
268
+ {
269
+ "from_state": t.from_state.value if t.from_state else None,
270
+ "to_state": t.to_state.value,
271
+ "at": t.at,
272
+ "reason": t.reason,
273
+ }
274
+ for t in self.history
275
+ ],
276
+ }
277
+
278
+ @classmethod
279
+ def from_dict(cls, data: dict) -> "CompositeOperation":
280
+ """Rehydrate from a persisted dict — the inverse of to_dict(). Fidelity
281
+ matters: restore the real state, the real state_entered_at, and the full
282
+ history, exactly as operations.py does for the atom. A naive constructor call
283
+ would let __post_init__ synthesize a fresh history and reset state_entered_at
284
+ to now, destroying time_in_state — the signal a guard relies on to spot a
285
+ stalled mission. So build, then overwrite with the stored values."""
286
+ comp = cls(
287
+ device_id=data["device_id"],
288
+ intent=data["intent"],
289
+ steps=[CompositeStep.from_dict(s) for s in data.get("steps", [])],
290
+ composite_id=data["composite_id"],
291
+ state=CompositeState(data["state"]),
292
+ current_index=data.get("current_index", 0),
293
+ created_at=data["created_at"],
294
+ context=data.get("context", {}),
295
+ )
296
+ comp.state_entered_at = data["state_entered_at"]
297
+ comp.history = [
298
+ StateTransition(
299
+ from_state=CompositeState(h["from_state"]) if h["from_state"] else None, # type: ignore[arg-type]
300
+ to_state=CompositeState(h["to_state"]), # type: ignore[arg-type]
301
+ at=h["at"],
302
+ reason=h.get("reason", ""),
303
+ )
304
+ for h in data.get("history", [])
305
+ ]
306
+ return comp