hyperloop 0.1.0__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.
- hyperloop/__init__.py +0 -0
- hyperloop/__main__.py +5 -0
- hyperloop/adapters/__init__.py +0 -0
- hyperloop/adapters/git_state.py +254 -0
- hyperloop/adapters/local.py +273 -0
- hyperloop/cli.py +166 -0
- hyperloop/compose.py +126 -0
- hyperloop/config.py +161 -0
- hyperloop/domain/__init__.py +0 -0
- hyperloop/domain/decide.py +104 -0
- hyperloop/domain/deps.py +66 -0
- hyperloop/domain/model.py +221 -0
- hyperloop/domain/pipeline.py +306 -0
- hyperloop/loop.py +510 -0
- hyperloop/ports/__init__.py +0 -0
- hyperloop/ports/pr.py +32 -0
- hyperloop/ports/runtime.py +37 -0
- hyperloop/ports/state.py +61 -0
- hyperloop/pr.py +212 -0
- hyperloop-0.1.0.dist-info/METADATA +253 -0
- hyperloop-0.1.0.dist-info/RECORD +23 -0
- hyperloop-0.1.0.dist-info/WHEEL +4 -0
- hyperloop-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""Pipeline executor — walks a task through pipeline steps.
|
|
2
|
+
|
|
3
|
+
Given the current pipeline position and a worker result, returns the next action
|
|
4
|
+
to take and the new position. No I/O, no side effects.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
|
|
15
|
+
from hyperloop.domain.model import (
|
|
16
|
+
ActionStep,
|
|
17
|
+
GateStep,
|
|
18
|
+
LoopStep,
|
|
19
|
+
PipelinePosition,
|
|
20
|
+
PipelineStep,
|
|
21
|
+
RoleStep,
|
|
22
|
+
Verdict,
|
|
23
|
+
WorkerResult,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# Pipeline actions (output of next_action)
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class SpawnRole:
|
|
33
|
+
"""Spawn a worker with the given role."""
|
|
34
|
+
|
|
35
|
+
role: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class WaitForGate:
|
|
40
|
+
"""Block until the named gate's external signal is received."""
|
|
41
|
+
|
|
42
|
+
gate: str
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class PerformAction:
|
|
47
|
+
"""Execute a terminal action (merge-pr, mark-pr-ready, etc.)."""
|
|
48
|
+
|
|
49
|
+
action: str
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class PipelineComplete:
|
|
54
|
+
"""The pipeline has been fully traversed — no more steps."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class PipelineFailed:
|
|
59
|
+
"""The pipeline cannot continue (fail with no enclosing loop)."""
|
|
60
|
+
|
|
61
|
+
reason: str
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
PipelineAction = SpawnRole | WaitForGate | PerformAction | PipelineComplete | PipelineFailed
|
|
65
|
+
"""Union of all pipeline action types."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
# Pipeline executor class
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class PipelineExecutor:
|
|
74
|
+
"""Walks a task through a pipeline, returning the next action at each step."""
|
|
75
|
+
|
|
76
|
+
def __init__(self, pipeline: tuple[PipelineStep, ...]) -> None:
|
|
77
|
+
self._pipeline = pipeline
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def pipeline(self) -> tuple[PipelineStep, ...]:
|
|
81
|
+
"""The pipeline steps this executor walks."""
|
|
82
|
+
return self._pipeline
|
|
83
|
+
|
|
84
|
+
def next_action(
|
|
85
|
+
self, position: PipelinePosition, result: WorkerResult | None
|
|
86
|
+
) -> tuple[PipelineAction, PipelinePosition]:
|
|
87
|
+
"""Determine the next pipeline action given the current position and result.
|
|
88
|
+
|
|
89
|
+
Pure method. No I/O. No side effects.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
position: Current position in the pipeline (path of indices).
|
|
93
|
+
result: The result from the current step's worker, or None if just arriving.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
A tuple of (action to take, new position).
|
|
97
|
+
"""
|
|
98
|
+
step = self.resolve_step(self._pipeline, position.path)
|
|
99
|
+
|
|
100
|
+
# --- No result: we're arriving at this step for the first time ---
|
|
101
|
+
if result is None:
|
|
102
|
+
return self._action_for_step(step), position
|
|
103
|
+
|
|
104
|
+
# --- Result received: decide what to do next ---
|
|
105
|
+
is_pass = result.verdict == Verdict.PASS
|
|
106
|
+
|
|
107
|
+
# Handle on_pass/on_fail overrides on RoleStep
|
|
108
|
+
if isinstance(step, RoleStep):
|
|
109
|
+
override = step.on_pass if is_pass else step.on_fail
|
|
110
|
+
if override is not None:
|
|
111
|
+
# Find the named step in the current step list (same nesting level)
|
|
112
|
+
parent_steps: Sequence[PipelineStep] = self._pipeline
|
|
113
|
+
prefix: tuple[int, ...] = ()
|
|
114
|
+
if len(position.path) > 1:
|
|
115
|
+
# Walk to the parent's step list
|
|
116
|
+
for idx in position.path[:-1]:
|
|
117
|
+
s = parent_steps[idx]
|
|
118
|
+
if isinstance(s, LoopStep):
|
|
119
|
+
parent_steps = s.steps
|
|
120
|
+
prefix = (*prefix, idx)
|
|
121
|
+
else:
|
|
122
|
+
break
|
|
123
|
+
|
|
124
|
+
target_idx = self._find_step_by_role(parent_steps, override)
|
|
125
|
+
if target_idx is not None:
|
|
126
|
+
return self._descend_to_leaf(parent_steps, prefix, target_idx)
|
|
127
|
+
|
|
128
|
+
if is_pass:
|
|
129
|
+
# Advance to next step
|
|
130
|
+
advanced = self.advance_from(self._pipeline, position.path)
|
|
131
|
+
if advanced is not None:
|
|
132
|
+
return advanced
|
|
133
|
+
# Past the end of the pipeline
|
|
134
|
+
return PipelineComplete(), position
|
|
135
|
+
|
|
136
|
+
# Fail: restart enclosing loop
|
|
137
|
+
restarted = self._restart_loop(self._pipeline, position.path)
|
|
138
|
+
if restarted is not None:
|
|
139
|
+
return restarted
|
|
140
|
+
|
|
141
|
+
# No enclosing loop — pipeline fails
|
|
142
|
+
reason = f"fail at step {list(position.path)} with no enclosing loop"
|
|
143
|
+
return PipelineFailed(reason=reason), position
|
|
144
|
+
|
|
145
|
+
def initial_position(self) -> PipelinePosition:
|
|
146
|
+
"""Return the position of the first leaf step in the pipeline."""
|
|
147
|
+
path: list[int] = [0]
|
|
148
|
+
step: PipelineStep = self._pipeline[0]
|
|
149
|
+
while isinstance(step, LoopStep):
|
|
150
|
+
path.append(0)
|
|
151
|
+
step = step.steps[0]
|
|
152
|
+
return PipelinePosition(path=tuple(path))
|
|
153
|
+
|
|
154
|
+
# -----------------------------------------------------------------------
|
|
155
|
+
# Private helpers
|
|
156
|
+
# -----------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
@staticmethod
|
|
159
|
+
def resolve_step(
|
|
160
|
+
steps: Sequence[PipelineStep],
|
|
161
|
+
path: tuple[int, ...],
|
|
162
|
+
) -> PipelineStep:
|
|
163
|
+
"""Walk the path to find the step at the given position."""
|
|
164
|
+
step = steps[path[0]]
|
|
165
|
+
for idx in path[1:]:
|
|
166
|
+
if not isinstance(step, LoopStep):
|
|
167
|
+
msg = f"Expected LoopStep at path prefix, got {type(step).__name__}"
|
|
168
|
+
raise ValueError(msg)
|
|
169
|
+
step = step.steps[idx]
|
|
170
|
+
return step
|
|
171
|
+
|
|
172
|
+
@staticmethod
|
|
173
|
+
def _find_step_by_role(
|
|
174
|
+
steps: Sequence[PipelineStep],
|
|
175
|
+
target_role: str,
|
|
176
|
+
) -> int | None:
|
|
177
|
+
"""Find the index of a step matching the target role name at the top level."""
|
|
178
|
+
for i, step in enumerate(steps):
|
|
179
|
+
if isinstance(step, RoleStep) and step.role == target_role:
|
|
180
|
+
return i
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
@staticmethod
|
|
184
|
+
def _action_for_step(step: PipelineStep) -> PipelineAction:
|
|
185
|
+
"""Return the immediate action for arriving at a step (no result yet)."""
|
|
186
|
+
if isinstance(step, RoleStep):
|
|
187
|
+
return SpawnRole(role=step.role)
|
|
188
|
+
if isinstance(step, GateStep):
|
|
189
|
+
return WaitForGate(gate=step.gate)
|
|
190
|
+
if isinstance(step, ActionStep):
|
|
191
|
+
return PerformAction(action=step.action)
|
|
192
|
+
# LoopStep — should not be called directly; caller descends into it.
|
|
193
|
+
msg = f"Cannot produce action for {type(step).__name__}"
|
|
194
|
+
raise ValueError(msg)
|
|
195
|
+
|
|
196
|
+
@staticmethod
|
|
197
|
+
def _descend_to_leaf(
|
|
198
|
+
steps: Sequence[PipelineStep],
|
|
199
|
+
prefix: tuple[int, ...],
|
|
200
|
+
index: int,
|
|
201
|
+
) -> tuple[PipelineAction, PipelinePosition]:
|
|
202
|
+
"""Descend from a step index, entering nested LoopSteps until we reach a leaf step.
|
|
203
|
+
|
|
204
|
+
Returns the action for the leaf step and its full position path.
|
|
205
|
+
"""
|
|
206
|
+
step = steps[index]
|
|
207
|
+
current_path = (*prefix, index)
|
|
208
|
+
|
|
209
|
+
while isinstance(step, LoopStep):
|
|
210
|
+
# Enter the loop at its first child
|
|
211
|
+
current_path = (*current_path, 0)
|
|
212
|
+
step = step.steps[0]
|
|
213
|
+
|
|
214
|
+
return PipelineExecutor._action_for_step(step), PipelinePosition(path=current_path)
|
|
215
|
+
|
|
216
|
+
@staticmethod
|
|
217
|
+
def _descend_to_leaf_from_loop(
|
|
218
|
+
loop: LoopStep,
|
|
219
|
+
loop_path: tuple[int, ...],
|
|
220
|
+
) -> tuple[PipelineAction, PipelinePosition]:
|
|
221
|
+
"""Descend from a LoopStep to its first leaf step."""
|
|
222
|
+
step: PipelineStep = loop.steps[0]
|
|
223
|
+
current_path = (*loop_path, 0)
|
|
224
|
+
|
|
225
|
+
while isinstance(step, LoopStep):
|
|
226
|
+
current_path = (*current_path, 0)
|
|
227
|
+
step = step.steps[0]
|
|
228
|
+
|
|
229
|
+
return PipelineExecutor._action_for_step(step), PipelinePosition(path=current_path)
|
|
230
|
+
|
|
231
|
+
@classmethod
|
|
232
|
+
def advance_from(
|
|
233
|
+
cls,
|
|
234
|
+
pipeline: Sequence[PipelineStep],
|
|
235
|
+
path: tuple[int, ...],
|
|
236
|
+
) -> tuple[PipelineAction, PipelinePosition] | None:
|
|
237
|
+
"""Try to advance to the next sibling step at the given nesting level.
|
|
238
|
+
|
|
239
|
+
If we're at the end of the current step list, returns None (caller must
|
|
240
|
+
handle exiting to the parent level).
|
|
241
|
+
"""
|
|
242
|
+
if len(path) == 1:
|
|
243
|
+
# Top level
|
|
244
|
+
next_idx = path[0] + 1
|
|
245
|
+
if next_idx >= len(pipeline):
|
|
246
|
+
return None
|
|
247
|
+
return cls._descend_to_leaf(pipeline, (), next_idx)
|
|
248
|
+
|
|
249
|
+
# Inside a nested structure — find the parent LoopStep
|
|
250
|
+
parent_path = path[:-1]
|
|
251
|
+
current_idx = path[-1]
|
|
252
|
+
|
|
253
|
+
# Walk to the parent step
|
|
254
|
+
parent_steps: Sequence[PipelineStep] = pipeline
|
|
255
|
+
for idx in parent_path[:-1]:
|
|
256
|
+
s = parent_steps[idx]
|
|
257
|
+
if not isinstance(s, LoopStep):
|
|
258
|
+
msg = f"Expected LoopStep, got {type(s).__name__}"
|
|
259
|
+
raise ValueError(msg)
|
|
260
|
+
parent_steps = s.steps
|
|
261
|
+
|
|
262
|
+
parent_step = parent_steps[parent_path[-1]]
|
|
263
|
+
if not isinstance(parent_step, LoopStep):
|
|
264
|
+
msg = f"Expected LoopStep, got {type(parent_step).__name__}"
|
|
265
|
+
raise ValueError(msg)
|
|
266
|
+
|
|
267
|
+
next_idx = current_idx + 1
|
|
268
|
+
if next_idx < len(parent_step.steps):
|
|
269
|
+
# There's a next sibling within this loop
|
|
270
|
+
return cls._descend_to_leaf(parent_step.steps, parent_path, next_idx)
|
|
271
|
+
|
|
272
|
+
# End of this loop's steps — exit the loop and advance at the parent level
|
|
273
|
+
return cls.advance_from(pipeline, parent_path)
|
|
274
|
+
|
|
275
|
+
@classmethod
|
|
276
|
+
def _restart_loop(
|
|
277
|
+
cls,
|
|
278
|
+
pipeline: Sequence[PipelineStep],
|
|
279
|
+
path: tuple[int, ...],
|
|
280
|
+
) -> tuple[PipelineAction, PipelinePosition] | None:
|
|
281
|
+
"""Find the nearest enclosing LoopStep and restart it.
|
|
282
|
+
|
|
283
|
+
Returns None if there is no enclosing loop (fail at top level).
|
|
284
|
+
"""
|
|
285
|
+
# Walk up the path to find a LoopStep
|
|
286
|
+
for depth in range(len(path) - 1, 0, -1):
|
|
287
|
+
candidate_path = path[:depth]
|
|
288
|
+
step: PipelineStep
|
|
289
|
+
steps: Sequence[PipelineStep] = pipeline
|
|
290
|
+
step = steps[candidate_path[0]]
|
|
291
|
+
for idx in candidate_path[1:]:
|
|
292
|
+
if not isinstance(step, LoopStep):
|
|
293
|
+
break
|
|
294
|
+
step = step.steps[idx]
|
|
295
|
+
else:
|
|
296
|
+
# We successfully walked the path — check if it points to a LoopStep
|
|
297
|
+
if isinstance(step, LoopStep):
|
|
298
|
+
return cls._descend_to_leaf_from_loop(step, candidate_path)
|
|
299
|
+
|
|
300
|
+
# Also check if the top-level step itself is a LoopStep
|
|
301
|
+
if len(path) > 1:
|
|
302
|
+
top_step = pipeline[path[0]]
|
|
303
|
+
if isinstance(top_step, LoopStep):
|
|
304
|
+
return cls._descend_to_leaf_from_loop(top_step, (path[0],))
|
|
305
|
+
|
|
306
|
+
return None
|