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.
- alter_runtime/__init__.py +11 -0
- alter_runtime/adapters/__init__.py +19 -0
- alter_runtime/adapters/claude_jsonl_watcher.py +545 -0
- alter_runtime/adapters/git_watcher.py +457 -0
- alter_runtime/adapters/household/__init__.py +29 -0
- alter_runtime/adapters/household/_base.py +138 -0
- alter_runtime/adapters/household/compost/__init__.py +17 -0
- alter_runtime/adapters/household/compost/adapter.py +81 -0
- alter_runtime/adapters/household/compost/storage.py +75 -0
- alter_runtime/adapters/household/compost/tests/__init__.py +0 -0
- alter_runtime/adapters/household/compost/tests/test_adapter.py +62 -0
- alter_runtime/adapters/household/compost/tests/test_storage.py +23 -0
- alter_runtime/adapters/household/compost/tests/test_traits.py +38 -0
- alter_runtime/adapters/household/compost/traits.py +79 -0
- alter_runtime/adapters/household/self_hoster/__init__.py +30 -0
- alter_runtime/adapters/household/self_hoster/adapter.py +248 -0
- alter_runtime/adapters/household/self_hoster/storage.py +83 -0
- alter_runtime/adapters/household/self_hoster/tests/__init__.py +0 -0
- alter_runtime/adapters/household/self_hoster/tests/test_adapter.py +216 -0
- alter_runtime/adapters/household/self_hoster/tests/test_storage.py +25 -0
- alter_runtime/adapters/household/self_hoster/tests/test_traits.py +55 -0
- alter_runtime/adapters/household/self_hoster/traits.py +105 -0
- alter_runtime/adapters/household/tapo_ecosystem/__init__.py +22 -0
- alter_runtime/adapters/household/tapo_ecosystem/adapter.py +98 -0
- alter_runtime/adapters/household/tapo_ecosystem/storage.py +95 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/__init__.py +0 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/test_adapter.py +55 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/test_storage.py +28 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/test_traits.py +45 -0
- alter_runtime/adapters/household/tapo_ecosystem/traits.py +97 -0
- alter_runtime/adapters/household/workshop_tools/__init__.py +25 -0
- alter_runtime/adapters/household/workshop_tools/adapter.py +77 -0
- alter_runtime/adapters/household/workshop_tools/storage.py +92 -0
- alter_runtime/adapters/household/workshop_tools/tests/__init__.py +0 -0
- alter_runtime/adapters/household/workshop_tools/tests/test_adapter.py +48 -0
- alter_runtime/adapters/household/workshop_tools/tests/test_storage.py +26 -0
- alter_runtime/adapters/household/workshop_tools/tests/test_traits.py +45 -0
- alter_runtime/adapters/household/workshop_tools/traits.py +95 -0
- alter_runtime/adapters/worktree_watcher.py +378 -0
- alter_runtime/atlas/__init__.py +48 -0
- alter_runtime/atlas/base.py +102 -0
- alter_runtime/atlas/ledger.py +196 -0
- alter_runtime/atlas/observations.py +136 -0
- alter_runtime/atlas/schema.py +106 -0
- alter_runtime/cap_cache.py +392 -0
- alter_runtime/cli.py +517 -0
- alter_runtime/clients/__init__.py +0 -0
- alter_runtime/clients/token_usage_client.py +273 -0
- alter_runtime/config.py +648 -0
- alter_runtime/consent.py +425 -0
- alter_runtime/daemon.py +518 -0
- alter_runtime/floor_loop.py +335 -0
- alter_runtime/floor_preflight.py +734 -0
- alter_runtime/http_auth.py +173 -0
- alter_runtime/notifiers/__init__.py +18 -0
- alter_runtime/notifiers/desktop.py +321 -0
- alter_runtime/sdk/__init__.py +12 -0
- alter_runtime/sdk/client.py +399 -0
- alter_runtime/service_install.py +616 -0
- alter_runtime/services/__init__.py +59 -0
- alter_runtime/services/launchd/com.alter.runtime.plist.in +90 -0
- alter_runtime/services/systemd/alter-runtime.service.in +74 -0
- alter_runtime/services/systemd/cf-access-env.conf.in +29 -0
- alter_runtime/sockets/__init__.py +20 -0
- alter_runtime/sockets/dbus.py +272 -0
- alter_runtime/sockets/unix.py +702 -0
- alter_runtime/subscribers/__init__.py +58 -0
- alter_runtime/subscribers/active_sessions_cron_emitter.py +313 -0
- alter_runtime/subscribers/active_sessions_do_publisher.py +1159 -0
- alter_runtime/subscribers/active_sessions_gc.py +432 -0
- alter_runtime/subscribers/active_sessions_writer.py +446 -0
- alter_runtime/subscribers/adapters_writer.py +415 -0
- alter_runtime/subscribers/agent_frames.py +461 -0
- alter_runtime/subscribers/bus.py +188 -0
- alter_runtime/subscribers/cache_writer.py +347 -0
- alter_runtime/subscribers/ceremony_echo.py +290 -0
- alter_runtime/subscribers/do_sse.py +864 -0
- alter_runtime/subscribers/ebpf.py +506 -0
- alter_runtime/subscribers/inbox_writer.py +469 -0
- alter_runtime/subscribers/mcp_fallback.py +391 -0
- alter_runtime/subscribers/presence_writer.py +426 -0
- alter_runtime/subscribers/session_presence.py +467 -0
- alter_runtime/subscribers/sse.py +125 -0
- alter_runtime/subscribers/weave_intent_writer.py +608 -0
- alter_runtime/update_loop.py +519 -0
- alter_runtime/weave/__init__.py +21 -0
- alter_runtime/weave/resolver.py +544 -0
- alter_runtime-0.3.0.dist-info/METADATA +289 -0
- alter_runtime-0.3.0.dist-info/RECORD +92 -0
- alter_runtime-0.3.0.dist-info/WHEEL +4 -0
- alter_runtime-0.3.0.dist-info/entry_points.txt +2 -0
- 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
|
+
]
|