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/operations.py ADDED
@@ -0,0 +1,331 @@
1
+ """
2
+ DoSync — Long-running operation lifecycle.
3
+ ==========================================
4
+
5
+ Most DoSync actions are instantaneous: send the command, get a result, done.
6
+ That is the `instant` execution model and it is unchanged by this module.
7
+
8
+ Some actions take time and pass through states while they run — "fly to this
9
+ waypoint" arms, takes off, navigates, and only then arrives. A network timeout
10
+ is not the same as a failure; a pilot grabbing the sticks is not a failure at
11
+ all. This module models that lifecycle. It is the foundation the drone adapter
12
+ will build on, but it knows nothing about drones, MAVLink, or any hardware —
13
+ it is pure state-machine logic, fully unit-testable on its own.
14
+
15
+ DESIGN (validated by a two-pass expert panel incl. a drone manufacturer and a
16
+ real pilot):
17
+
18
+ * The HUB owns operation state, not the device. The device reports raw facts
19
+ ("arrived", "failed", "pilot took control"); the hub models the lifecycle.
20
+ The device remains the source of truth for its *real* state — the hub keeps
21
+ a replica reconciled against telemetry and never assumes. (Reconciliation
22
+ itself lives in a later layer; this module defines the states and the legal
23
+ transitions between them.)
24
+
25
+ * The state machine is HIERARCHICAL, not flat:
26
+ - A minimal CORE every long-running device satisfies:
27
+ pending -> in_progress -> completed | failed
28
+ - First-class added states (always available):
29
+ rejected — the device refused by its physical state (not armed,
30
+ no GPS, failsafe active). "Can't even start" — distinct
31
+ from failing partway through.
32
+ cancelled — explicit cancellation (only if supports_cancel).
33
+ interrupted — human intervention (a pilot takes the sticks). NOT a
34
+ failure: a normal, expected outcome. Does NOT resume —
35
+ if the system retakes control it is a NEW operation.
36
+ - Optional SUB-STATES, only meaningful for richer profiles:
37
+ preparing — a pre-action setup phase, made visible instead of
38
+ hidden inside an opaque in_progress. Universal: a
39
+ camera focusing, a blind releasing its lock, an
40
+ aerial vehicle arming/taking off. The device-
41
+ specific sub-phase is carried in Operation.phase
42
+ (the protocol understands `preparing`; a domain
43
+ profile specializes it).
44
+ paused_by_device — the device paused *itself*. Universal: a speaker
45
+ whose stream dropped, a camera whose storage filled,
46
+ an aerial vehicle holding position in wind. Neither
47
+ failed, nor interrupted, nor advancing.
48
+ reconciling — transient state after a hub restart: the hub
49
+ reconciles against telemetry before assuming
50
+ anything. Generic; its criticality varies by domain.
51
+ A simple device (an oven) only ever sees the core. A speaker adds cancellation;
52
+ a camera adds preparing; an aerial vehicle uses all of it. Each device declares
53
+ how rich it is — the names belong to the protocol, the domain detail to `phase`.
54
+
55
+ * SILENCE IS NOT SUCCESS. Reaching `completed` requires a positive signal, never
56
+ the mere absence of an error or a timeout. This is the opposite of the instant
57
+ model and is non-negotiable where the error is physical.
58
+
59
+ * Time-in-state is first-class data: every state records when it was entered,
60
+ so a Policy Engine (separate component) can decide what to do about a state
61
+ that has lasted too long. This module exposes the timing; it does NOT decide
62
+ policy.
63
+ """
64
+
65
+ from __future__ import annotations
66
+
67
+ import time
68
+ import uuid
69
+ from dataclasses import dataclass, field
70
+ from enum import Enum
71
+
72
+
73
+ class OperationState(str, Enum):
74
+ """Lifecycle states of a long-running operation.
75
+
76
+ Inherits from str so values serialize cleanly to JSON / SQLite and compare
77
+ against plain strings, consistent with the rest of DoSync's enums.
78
+ """
79
+
80
+ # ── Core (every long_running device) ──────────────────────────────────────
81
+ PENDING = "pending" # accepted by the hub, not yet confirmed started
82
+ IN_PROGRESS = "in_progress" # confirmed underway (positive signal received)
83
+ COMPLETED = "completed" # confirmed finished successfully (positive signal)
84
+ FAILED = "failed" # failed partway through
85
+
86
+ # ── First-class added states (always available) ──────────────────────────
87
+ REJECTED = "rejected" # device refused by physical state — never started
88
+ CANCELLED = "cancelled" # explicit cancellation
89
+ INTERRUPTED = "interrupted" # human intervention — normal outcome, does not resume
90
+
91
+ # ── Optional sub-states (richer profiles only) ────────────────────────────
92
+ PREPARING = "preparing" # pre-action setup phase (camera focusing, blind
93
+ # releasing its lock, a drone arming/taking off).
94
+ # Device-specific sub-phase carried in Operation.phase.
95
+ PAUSED_BY_DEVICE = "paused_by_device" # the device paused itself (speaker lost its
96
+ # stream, camera storage full, drone holding in wind)
97
+ RECONCILING = "reconciling" # transient: hub reconciling against telemetry after restart
98
+
99
+
100
+ # States in which an operation is finished and will not transition further.
101
+ TERMINAL_STATES: frozenset[OperationState] = frozenset({
102
+ OperationState.COMPLETED,
103
+ OperationState.FAILED,
104
+ OperationState.REJECTED,
105
+ OperationState.CANCELLED,
106
+ OperationState.INTERRUPTED,
107
+ })
108
+
109
+ # Sub-states that only apply to telemetry-capable profiles. A core-only device
110
+ # (declares long_running without emits_telemetry) must never be driven into one.
111
+ TELEMETRY_ONLY_STATES: frozenset[OperationState] = frozenset({
112
+ OperationState.PREPARING,
113
+ OperationState.PAUSED_BY_DEVICE,
114
+ OperationState.RECONCILING,
115
+ })
116
+
117
+ # Legal transitions. Key = from-state, value = set of allowed next states.
118
+ # Encodes the panel's rules:
119
+ # - core flow pending -> in_progress -> completed/failed
120
+ # - rejected only from pending (can't even start)
121
+ # - interrupted/failed/cancelled reachable from any active (non-terminal) state
122
+ # - terminal states have no outgoing transitions (interrupted does NOT resume)
123
+ # - sub-state (preparing) sits between pending and in_progress
124
+ # - paused_by_device is reachable from active states and can resume
125
+ # - reconciling can reach any non-pending outcome once telemetry clarifies
126
+ _ALLOWED_TRANSITIONS: dict[OperationState, frozenset[OperationState]] = {
127
+ OperationState.PENDING: frozenset({
128
+ OperationState.PREPARING,
129
+ OperationState.IN_PROGRESS,
130
+ OperationState.REJECTED,
131
+ OperationState.CANCELLED,
132
+ OperationState.INTERRUPTED,
133
+ OperationState.FAILED,
134
+ OperationState.RECONCILING, # recovered on restart while pending
135
+ }),
136
+ OperationState.PREPARING: frozenset({
137
+ OperationState.IN_PROGRESS,
138
+ OperationState.FAILED,
139
+ OperationState.CANCELLED,
140
+ OperationState.INTERRUPTED,
141
+ OperationState.REJECTED,
142
+ OperationState.RECONCILING, # recovered on restart while preparing
143
+ }),
144
+ OperationState.IN_PROGRESS: frozenset({
145
+ OperationState.COMPLETED,
146
+ OperationState.FAILED,
147
+ OperationState.CANCELLED,
148
+ OperationState.INTERRUPTED,
149
+ OperationState.PAUSED_BY_DEVICE,
150
+ OperationState.RECONCILING, # recovered on restart while in progress
151
+ }),
152
+ OperationState.PAUSED_BY_DEVICE: frozenset({
153
+ OperationState.IN_PROGRESS, # device resumes on its own (wind eased, stream back)
154
+ OperationState.FAILED,
155
+ OperationState.CANCELLED,
156
+ OperationState.INTERRUPTED,
157
+ OperationState.RECONCILING, # recovered on restart while paused
158
+ }),
159
+ OperationState.RECONCILING: frozenset({
160
+ OperationState.IN_PROGRESS,
161
+ OperationState.PAUSED_BY_DEVICE,
162
+ OperationState.COMPLETED,
163
+ OperationState.FAILED,
164
+ OperationState.INTERRUPTED,
165
+ OperationState.CANCELLED,
166
+ }),
167
+ # Terminal states: no outgoing transitions.
168
+ OperationState.COMPLETED: frozenset(),
169
+ OperationState.FAILED: frozenset(),
170
+ OperationState.REJECTED: frozenset(),
171
+ OperationState.CANCELLED: frozenset(),
172
+ OperationState.INTERRUPTED: frozenset(),
173
+ }
174
+
175
+
176
+ class InvalidTransition(Exception):
177
+ """Raised when a state transition is not permitted by the state machine."""
178
+
179
+
180
+ @dataclass
181
+ class StateTransition:
182
+ """A single recorded transition — the audit trail of an operation's life."""
183
+ from_state: OperationState | None # None for the initial entry into PENDING
184
+ to_state: OperationState
185
+ at: float # unix timestamp
186
+ reason: str = "" # human-readable cause (e.g. "telemetry: arrived")
187
+
188
+
189
+ @dataclass
190
+ class Operation:
191
+ """A long-running operation tracked by the hub.
192
+
193
+ The hub creates one of these when a long_running action begins, then advances
194
+ it through the state machine as positive signals arrive. The device is the
195
+ source of truth; this object is the hub's reconciled replica.
196
+ """
197
+ device_id: str
198
+ action: str
199
+ operation_id: str = field(default_factory=lambda: f"op_{uuid.uuid4().hex[:12]}")
200
+ state: OperationState = OperationState.PENDING
201
+ created_at: float = field(default_factory=time.time)
202
+ state_entered_at: float = field(default_factory=time.time)
203
+ history: list[StateTransition] = field(default_factory=list)
204
+ # Whether the device backing this operation emits telemetry. Gates the
205
+ # richer sub-states so a core-only device can't be driven into them.
206
+ telemetry_capable: bool = False
207
+ # Optional domain-specific sub-phase detail, meaningful while in PREPARING.
208
+ # The protocol understands the generic PREPARING state; a domain profile can
209
+ # specialize it — e.g. an aerial vehicle sets phase="arming" then "taking_off",
210
+ # a camera "focusing". The protocol never interprets this string; the device
211
+ # and its operator do. Keeps the core universal while preserving granularity.
212
+ phase: str = ""
213
+
214
+ def __post_init__(self):
215
+ if isinstance(self.state, str):
216
+ self.state = OperationState(self.state)
217
+ # Record the initial entry into the starting state.
218
+ if not self.history:
219
+ self.history.append(StateTransition(
220
+ from_state=None, to_state=self.state,
221
+ at=self.created_at, reason="created",
222
+ ))
223
+
224
+ @property
225
+ def is_terminal(self) -> bool:
226
+ return self.state in TERMINAL_STATES
227
+
228
+ def time_in_state(self, now: float | None = None) -> float:
229
+ """Seconds spent in the current state. First-class data for the Policy
230
+ Engine to reason about (this module never acts on it itself)."""
231
+ return (now if now is not None else time.time()) - self.state_entered_at
232
+
233
+ def can_transition_to(self, target: OperationState) -> bool:
234
+ """Whether `target` is a legal next state from the current one, honoring
235
+ both the transition table and the telemetry gate."""
236
+ if isinstance(target, str):
237
+ target = OperationState(target)
238
+ if target in TELEMETRY_ONLY_STATES and not self.telemetry_capable:
239
+ return False
240
+ return target in _ALLOWED_TRANSITIONS.get(self.state, frozenset())
241
+
242
+ def transition_to(self, target: OperationState, reason: str = "",
243
+ now: float | None = None) -> "StateTransition":
244
+ """Advance to `target`, recording the transition. Raises InvalidTransition
245
+ if the move is not permitted — silence is never success, and an illegal
246
+ jump is never silently accepted."""
247
+ if isinstance(target, str):
248
+ target = OperationState(target)
249
+ ts = now if now is not None else time.time()
250
+
251
+ if self.is_terminal:
252
+ raise InvalidTransition(
253
+ f"operation {self.operation_id} is terminal ({self.state.value}); "
254
+ f"cannot transition to {target.value}. "
255
+ f"(An interrupted/finished operation does not resume — start a new one.)"
256
+ )
257
+ if target in TELEMETRY_ONLY_STATES and not self.telemetry_capable:
258
+ raise InvalidTransition(
259
+ f"state {target.value} requires a telemetry-capable device; "
260
+ f"operation {self.operation_id} backs a core-only device."
261
+ )
262
+ if target not in _ALLOWED_TRANSITIONS.get(self.state, frozenset()):
263
+ raise InvalidTransition(
264
+ f"illegal transition {self.state.value} -> {target.value} "
265
+ f"for operation {self.operation_id}."
266
+ )
267
+
268
+ transition = StateTransition(from_state=self.state, to_state=target,
269
+ at=ts, reason=reason)
270
+ self.history.append(transition)
271
+ self.state = target
272
+ self.state_entered_at = ts
273
+ return transition
274
+
275
+ def to_dict(self) -> dict:
276
+ """Serialize for persistence / API. Mirrors the project's dataclass style."""
277
+ return {
278
+ "operation_id": self.operation_id,
279
+ "device_id": self.device_id,
280
+ "action": self.action,
281
+ "state": self.state.value,
282
+ "created_at": self.created_at,
283
+ "state_entered_at": self.state_entered_at,
284
+ "telemetry_capable": self.telemetry_capable,
285
+ "phase": self.phase,
286
+ "history": [
287
+ {
288
+ "from_state": t.from_state.value if t.from_state else None,
289
+ "to_state": t.to_state.value,
290
+ "at": t.at,
291
+ "reason": t.reason,
292
+ }
293
+ for t in self.history
294
+ ],
295
+ }
296
+
297
+ @classmethod
298
+ def from_dict(cls, data: dict) -> "Operation":
299
+ """Rehydrate an Operation from its persisted dict — the inverse of
300
+ to_dict(). Used when telemetry arrives for an operation that lives in the
301
+ DB and must be reconciled against.
302
+
303
+ Fidelity matters here: we restore the real state, the real
304
+ state_entered_at, and the full transition history. A naive
305
+ Operation(state=...) would let __post_init__ synthesize a fresh history
306
+ and reset state_entered_at to *now*, silently destroying time_in_state —
307
+ the very signal the Policy Engine relies on to spot a stalled operation.
308
+ So we build the object, then overwrite those fields with the stored ones.
309
+ """
310
+ op = cls(
311
+ device_id=data["device_id"],
312
+ action=data["action"],
313
+ operation_id=data["operation_id"],
314
+ state=OperationState(data["state"]),
315
+ created_at=data["created_at"],
316
+ telemetry_capable=data.get("telemetry_capable", False),
317
+ phase=data.get("phase", ""),
318
+ )
319
+ # Restore the true timing and history (the constructor reset/synthesized
320
+ # these — replace them with what actually happened).
321
+ op.state_entered_at = data["state_entered_at"]
322
+ op.history = [
323
+ StateTransition(
324
+ from_state=OperationState(h["from_state"]) if h["from_state"] else None,
325
+ to_state=OperationState(h["to_state"]),
326
+ at=h["at"],
327
+ reason=h.get("reason", ""),
328
+ )
329
+ for h in data.get("history", [])
330
+ ]
331
+ return op