alter-runtime 0.3.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.
Files changed (92) hide show
  1. alter_runtime/__init__.py +11 -0
  2. alter_runtime/adapters/__init__.py +19 -0
  3. alter_runtime/adapters/claude_jsonl_watcher.py +545 -0
  4. alter_runtime/adapters/git_watcher.py +457 -0
  5. alter_runtime/adapters/household/__init__.py +29 -0
  6. alter_runtime/adapters/household/_base.py +138 -0
  7. alter_runtime/adapters/household/compost/__init__.py +17 -0
  8. alter_runtime/adapters/household/compost/adapter.py +81 -0
  9. alter_runtime/adapters/household/compost/storage.py +75 -0
  10. alter_runtime/adapters/household/compost/tests/__init__.py +0 -0
  11. alter_runtime/adapters/household/compost/tests/test_adapter.py +62 -0
  12. alter_runtime/adapters/household/compost/tests/test_storage.py +23 -0
  13. alter_runtime/adapters/household/compost/tests/test_traits.py +38 -0
  14. alter_runtime/adapters/household/compost/traits.py +79 -0
  15. alter_runtime/adapters/household/self_hoster/__init__.py +30 -0
  16. alter_runtime/adapters/household/self_hoster/adapter.py +248 -0
  17. alter_runtime/adapters/household/self_hoster/storage.py +83 -0
  18. alter_runtime/adapters/household/self_hoster/tests/__init__.py +0 -0
  19. alter_runtime/adapters/household/self_hoster/tests/test_adapter.py +216 -0
  20. alter_runtime/adapters/household/self_hoster/tests/test_storage.py +25 -0
  21. alter_runtime/adapters/household/self_hoster/tests/test_traits.py +55 -0
  22. alter_runtime/adapters/household/self_hoster/traits.py +105 -0
  23. alter_runtime/adapters/household/tapo_ecosystem/__init__.py +22 -0
  24. alter_runtime/adapters/household/tapo_ecosystem/adapter.py +98 -0
  25. alter_runtime/adapters/household/tapo_ecosystem/storage.py +95 -0
  26. alter_runtime/adapters/household/tapo_ecosystem/tests/__init__.py +0 -0
  27. alter_runtime/adapters/household/tapo_ecosystem/tests/test_adapter.py +55 -0
  28. alter_runtime/adapters/household/tapo_ecosystem/tests/test_storage.py +28 -0
  29. alter_runtime/adapters/household/tapo_ecosystem/tests/test_traits.py +45 -0
  30. alter_runtime/adapters/household/tapo_ecosystem/traits.py +97 -0
  31. alter_runtime/adapters/household/workshop_tools/__init__.py +25 -0
  32. alter_runtime/adapters/household/workshop_tools/adapter.py +77 -0
  33. alter_runtime/adapters/household/workshop_tools/storage.py +92 -0
  34. alter_runtime/adapters/household/workshop_tools/tests/__init__.py +0 -0
  35. alter_runtime/adapters/household/workshop_tools/tests/test_adapter.py +48 -0
  36. alter_runtime/adapters/household/workshop_tools/tests/test_storage.py +26 -0
  37. alter_runtime/adapters/household/workshop_tools/tests/test_traits.py +45 -0
  38. alter_runtime/adapters/household/workshop_tools/traits.py +95 -0
  39. alter_runtime/adapters/worktree_watcher.py +378 -0
  40. alter_runtime/atlas/__init__.py +48 -0
  41. alter_runtime/atlas/base.py +102 -0
  42. alter_runtime/atlas/ledger.py +196 -0
  43. alter_runtime/atlas/observations.py +136 -0
  44. alter_runtime/atlas/schema.py +106 -0
  45. alter_runtime/cap_cache.py +392 -0
  46. alter_runtime/cli.py +517 -0
  47. alter_runtime/clients/__init__.py +0 -0
  48. alter_runtime/clients/token_usage_client.py +273 -0
  49. alter_runtime/config.py +648 -0
  50. alter_runtime/consent.py +425 -0
  51. alter_runtime/daemon.py +518 -0
  52. alter_runtime/floor_loop.py +335 -0
  53. alter_runtime/floor_preflight.py +734 -0
  54. alter_runtime/http_auth.py +173 -0
  55. alter_runtime/notifiers/__init__.py +18 -0
  56. alter_runtime/notifiers/desktop.py +321 -0
  57. alter_runtime/sdk/__init__.py +12 -0
  58. alter_runtime/sdk/client.py +399 -0
  59. alter_runtime/service_install.py +616 -0
  60. alter_runtime/services/__init__.py +59 -0
  61. alter_runtime/services/launchd/com.alter.runtime.plist.in +90 -0
  62. alter_runtime/services/systemd/alter-runtime.service.in +74 -0
  63. alter_runtime/services/systemd/cf-access-env.conf.in +29 -0
  64. alter_runtime/sockets/__init__.py +20 -0
  65. alter_runtime/sockets/dbus.py +272 -0
  66. alter_runtime/sockets/unix.py +702 -0
  67. alter_runtime/subscribers/__init__.py +58 -0
  68. alter_runtime/subscribers/active_sessions_cron_emitter.py +313 -0
  69. alter_runtime/subscribers/active_sessions_do_publisher.py +1159 -0
  70. alter_runtime/subscribers/active_sessions_gc.py +432 -0
  71. alter_runtime/subscribers/active_sessions_writer.py +446 -0
  72. alter_runtime/subscribers/adapters_writer.py +415 -0
  73. alter_runtime/subscribers/agent_frames.py +461 -0
  74. alter_runtime/subscribers/bus.py +188 -0
  75. alter_runtime/subscribers/cache_writer.py +347 -0
  76. alter_runtime/subscribers/ceremony_echo.py +290 -0
  77. alter_runtime/subscribers/do_sse.py +864 -0
  78. alter_runtime/subscribers/ebpf.py +506 -0
  79. alter_runtime/subscribers/inbox_writer.py +469 -0
  80. alter_runtime/subscribers/mcp_fallback.py +391 -0
  81. alter_runtime/subscribers/presence_writer.py +426 -0
  82. alter_runtime/subscribers/session_presence.py +467 -0
  83. alter_runtime/subscribers/sse.py +125 -0
  84. alter_runtime/subscribers/weave_intent_writer.py +608 -0
  85. alter_runtime/update_loop.py +519 -0
  86. alter_runtime/weave/__init__.py +21 -0
  87. alter_runtime/weave/resolver.py +544 -0
  88. alter_runtime-0.3.0.dist-info/METADATA +289 -0
  89. alter_runtime-0.3.0.dist-info/RECORD +92 -0
  90. alter_runtime-0.3.0.dist-info/WHEEL +4 -0
  91. alter_runtime-0.3.0.dist-info/entry_points.txt +2 -0
  92. alter_runtime-0.3.0.dist-info/licenses/LICENSE +190 -0
@@ -0,0 +1,519 @@
1
+ """UpdateLoop — daemon-side auto-update observer for ALTER artefacts.
2
+
3
+ D-AUTOUPDATE-1 (RATIFIED 2026-05-05) Phase 1 daemon scaffold. This component
4
+ polls ``releases.truealter.com/manifest.json`` on the cadence specified in
5
+ §Decision §2, compares the advertised version against the running daemon's
6
+ ``__version__``, and emits structured log events describing what it sees.
7
+
8
+ **Scope of this module (Phase 1 — observation only).**
9
+
10
+ This is the foundation layer of the daemon-side auto-update story. It does
11
+ *not*:
12
+
13
+ * download any artefact;
14
+ * verify any signature;
15
+ * replace any binary;
16
+ * restart the supervisor;
17
+ * emit Org Alter MCP signals.
18
+
19
+ Each of those is a follow-up phase per the DR rollout sequence. Phase 1's
20
+ purpose is to validate that the substrate is reachable, the manifest is
21
+ parseable, and the daemon can detect when a newer version exists. Operators
22
+ read the structured log output (``journalctl --user -u alter-runtime``) to
23
+ verify Phase 1 behaviour end-to-end before downstream phases land.
24
+
25
+ **Poll cadence (per DR §2).**
26
+
27
+ * Immediate fire-and-forget poll on daemon start (does not block startup).
28
+ * Every ``poll_interval_seconds`` afterwards (default 24h with ±5 % jitter
29
+ per DR §2 timing-fingerprint guidance, narrowed from the spec's 4h window
30
+ since Phase 1 carries no identifying load).
31
+ * DO-pushed ``alter:update-now`` event is deferred to a follow-up phase
32
+ alongside the apply path — Phase 1 is operator-time observability only.
33
+
34
+ **Channel selection (per DR §6).**
35
+
36
+ * Daemon reads the same ``channel.json`` config file the CLI reads, with
37
+ the same env override (``ALTER_CLI_CHANNEL``). The principal / public
38
+ channel substrate is preserved end-to-end — a public install can never
39
+ resolve a principal-tagged release.
40
+
41
+ **Network failure mode (per DR §9).**
42
+
43
+ * Single missed poll → debug log + retry next tick.
44
+ * Seven consecutive misses → warning log noting the substrate is
45
+ unreachable. No retry storm; the supervisor continues running the
46
+ current version indefinitely.
47
+
48
+ **Empty manifest tolerance.**
49
+
50
+ The substrate ships empty until D-INSTALL-INDEPENDENCE-1 P3 first-release
51
+ ingest lands (the manifest currently returns ``platforms: {}`` and
52
+ ``channels: {stable: null, beta: null}``). An empty manifest reads as
53
+ "no release advertised for this channel/platform" and is treated as a
54
+ no-op — zero log noise beyond a single debug line per poll.
55
+ """
56
+
57
+ from __future__ import annotations
58
+
59
+ import asyncio
60
+ import json
61
+ import logging
62
+ import os
63
+ import platform
64
+ import random
65
+ import re
66
+ from dataclasses import dataclass
67
+ from pathlib import Path
68
+ from typing import Any
69
+
70
+ import httpx
71
+
72
+ from alter_runtime import __version__ as RUNTIME_VERSION
73
+ from alter_runtime.daemon import Component
74
+
75
+ __all__ = [
76
+ "UpdateLoop",
77
+ "PlatformKey",
78
+ "PollOutcome",
79
+ "detect_platform_key",
80
+ "resolve_channel",
81
+ "compare_semver",
82
+ "parse_manifest",
83
+ "ManifestSnapshot",
84
+ "DEFAULT_MANIFEST_URL",
85
+ "DEFAULT_POLL_INTERVAL_SECONDS",
86
+ ]
87
+
88
+
89
+ # Sentinel used to distinguish "caller did not pass platform_key" from "caller
90
+ # explicitly passed None to mean unsupported". The constructor checks identity
91
+ # (``is _AUTO_DETECT``) so this value never accidentally collides with a real
92
+ # argument.
93
+ _AUTO_DETECT: object = object()
94
+
95
+
96
+ logger = logging.getLogger("alter_runtime.update_loop")
97
+
98
+
99
+ DEFAULT_MANIFEST_URL: str = "https://releases.truealter.com/manifest.json"
100
+ DEFAULT_POLL_INTERVAL_SECONDS: int = 24 * 60 * 60 # 24h per DR §2
101
+ INITIAL_POLL_DELAY_SECONDS: float = 5.0 # fire after daemon settles, not at zero
102
+ JITTER_FRACTION: float = 0.05 # ±5 % timing-fingerprint defence (Phase 1 narrowing)
103
+ NETWORK_TIMEOUT_SECONDS: float = 10.0
104
+ UNREACHABLE_WARN_THRESHOLD: int = 7 # consecutive misses before WARN (DR §9)
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # Type surfaces
109
+ # ---------------------------------------------------------------------------
110
+
111
+
112
+ PlatformKey = str
113
+ """Platform identifier shared with the Worker manifest schema.
114
+
115
+ Possible values (mirrors ``cloudflare/workers/releases-alter/src/types.ts``):
116
+ ``linux-x64-tarball``, ``linux-arm64-tarball``, ``darwin-x64-tarball``,
117
+ ``darwin-arm64-tarball``, ``windows-x64``, ``windows-arm64``.
118
+ """
119
+
120
+
121
+ @dataclass(frozen=True)
122
+ class ManifestSnapshot:
123
+ """Parsed view of the release manifest at a point in time."""
124
+
125
+ schema_version: int
126
+ channels: dict[str, str | None]
127
+ platform_versions: dict[PlatformKey, str]
128
+ updated_at: str
129
+
130
+ def version_for(self, channel: str, platform_key: PlatformKey) -> str | None:
131
+ """Resolve the version advertised for a (channel, platform) tuple.
132
+
133
+ Returns ``None`` when the manifest is empty for that slot, when the
134
+ channel is unknown, or when the channel pointer references a missing
135
+ platform entry. Empty-manifest tolerance is load-bearing — the
136
+ substrate ships empty until the first release lands.
137
+ """
138
+ channel_pointer = self.channels.get(channel)
139
+ if channel_pointer is None:
140
+ return None
141
+ # Substrate convention (per Worker types.ts): the `channels` map carries
142
+ # the release tag, and the platform entry's `latest_version` carries the
143
+ # per-platform version, which must agree with the channel pointer when
144
+ # a release has been ingested. We surface the platform value because it
145
+ # is the authoritative one for that platform; the channel pointer is the
146
+ # advertisement target.
147
+ platform_version = self.platform_versions.get(platform_key)
148
+ if platform_version is None:
149
+ return None
150
+ return platform_version
151
+
152
+
153
+ # ---------------------------------------------------------------------------
154
+ # Platform + channel detection (pure)
155
+ # ---------------------------------------------------------------------------
156
+
157
+
158
+ def detect_platform_key() -> PlatformKey | None:
159
+ """Map the running process's OS + arch to the manifest's platform key.
160
+
161
+ Returns ``None`` on an unsupported platform — the daemon still runs, it
162
+ just never matches a manifest entry, which is the correct degraded
163
+ behaviour (an unsupported platform has no auto-update story by design).
164
+ """
165
+ system = platform.system().lower()
166
+ machine = platform.machine().lower()
167
+
168
+ # Normalise arch labels to the manifest's vocabulary.
169
+ if machine in ("x86_64", "amd64"):
170
+ arch = "x64"
171
+ elif machine in ("aarch64", "arm64"):
172
+ arch = "arm64"
173
+ else:
174
+ return None
175
+
176
+ if system == "linux":
177
+ return f"linux-{arch}-tarball"
178
+ if system == "darwin":
179
+ return f"darwin-{arch}-tarball"
180
+ if system == "windows":
181
+ return f"windows-{arch}"
182
+ return None
183
+
184
+
185
+ def resolve_channel(env: dict[str, str] | None = None) -> str:
186
+ """Resolve the active channel using the same priority as the CLI.
187
+
188
+ ``ALTER_CLI_CHANNEL`` env var > ``${XDG_CONFIG_HOME}/alter/channel.json``
189
+ > default ``stable``. The default is ``stable`` (mapped to the public
190
+ npm dist-tag ``latest`` by the CLI; the manifest substrate uses
191
+ ``stable`` / ``beta`` channel names per the Worker schema).
192
+ """
193
+ env_map = env if env is not None else os.environ
194
+ from_env = env_map.get("ALTER_CLI_CHANNEL", "").strip()
195
+ if from_env:
196
+ return from_env
197
+
198
+ xdg_config = env_map.get("XDG_CONFIG_HOME") or str(
199
+ Path(env_map.get("HOME", str(Path.home()))) / ".config"
200
+ )
201
+ channel_file = Path(xdg_config) / "alter" / "channel.json"
202
+ try:
203
+ raw = channel_file.read_text(encoding="utf-8")
204
+ except (FileNotFoundError, PermissionError, OSError):
205
+ return "stable"
206
+ try:
207
+ parsed = json.loads(raw)
208
+ except json.JSONDecodeError:
209
+ return "stable"
210
+ if isinstance(parsed, dict):
211
+ value = parsed.get("channel")
212
+ if isinstance(value, str) and value.strip():
213
+ return value.strip()
214
+ return "stable"
215
+
216
+
217
+ # ---------------------------------------------------------------------------
218
+ # Semver comparison (pure)
219
+ # ---------------------------------------------------------------------------
220
+
221
+
222
+ _SEMVER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.\-]+))?")
223
+
224
+
225
+ def compare_semver(a: str, b: str) -> int:
226
+ """Compare two semver strings.
227
+
228
+ Returns >0 when ``a > b``, <0 when ``a < b``, 0 when equal. Pre-release
229
+ tags (``-rc.1``, ``-beta.2``) are ordered lower than the same version
230
+ without a pre-release tag, matching the CLI's compareSemver semantics
231
+ so the two surfaces never disagree on ordering.
232
+ """
233
+ a_match = _SEMVER_RE.match(a)
234
+ b_match = _SEMVER_RE.match(b)
235
+ if a_match is None and b_match is None:
236
+ return 0
237
+ if a_match is None:
238
+ return -1
239
+ if b_match is None:
240
+ return 1
241
+ a_major, a_minor, a_patch, a_pre = a_match.groups()
242
+ b_major, b_minor, b_patch, b_pre = b_match.groups()
243
+ a_ints = (int(a_major), int(a_minor), int(a_patch))
244
+ b_ints = (int(b_major), int(b_minor), int(b_patch))
245
+ if a_ints != b_ints:
246
+ return -1 if a_ints < b_ints else 1
247
+ # Equal numeric portion — pre-release tags decide.
248
+ if a_pre is None and b_pre is None:
249
+ return 0
250
+ if a_pre is None:
251
+ return 1 # `a` is the release, `b` is a pre-release of it
252
+ if b_pre is None:
253
+ return -1
254
+ if a_pre == b_pre:
255
+ return 0
256
+ return -1 if a_pre < b_pre else 1
257
+
258
+
259
+ # ---------------------------------------------------------------------------
260
+ # Manifest parsing (pure)
261
+ # ---------------------------------------------------------------------------
262
+
263
+
264
+ def parse_manifest(body: Any) -> ManifestSnapshot | None:
265
+ """Parse a manifest JSON body into a snapshot.
266
+
267
+ Returns ``None`` if the shape doesn't match the schema. Designed to be
268
+ paranoid — the substrate is a public read surface, so any malformed
269
+ response must be ignored rather than crashing the daemon.
270
+ """
271
+ if not isinstance(body, dict):
272
+ return None
273
+ schema_version = body.get("schema_version")
274
+ if not isinstance(schema_version, int) or schema_version != 1:
275
+ return None
276
+
277
+ channels_raw = body.get("channels", {})
278
+ channels: dict[str, str | None] = {}
279
+ if isinstance(channels_raw, dict):
280
+ for key, value in channels_raw.items():
281
+ if not isinstance(key, str):
282
+ continue
283
+ if value is None or isinstance(value, str):
284
+ channels[key] = value
285
+
286
+ platforms_raw = body.get("platforms", {})
287
+ platform_versions: dict[PlatformKey, str] = {}
288
+ if isinstance(platforms_raw, dict):
289
+ for key, value in platforms_raw.items():
290
+ if not isinstance(key, str) or not isinstance(value, dict):
291
+ continue
292
+ version = value.get("latest_version")
293
+ if isinstance(version, str) and version.strip():
294
+ platform_versions[key] = version.strip()
295
+
296
+ updated_at_raw = body.get("updated_at")
297
+ updated_at = updated_at_raw if isinstance(updated_at_raw, str) else ""
298
+
299
+ return ManifestSnapshot(
300
+ schema_version=schema_version,
301
+ channels=channels,
302
+ platform_versions=platform_versions,
303
+ updated_at=updated_at,
304
+ )
305
+
306
+
307
+ # ---------------------------------------------------------------------------
308
+ # UpdateLoop Component
309
+ # ---------------------------------------------------------------------------
310
+
311
+
312
+ class UpdateLoop(Component):
313
+ """Polls the release substrate on cadence and logs what it sees.
314
+
315
+ Wave 1 daemon scaffold per D-AUTOUPDATE-1 §Rollout Phase 1. No download,
316
+ no verify, no apply — those land in subsequent phases. Operators verify
317
+ Phase 1 by reading the daemon's structured log output.
318
+
319
+ The component is intentionally narrow:
320
+
321
+ * One responsibility: poll → parse → compare → log.
322
+ * No side effects on the filesystem, no signals out, no apply.
323
+ * Defensive against every malformed-manifest shape the public substrate
324
+ could plausibly return.
325
+ """
326
+
327
+ name: str = "update-loop"
328
+
329
+ def __init__(
330
+ self,
331
+ *,
332
+ manifest_url: str = DEFAULT_MANIFEST_URL,
333
+ poll_interval_seconds: int = DEFAULT_POLL_INTERVAL_SECONDS,
334
+ runtime_version: str = RUNTIME_VERSION,
335
+ platform_key: Any = _AUTO_DETECT,
336
+ channel: str | None = None,
337
+ http_client_factory: Any = None,
338
+ rng: random.Random | None = None,
339
+ ) -> None:
340
+ self._manifest_url = manifest_url
341
+ self._poll_interval_seconds = poll_interval_seconds
342
+ self._runtime_version = runtime_version
343
+ # Sentinel handling: _AUTO_DETECT means use the live platform; an
344
+ # explicit None means "the running platform is unsupported by the
345
+ # manifest, and this loop should never resolve an advertised version
346
+ # for it". Used by tests to pin the unsupported-platform path.
347
+ if platform_key is _AUTO_DETECT:
348
+ self._platform_key = detect_platform_key()
349
+ else:
350
+ self._platform_key = platform_key
351
+ self._channel = channel if channel is not None else resolve_channel()
352
+ self._http_client_factory = http_client_factory or _default_client_factory
353
+ self._rng = rng or random.Random()
354
+ self._consecutive_failures = 0
355
+ self._stopping = asyncio.Event()
356
+
357
+ async def stop(self) -> None:
358
+ self._stopping.set()
359
+
360
+ async def run(self) -> None: # pragma: no cover - exercised by integration smoke
361
+ """Long-running poll loop.
362
+
363
+ Fires an initial poll after a small settle delay, then on the
364
+ configured cadence with jitter. Cancellation-safe: ``stop()``
365
+ unblocks the next sleep and the loop exits cleanly.
366
+ """
367
+ # Initial fire-and-forget settle delay so daemon startup is not
368
+ # blocked by the first poll's network round-trip.
369
+ try:
370
+ await asyncio.wait_for(self._stopping.wait(), timeout=INITIAL_POLL_DELAY_SECONDS)
371
+ return # stopping requested during settle
372
+ except (TimeoutError, asyncio.TimeoutError):
373
+ pass
374
+
375
+ while not self._stopping.is_set():
376
+ await self.poll_once()
377
+
378
+ sleep_for = self._poll_interval_seconds * (
379
+ 1.0 + self._rng.uniform(-JITTER_FRACTION, JITTER_FRACTION)
380
+ )
381
+ try:
382
+ await asyncio.wait_for(self._stopping.wait(), timeout=sleep_for)
383
+ return # stopping requested during sleep
384
+ except (TimeoutError, asyncio.TimeoutError):
385
+ continue
386
+
387
+ async def poll_once(self) -> "PollOutcome":
388
+ """Run one poll cycle. Idempotent; never raises out."""
389
+ if self._platform_key is None:
390
+ logger.debug("update-loop: skipping poll — running platform is unsupported by manifest")
391
+ return PollOutcome("platform-unsupported", current=self._runtime_version)
392
+
393
+ try:
394
+ manifest = await self._fetch_manifest()
395
+ except Exception as exc:
396
+ self._consecutive_failures += 1
397
+ if self._consecutive_failures >= UNREACHABLE_WARN_THRESHOLD:
398
+ logger.warning(
399
+ "update-loop: release substrate unreachable for %d consecutive polls"
400
+ " — staying on %s",
401
+ self._consecutive_failures,
402
+ self._runtime_version,
403
+ )
404
+ else:
405
+ logger.debug(
406
+ "update-loop: poll failed (%s) — staying on %s, retry next tick",
407
+ exc,
408
+ self._runtime_version,
409
+ )
410
+ return PollOutcome(
411
+ "fetch-failed",
412
+ current=self._runtime_version,
413
+ error=str(exc),
414
+ )
415
+
416
+ # Reset failure counter on a successful fetch.
417
+ self._consecutive_failures = 0
418
+
419
+ if manifest is None:
420
+ logger.debug(
421
+ "update-loop: manifest at %s is not parseable — ignoring",
422
+ self._manifest_url,
423
+ )
424
+ return PollOutcome("manifest-malformed", current=self._runtime_version)
425
+
426
+ advertised = manifest.version_for(self._channel, self._platform_key)
427
+ if advertised is None:
428
+ logger.debug(
429
+ "update-loop: manifest has no release for channel=%s platform=%s"
430
+ " — substrate may not have ingested first release yet",
431
+ self._channel,
432
+ self._platform_key,
433
+ )
434
+ return PollOutcome(
435
+ "no-release-advertised",
436
+ current=self._runtime_version,
437
+ channel=self._channel,
438
+ platform_key=self._platform_key,
439
+ )
440
+
441
+ cmp = compare_semver(advertised, self._runtime_version)
442
+ if cmp > 0:
443
+ logger.info(
444
+ "update-loop: newer release available — current=%s advertised=%s"
445
+ " channel=%s platform=%s"
446
+ " (Phase 1 observation only; no download or apply this phase)",
447
+ self._runtime_version,
448
+ advertised,
449
+ self._channel,
450
+ self._platform_key,
451
+ )
452
+ return PollOutcome(
453
+ "newer-available",
454
+ current=self._runtime_version,
455
+ advertised=advertised,
456
+ channel=self._channel,
457
+ platform_key=self._platform_key,
458
+ )
459
+ if cmp < 0:
460
+ logger.warning(
461
+ "update-loop: running ahead of manifest — current=%s advertised=%s channel=%s "
462
+ "(pre-release or local-build path; safe to ignore unless unexpected)",
463
+ self._runtime_version,
464
+ advertised,
465
+ self._channel,
466
+ )
467
+ return PollOutcome(
468
+ "running-ahead",
469
+ current=self._runtime_version,
470
+ advertised=advertised,
471
+ channel=self._channel,
472
+ platform_key=self._platform_key,
473
+ )
474
+
475
+ logger.debug(
476
+ "update-loop: already current — current=%s channel=%s platform=%s",
477
+ self._runtime_version,
478
+ self._channel,
479
+ self._platform_key,
480
+ )
481
+ return PollOutcome(
482
+ "already-current",
483
+ current=self._runtime_version,
484
+ advertised=advertised,
485
+ channel=self._channel,
486
+ platform_key=self._platform_key,
487
+ )
488
+
489
+ async def _fetch_manifest(self) -> ManifestSnapshot | None:
490
+ client_cm = self._http_client_factory() # type: ignore[operator]
491
+ async with client_cm as client:
492
+ response = await client.get(
493
+ self._manifest_url,
494
+ timeout=NETWORK_TIMEOUT_SECONDS,
495
+ )
496
+ response.raise_for_status()
497
+ body = response.json()
498
+ return parse_manifest(body)
499
+
500
+
501
+ # ---------------------------------------------------------------------------
502
+ # Internal helpers
503
+ # ---------------------------------------------------------------------------
504
+
505
+
506
+ @dataclass(frozen=True)
507
+ class PollOutcome:
508
+ """Structured view of a single poll cycle. Returned for unit-testing."""
509
+
510
+ kind: str
511
+ current: str = ""
512
+ advertised: str = ""
513
+ channel: str = ""
514
+ platform_key: str = ""
515
+ error: str = ""
516
+
517
+
518
+ def _default_client_factory() -> "httpx.AsyncClient": # pragma: no cover - thin wrapper
519
+ return httpx.AsyncClient(http2=False, follow_redirects=True)
@@ -0,0 +1,21 @@
1
+ """alter_runtime.weave — Weave coordination-plane utilities.
2
+
3
+ D-WEAVE-VC-2 (RATIFIED 2026-05-21): semantic-unit resolver and import-graph
4
+ primitives that underpin the pre-write advisory deconfliction layer.
5
+
6
+ This sub-package is pure observation — no blocking, no locks, no winner-picking.
7
+ """
8
+
9
+ from alter_runtime.weave.resolver import (
10
+ SemanticUnit,
11
+ import_graph,
12
+ resolve,
13
+ reverse_dependents,
14
+ )
15
+
16
+ __all__ = [
17
+ "SemanticUnit",
18
+ "resolve",
19
+ "import_graph",
20
+ "reverse_dependents",
21
+ ]