hyperloop 0.8.0__tar.gz → 0.9.0__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.
Files changed (83) hide show
  1. {hyperloop-0.8.0 → hyperloop-0.9.0}/CHANGELOG.md +11 -0
  2. {hyperloop-0.8.0 → hyperloop-0.9.0}/PKG-INFO +1 -1
  3. {hyperloop-0.8.0 → hyperloop-0.9.0}/pyproject.toml +2 -1
  4. {hyperloop-0.8.0 → hyperloop-0.9.0}/src/hyperloop/adapters/matrix_setup.py +143 -54
  5. {hyperloop-0.8.0 → hyperloop-0.9.0}/src/hyperloop/config.py +3 -3
  6. {hyperloop-0.8.0 → hyperloop-0.9.0}/tests/fakes/probe.py +1 -1
  7. {hyperloop-0.8.0 → hyperloop-0.9.0}/tests/test_compose.py +1 -1
  8. {hyperloop-0.8.0 → hyperloop-0.9.0}/uv.lock +1 -1
  9. {hyperloop-0.8.0 → hyperloop-0.9.0}/.github/workflows/ci.yaml +0 -0
  10. {hyperloop-0.8.0 → hyperloop-0.9.0}/.github/workflows/release.yaml +0 -0
  11. {hyperloop-0.8.0 → hyperloop-0.9.0}/.gitignore +0 -0
  12. {hyperloop-0.8.0 → hyperloop-0.9.0}/.pre-commit-config.yaml +0 -0
  13. {hyperloop-0.8.0 → hyperloop-0.9.0}/.python-version +0 -0
  14. {hyperloop-0.8.0 → hyperloop-0.9.0}/CLAUDE.md +0 -0
  15. {hyperloop-0.8.0 → hyperloop-0.9.0}/LICENSE +0 -0
  16. {hyperloop-0.8.0 → hyperloop-0.9.0}/README.md +0 -0
  17. {hyperloop-0.8.0 → hyperloop-0.9.0}/base/implementer.yaml +0 -0
  18. {hyperloop-0.8.0 → hyperloop-0.9.0}/base/kustomization.yaml +0 -0
  19. {hyperloop-0.8.0 → hyperloop-0.9.0}/base/pm.yaml +0 -0
  20. {hyperloop-0.8.0 → hyperloop-0.9.0}/base/process-improver.yaml +0 -0
  21. {hyperloop-0.8.0 → hyperloop-0.9.0}/base/process.yaml +0 -0
  22. {hyperloop-0.8.0 → hyperloop-0.9.0}/base/rebase-resolver.yaml +0 -0
  23. {hyperloop-0.8.0 → hyperloop-0.9.0}/base/verifier.yaml +0 -0
  24. {hyperloop-0.8.0 → hyperloop-0.9.0}/specs/observability.md +0 -0
  25. {hyperloop-0.8.0 → hyperloop-0.9.0}/specs/prompts/checklist.md +0 -0
  26. {hyperloop-0.8.0 → hyperloop-0.9.0}/specs/prompts/checks/check_result_file.sh +0 -0
  27. {hyperloop-0.8.0 → hyperloop-0.9.0}/specs/prompts/rules.md +0 -0
  28. {hyperloop-0.8.0 → hyperloop-0.9.0}/specs/spec.md +0 -0
  29. {hyperloop-0.8.0 → hyperloop-0.9.0}/specs/tasks/task-001.md +0 -0
  30. {hyperloop-0.8.0 → hyperloop-0.9.0}/specs/tasks/task-002.md +0 -0
  31. {hyperloop-0.8.0 → hyperloop-0.9.0}/specs/tasks/task-003.md +0 -0
  32. {hyperloop-0.8.0 → hyperloop-0.9.0}/specs/tasks/task-004.md +0 -0
  33. {hyperloop-0.8.0 → hyperloop-0.9.0}/specs/tasks/task-005.md +0 -0
  34. {hyperloop-0.8.0 → hyperloop-0.9.0}/specs/tasks/task-006.md +0 -0
  35. {hyperloop-0.8.0 → hyperloop-0.9.0}/src/hyperloop/__init__.py +0 -0
  36. {hyperloop-0.8.0 → hyperloop-0.9.0}/src/hyperloop/__main__.py +0 -0
  37. {hyperloop-0.8.0 → hyperloop-0.9.0}/src/hyperloop/adapters/__init__.py +0 -0
  38. {hyperloop-0.8.0 → hyperloop-0.9.0}/src/hyperloop/adapters/git_state.py +0 -0
  39. {hyperloop-0.8.0 → hyperloop-0.9.0}/src/hyperloop/adapters/local.py +0 -0
  40. {hyperloop-0.8.0 → hyperloop-0.9.0}/src/hyperloop/adapters/matrix_probe.py +0 -0
  41. {hyperloop-0.8.0 → hyperloop-0.9.0}/src/hyperloop/adapters/probe.py +0 -0
  42. {hyperloop-0.8.0 → hyperloop-0.9.0}/src/hyperloop/adapters/serial.py +0 -0
  43. {hyperloop-0.8.0 → hyperloop-0.9.0}/src/hyperloop/adapters/structlog_probe.py +0 -0
  44. {hyperloop-0.8.0 → hyperloop-0.9.0}/src/hyperloop/cli.py +0 -0
  45. {hyperloop-0.8.0 → hyperloop-0.9.0}/src/hyperloop/compose.py +0 -0
  46. {hyperloop-0.8.0 → hyperloop-0.9.0}/src/hyperloop/domain/__init__.py +0 -0
  47. {hyperloop-0.8.0 → hyperloop-0.9.0}/src/hyperloop/domain/decide.py +0 -0
  48. {hyperloop-0.8.0 → hyperloop-0.9.0}/src/hyperloop/domain/deps.py +0 -0
  49. {hyperloop-0.8.0 → hyperloop-0.9.0}/src/hyperloop/domain/model.py +0 -0
  50. {hyperloop-0.8.0 → hyperloop-0.9.0}/src/hyperloop/domain/pipeline.py +0 -0
  51. {hyperloop-0.8.0 → hyperloop-0.9.0}/src/hyperloop/logging.py +0 -0
  52. {hyperloop-0.8.0 → hyperloop-0.9.0}/src/hyperloop/loop.py +0 -0
  53. {hyperloop-0.8.0 → hyperloop-0.9.0}/src/hyperloop/ports/__init__.py +0 -0
  54. {hyperloop-0.8.0 → hyperloop-0.9.0}/src/hyperloop/ports/pr.py +0 -0
  55. {hyperloop-0.8.0 → hyperloop-0.9.0}/src/hyperloop/ports/probe.py +0 -0
  56. {hyperloop-0.8.0 → hyperloop-0.9.0}/src/hyperloop/ports/runtime.py +0 -0
  57. {hyperloop-0.8.0 → hyperloop-0.9.0}/src/hyperloop/ports/serial.py +0 -0
  58. {hyperloop-0.8.0 → hyperloop-0.9.0}/src/hyperloop/ports/state.py +0 -0
  59. {hyperloop-0.8.0 → hyperloop-0.9.0}/src/hyperloop/pr.py +0 -0
  60. {hyperloop-0.8.0 → hyperloop-0.9.0}/tests/__init__.py +0 -0
  61. {hyperloop-0.8.0 → hyperloop-0.9.0}/tests/fakes/__init__.py +0 -0
  62. {hyperloop-0.8.0 → hyperloop-0.9.0}/tests/fakes/pr.py +0 -0
  63. {hyperloop-0.8.0 → hyperloop-0.9.0}/tests/fakes/runtime.py +0 -0
  64. {hyperloop-0.8.0 → hyperloop-0.9.0}/tests/fakes/serial.py +0 -0
  65. {hyperloop-0.8.0 → hyperloop-0.9.0}/tests/fakes/state.py +0 -0
  66. {hyperloop-0.8.0 → hyperloop-0.9.0}/tests/test_cli.py +0 -0
  67. {hyperloop-0.8.0 → hyperloop-0.9.0}/tests/test_config.py +0 -0
  68. {hyperloop-0.8.0 → hyperloop-0.9.0}/tests/test_decide.py +0 -0
  69. {hyperloop-0.8.0 → hyperloop-0.9.0}/tests/test_deps.py +0 -0
  70. {hyperloop-0.8.0 → hyperloop-0.9.0}/tests/test_e2e.py +0 -0
  71. {hyperloop-0.8.0 → hyperloop-0.9.0}/tests/test_fakes.py +0 -0
  72. {hyperloop-0.8.0 → hyperloop-0.9.0}/tests/test_git_state.py +0 -0
  73. {hyperloop-0.8.0 → hyperloop-0.9.0}/tests/test_local_runtime.py +0 -0
  74. {hyperloop-0.8.0 → hyperloop-0.9.0}/tests/test_loop.py +0 -0
  75. {hyperloop-0.8.0 → hyperloop-0.9.0}/tests/test_matrix_probe.py +0 -0
  76. {hyperloop-0.8.0 → hyperloop-0.9.0}/tests/test_model.py +0 -0
  77. {hyperloop-0.8.0 → hyperloop-0.9.0}/tests/test_pipeline.py +0 -0
  78. {hyperloop-0.8.0 → hyperloop-0.9.0}/tests/test_pr.py +0 -0
  79. {hyperloop-0.8.0 → hyperloop-0.9.0}/tests/test_probe.py +0 -0
  80. {hyperloop-0.8.0 → hyperloop-0.9.0}/tests/test_serial_agents.py +0 -0
  81. {hyperloop-0.8.0 → hyperloop-0.9.0}/tests/test_smoke.py +0 -0
  82. {hyperloop-0.8.0 → hyperloop-0.9.0}/tests/test_state_contract.py +0 -0
  83. {hyperloop-0.8.0 → hyperloop-0.9.0}/tests/test_structlog_probe.py +0 -0
@@ -2,6 +2,17 @@
2
2
 
3
3
  <!-- version list -->
4
4
 
5
+ ## v0.9.0 (2026-04-15)
6
+
7
+
8
+ ## v0.8.1 (2026-04-15)
9
+
10
+ ### Bug Fixes
11
+
12
+ - **matrix**: Auto-create room independently of registration
13
+ ([`cb4c0a6`](https://github.com/jsell-rh/hyperloop/commit/cb4c0a665a018a6739ff0fb70e333e7f69202e54))
14
+
15
+
5
16
  ## v0.8.0 (2026-04-15)
6
17
 
7
18
  ### Features
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hyperloop
3
- Version: 0.8.0
3
+ Version: 0.9.0
4
4
  Summary: Orchestrator that walks tasks through composable process pipelines using AI agents
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.12
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "hyperloop"
3
- version = "0.8.0"
3
+ version = "0.9.0"
4
4
  description = "Orchestrator that walks tasks through composable process pipelines using AI agents"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -57,6 +57,7 @@ ignore = []
57
57
  pythonVersion = "3.12"
58
58
  typeCheckingMode = "strict"
59
59
  reportMissingTypeStubs = false
60
+ reportPrivateUsage = false
60
61
 
61
62
  [tool.pytest.ini_options]
62
63
  testpaths = ["tests"]
@@ -49,6 +49,21 @@ def _load_cache(repo_path: Path, homeserver: str) -> dict[str, str] | None:
49
49
  return None
50
50
 
51
51
 
52
+ def _ensure_gitignored(repo_path: Path) -> None:
53
+ """Ensure .hyperloop/ is in the target repo's .gitignore."""
54
+ gitignore = repo_path / ".gitignore"
55
+ entry = ".hyperloop/"
56
+ if gitignore.is_file():
57
+ content = gitignore.read_text()
58
+ if entry in content.splitlines():
59
+ return
60
+ if not content.endswith("\n"):
61
+ content += "\n"
62
+ gitignore.write_text(content + entry + "\n")
63
+ else:
64
+ gitignore.write_text(entry + "\n")
65
+
66
+
52
67
  def _save_cache(
53
68
  repo_path: Path,
54
69
  *,
@@ -59,6 +74,7 @@ def _save_cache(
59
74
  password: str,
60
75
  ) -> None:
61
76
  """Persist Matrix credentials to the cache file."""
77
+ _ensure_gitignored(repo_path)
62
78
  cache_path = repo_path / _CACHE_FILE
63
79
  cache_path.parent.mkdir(parents=True, exist_ok=True)
64
80
  cache_path.write_text(
@@ -166,15 +182,18 @@ def _create_room(
166
182
  homeserver: str,
167
183
  access_token: str,
168
184
  room_name: str,
185
+ invite_user: str = "",
169
186
  ) -> str:
170
- """Create a private Matrix room. Returns the room_id."""
187
+ """Create a private Matrix room and optionally invite a user. Returns the room_id."""
171
188
  url = f"{homeserver}/_matrix/client/v3/createRoom"
172
- body = {
189
+ body: dict[str, object] = {
173
190
  "name": room_name,
174
191
  "topic": "hyperloop orchestrator notifications",
175
192
  "visibility": "private",
176
193
  "preset": "private_chat",
177
194
  }
195
+ if invite_user:
196
+ body["invite"] = [invite_user]
178
197
 
179
198
  resp = client.post(
180
199
  url,
@@ -197,10 +216,15 @@ def ensure_matrix_ready(
197
216
  ) -> tuple[str, str]:
198
217
  """Ensure Matrix credentials and room are available.
199
218
 
200
- Resolution order:
201
- 1. Explicit ``token_env`` + ``room_id`` in config → use directly (no setup).
202
- 2. Cached credentials in ``.hyperloop/matrix-state.json`` reuse.
203
- 3. ``registration_token`` register bot, create room, cache.
219
+ **Access token** resolution:
220
+ 1. Explicit ``token_env`` env var → use directly.
221
+ 2. ``registration_token_env`` register a fresh disposable bot
222
+ (``hyperloop-{repo}-{random}``), deactivate the previous one.
223
+
224
+ **Room ID** resolution:
225
+ 1. Explicit ``room_id`` in config.
226
+ 2. Cached room_id from ``.hyperloop/matrix-state.json``.
227
+ 3. Auto-create via Matrix API (invites ``invite_user``).
204
228
 
205
229
  Returns:
206
230
  (access_token, room_id) tuple. Either or both may be empty string
@@ -209,82 +233,147 @@ def ensure_matrix_ready(
209
233
  import os
210
234
 
211
235
  homeserver = config.homeserver.rstrip("/")
236
+ cache = _load_cache(repo_path, homeserver)
212
237
 
213
- # 1. Explicit token from env takes precedence
238
+ # --- Resolve access token ---
214
239
  explicit_token = os.environ.get(config.token_env) if config.token_env else ""
215
- explicit_room = config.room_id
216
-
217
- if explicit_token and explicit_room:
218
- return explicit_token, explicit_room
240
+ access_token = explicit_token
219
241
 
220
- # 2. Try cache
221
- cache = _load_cache(repo_path, homeserver)
222
- if cache is not None:
223
- cached_token = cache.get("access_token", "")
224
- cached_room = cache.get("room_id", "")
225
- # Allow explicit overrides to supplement cache
226
- token = explicit_token or cached_token
227
- room_id = explicit_room or cached_room
228
- if token and room_id:
229
- return token, room_id
230
-
231
- # 3. Auto-setup via registration_token from env
232
- registration_token = (
233
- os.environ.get(config.registration_token_env) if config.registration_token_env else ""
234
- )
235
- if not registration_token:
236
- _log.warning(
237
- "Matrix: no access token, no cached credentials, and no registration token — skipping"
242
+ if not access_token:
243
+ registration_token = (
244
+ os.environ.get(config.registration_token_env) if config.registration_token_env else ""
238
245
  )
246
+ if registration_token:
247
+ try:
248
+ access_token = _register_disposable_bot(
249
+ config, repo_path, homeserver, registration_token, cache
250
+ )
251
+ except Exception:
252
+ _log.exception("Matrix bot registration failed")
253
+ else:
254
+ _log.warning("Matrix: no access token and no registration token — skipping")
255
+ return "", ""
256
+
257
+ if not access_token:
239
258
  return "", ""
240
259
 
241
- try:
242
- return _auto_setup(config, repo_path, homeserver, registration_token, cache)
243
- except Exception:
244
- _log.exception("Matrix auto-setup failed — skipping Matrix notifications")
245
- return "", ""
260
+ # --- Resolve room_id ---
261
+ cached_room = cache.get("room_id", "") if cache else ""
262
+ room_id = config.room_id or cached_room
263
+
264
+ if not room_id:
265
+ try:
266
+ room_id = _auto_create_room(config, repo_path, homeserver, access_token)
267
+ except Exception:
268
+ _log.exception("Matrix room creation failed")
269
+ return access_token, ""
270
+
271
+ # Ensure the bot has joined the room (it's a new user each run)
272
+ if access_token != explicit_token:
273
+ try:
274
+ _join_room(httpx.Client(timeout=10.0), homeserver, access_token, room_id)
275
+ except Exception:
276
+ _log.exception("Matrix room join failed")
246
277
 
278
+ return access_token, room_id
247
279
 
248
- def _auto_setup(
280
+
281
+ def _register_disposable_bot(
249
282
  config: MatrixConfig,
250
283
  repo_path: Path,
251
284
  homeserver: str,
252
285
  registration_token: str,
253
286
  cache: dict[str, str] | None,
254
- ) -> tuple[str, str]:
255
- """Register bot, create room, cache credentials. Returns (token, room_id)."""
256
- # Derive bot username
257
- repo_name = repo_path.name
258
- username = config.bot_username or f"hyperloop-{repo_name}"
287
+ ) -> str:
288
+ """Register a fresh disposable bot user. Returns access_token.
259
289
 
260
- # Reuse cached password if available, otherwise generate one
261
- password = cache.get("password", "") if cache else ""
262
- if not password:
263
- password = secrets.token_urlsafe(32)
290
+ Each run gets a new identity (``hyperloop-{repo}-{random}``). The
291
+ previous bot is deactivated best-effort. Only the room_id is cached.
292
+ """
293
+ repo_name = repo_path.name
294
+ suffix = secrets.token_hex(4)
295
+ username = f"hyperloop-{repo_name}-{suffix}"
296
+ password = secrets.token_urlsafe(32)
264
297
 
265
298
  client = httpx.Client(timeout=30.0)
266
299
  try:
267
- # Register or login
300
+ # Deactivate previous bot (best-effort)
301
+ prev_token = cache.get("access_token", "") if cache else ""
302
+ if prev_token:
303
+ _deactivate_user(client, homeserver, prev_token)
304
+
305
+ # Register new bot
268
306
  user_id, access_token = _register_bot(
269
307
  client, homeserver, registration_token, username, password
270
308
  )
271
309
 
272
- # Create room if needed
273
- room_id = config.room_id
274
- if not room_id:
275
- room_id = _create_room(client, homeserver, access_token, f"hyperloop-{repo_name}")
276
-
277
- # Cache for next run
310
+ # Cache room_id + new bot credentials
311
+ cached_room = cache.get("room_id", "") if cache else ""
278
312
  _save_cache(
279
313
  repo_path,
280
314
  homeserver=homeserver,
281
315
  user_id=user_id,
282
316
  access_token=access_token,
283
- room_id=room_id,
317
+ room_id=config.room_id or cached_room,
284
318
  password=password,
285
319
  )
286
320
 
287
- _log.info("Matrix auto-setup complete: user=%s room=%s", user_id, room_id)
288
- return access_token, room_id
321
+ _log.info("Matrix bot registered: %s", user_id)
322
+ return access_token
323
+ finally:
324
+ client.close()
325
+
326
+
327
+ def _deactivate_user(client: httpx.Client, homeserver: str, access_token: str) -> None:
328
+ """Deactivate the user associated with the given access token. Best-effort."""
329
+ import contextlib
330
+
331
+ url = f"{homeserver}/_matrix/client/v3/account/deactivate"
332
+ with contextlib.suppress(Exception):
333
+ client.post(
334
+ url,
335
+ json={"auth": {"type": "m.login.password"}},
336
+ headers={"Authorization": f"Bearer {access_token}"},
337
+ )
338
+
339
+
340
+ def _join_room(client: httpx.Client, homeserver: str, access_token: str, room_id: str) -> None:
341
+ """Join a room by ID."""
342
+ url = f"{homeserver}/_matrix/client/v3/join/{room_id}"
343
+ resp = client.post(url, json={}, headers={"Authorization": f"Bearer {access_token}"})
344
+ resp.raise_for_status()
345
+
346
+
347
+ def _auto_create_room(
348
+ config: MatrixConfig,
349
+ repo_path: Path,
350
+ homeserver: str,
351
+ access_token: str,
352
+ ) -> str:
353
+ """Create a room, invite the configured user, and cache room_id. Returns room_id."""
354
+ repo_name = repo_path.name
355
+ client = httpx.Client(timeout=30.0)
356
+ try:
357
+ room_id = _create_room(
358
+ client,
359
+ homeserver,
360
+ access_token,
361
+ f"hyperloop-{repo_name}",
362
+ invite_user=config.invite_user,
363
+ )
364
+
365
+ # Update cache with the new room_id
366
+ cache = _load_cache(repo_path, homeserver) or {}
367
+ _save_cache(
368
+ repo_path,
369
+ homeserver=homeserver,
370
+ user_id=cache.get("user_id", ""),
371
+ access_token=cache.get("access_token", access_token),
372
+ room_id=room_id,
373
+ password=cache.get("password", ""),
374
+ )
375
+
376
+ _log.info("Matrix room created: %s", room_id)
377
+ return room_id
289
378
  finally:
290
379
  client.close()
@@ -31,7 +31,7 @@ class MatrixConfig:
31
31
  token_env: str
32
32
  verbose: bool
33
33
  registration_token_env: str # env var holding the registration token
34
- bot_username: str
34
+ invite_user: str # Matrix user ID to invite to auto-created rooms
35
35
 
36
36
 
37
37
  @dataclass(frozen=True)
@@ -144,7 +144,7 @@ def _flatten_yaml(raw: dict[str, object]) -> dict[str, object]:
144
144
  flat["matrix_token_env"] = mx.get("token_env", "")
145
145
  flat["matrix_verbose"] = mx.get("verbose", False)
146
146
  flat["matrix_registration_token_env"] = mx.get("registration_token_env", "")
147
- flat["matrix_bot_username"] = mx.get("bot_username", "")
147
+ flat["matrix_invite_user"] = mx.get("invite_user", "")
148
148
 
149
149
  return flat
150
150
 
@@ -208,7 +208,7 @@ def load_config(
208
208
  token_env=token_env,
209
209
  verbose=bool(values.get("matrix_verbose", False)),
210
210
  registration_token_env=registration_token_env,
211
- bot_username=str(values.get("matrix_bot_username", "")),
211
+ invite_user=str(values.get("matrix_invite_user", "")),
212
212
  )
213
213
 
214
214
  obs_cfg = ObservabilityConfig(
@@ -13,7 +13,7 @@ class RecordedCall:
13
13
  """A single recorded probe call."""
14
14
 
15
15
  method: str
16
- kwargs: dict[str, object] = field(default_factory=dict)
16
+ kwargs: dict[str, object] = field(default_factory=lambda: {})
17
17
 
18
18
 
19
19
  class RecordingProbe:
@@ -343,7 +343,7 @@ class TestCheckKustomize:
343
343
  def test_raises_when_missing(self, monkeypatch: pytest.MonkeyPatch) -> None:
344
344
  from hyperloop.compose import check_kustomize_available
345
345
 
346
- monkeypatch.setattr(shutil, "which", lambda _name: None)
346
+ monkeypatch.setattr(shutil, "which", lambda _name: None) # type: ignore[arg-type]
347
347
 
348
348
  with pytest.raises(SystemExit, match="kustomize CLI not found"):
349
349
  check_kustomize_available()
@@ -120,7 +120,7 @@ wheels = [
120
120
 
121
121
  [[package]]
122
122
  name = "hyperloop"
123
- version = "0.7.0"
123
+ version = "0.8.1"
124
124
  source = { editable = "." }
125
125
  dependencies = [
126
126
  { name = "httpx" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes