toolframe-core 0.6.2__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.
- toolframe_core/__init__.py +3 -0
- toolframe_core/_state_postgres.py +420 -0
- toolframe_core/_state_sqlite.py +339 -0
- toolframe_core/access.py +513 -0
- toolframe_core/api.py +700 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/LICENSE +21 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/SKILL.md +59 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/agents/openai.yaml +4 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/LICENSE +21 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/pyproject.toml +21 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/__init__.py +1 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/docs/usage.md +13 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/migrations/0001_init.sql +5 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/page.py +25 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/plugin.py +119 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/service.py +48 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/references/design.md +60 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/references/operations.md +133 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/references/plugin-api.md +186 -0
- toolframe_core/cli.py +681 -0
- toolframe_core/config.py +267 -0
- toolframe_core/diagnostics.py +200 -0
- toolframe_core/graph.py +207 -0
- toolframe_core/host.py +2610 -0
- toolframe_core/maintenance.py +868 -0
- toolframe_core/mcp_access.py +259 -0
- toolframe_core/network.py +97 -0
- toolframe_core/oidc_access.py +187 -0
- toolframe_core/packages.py +728 -0
- toolframe_core/process.py +212 -0
- toolframe_core/redaction.py +120 -0
- toolframe_core/scaffold.py +142 -0
- toolframe_core/skills.py +210 -0
- toolframe_core/state.py +226 -0
- toolframe_core/state_admin.py +270 -0
- toolframe_core/stores.py +88 -0
- toolframe_core/templates.json +6 -0
- toolframe_core/templates.py +56 -0
- toolframe_core-0.6.2.dist-info/METADATA +465 -0
- toolframe_core-0.6.2.dist-info/RECORD +46 -0
- toolframe_core-0.6.2.dist-info/WHEEL +4 -0
- toolframe_core-0.6.2.dist-info/entry_points.txt +2 -0
- toolframe_core-0.6.2.dist-info/licenses/LICENSE +202 -0
- toolframe_core-0.6.2.dist-info/licenses/NOTICE +14 -0
- toolframe_core-0.6.2.dist-info/licenses/src/toolframe_core/bundled_skills/toolframe-plugin-design/LICENSE +21 -0
- toolframe_core-0.6.2.dist-info/licenses/src/toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/LICENSE +21 -0
toolframe_core/host.py
ADDED
|
@@ -0,0 +1,2610 @@
|
|
|
1
|
+
"""Persistent Cordis host; plugin implementation code only runs in workers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import copy
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import math
|
|
11
|
+
import os
|
|
12
|
+
import time
|
|
13
|
+
import uuid
|
|
14
|
+
from contextlib import ExitStack
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import tomlkit
|
|
19
|
+
from fastapi_cordis import Plugin as CordisPlugin
|
|
20
|
+
from fastapi_cordis import PluginManager
|
|
21
|
+
from toolframe_sdk import PluginError, Principal, digest
|
|
22
|
+
from toolframe_sdk.logging import LoggingSession
|
|
23
|
+
from toolframe_sdk.tracing import configure_tracing
|
|
24
|
+
|
|
25
|
+
from .access import Access, operation_scopes
|
|
26
|
+
from .config import ConfigFile, HostLock, PluginRequest, atomic_write, management_token
|
|
27
|
+
from .diagnostics import dependency_snapshot
|
|
28
|
+
from .graph import Graph
|
|
29
|
+
from .mcp_access import McpAccess
|
|
30
|
+
from .network import bind_host
|
|
31
|
+
from .packages import Packages, pinned_index_source, source_files
|
|
32
|
+
from .process import WorkerProcess, group_exists, process_inventory, stop_group
|
|
33
|
+
from .redaction import redact, secret_values
|
|
34
|
+
from .state import CoreState, StateSettings
|
|
35
|
+
from .stores import RetainedStores
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class Runtime:
|
|
40
|
+
name: str
|
|
41
|
+
generation: str
|
|
42
|
+
candidate: dict
|
|
43
|
+
worker: WorkerProcess | None = None
|
|
44
|
+
health: dict = field(default_factory=dict)
|
|
45
|
+
observed: float = 0
|
|
46
|
+
admitted: bool = False
|
|
47
|
+
epoch: str | None = None
|
|
48
|
+
_logged_state: str | None = field(default=None, repr=False)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class Root:
|
|
53
|
+
id: str
|
|
54
|
+
owner: str
|
|
55
|
+
deadline: float
|
|
56
|
+
runtimes: dict
|
|
57
|
+
leases: ExitStack
|
|
58
|
+
paths: dict = field(default_factory=dict)
|
|
59
|
+
read_only: bool = False
|
|
60
|
+
principal: Principal | None = None
|
|
61
|
+
scope_catalog: dict = field(default_factory=dict)
|
|
62
|
+
bindings: dict = field(default_factory=dict)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Host:
|
|
66
|
+
@classmethod
|
|
67
|
+
async def create(
|
|
68
|
+
cls, home, *, scratch=False, listen_host=None, state_settings=None
|
|
69
|
+
):
|
|
70
|
+
host = cls()
|
|
71
|
+
try:
|
|
72
|
+
await host._initialize(
|
|
73
|
+
home,
|
|
74
|
+
scratch=scratch,
|
|
75
|
+
listen_host=listen_host,
|
|
76
|
+
state_settings=state_settings,
|
|
77
|
+
)
|
|
78
|
+
return host
|
|
79
|
+
except BaseException:
|
|
80
|
+
if getattr(host, "logging", None):
|
|
81
|
+
host.logging.close()
|
|
82
|
+
if getattr(host, "tracing", None):
|
|
83
|
+
await asyncio.to_thread(host.tracing.shutdown)
|
|
84
|
+
if getattr(host, "state", None):
|
|
85
|
+
await host.state.close()
|
|
86
|
+
if getattr(host, "lock", None):
|
|
87
|
+
host.lock.close()
|
|
88
|
+
raise
|
|
89
|
+
|
|
90
|
+
async def _initialize(
|
|
91
|
+
self, home, *, scratch=False, listen_host=None, state_settings=None
|
|
92
|
+
):
|
|
93
|
+
self.home = Path(home).resolve()
|
|
94
|
+
self.config = ConfigFile(self.home)
|
|
95
|
+
self.lock = HostLock(self.home)
|
|
96
|
+
self.lock.acquire()
|
|
97
|
+
self.state = await CoreState.open(self.home, settings=state_settings)
|
|
98
|
+
self.journal_error = None
|
|
99
|
+
self.configuration_error = None
|
|
100
|
+
try:
|
|
101
|
+
self.composition, self.revision = self.config.read()
|
|
102
|
+
except PluginError as error:
|
|
103
|
+
from .config import Application, Composition
|
|
104
|
+
|
|
105
|
+
self.configuration_error = error.as_dict()
|
|
106
|
+
previous = await self.state.meta("last_valid_composition")
|
|
107
|
+
self.composition = (
|
|
108
|
+
Composition.model_validate(previous)
|
|
109
|
+
if previous
|
|
110
|
+
else Composition(application=Application(id="unconfigured"))
|
|
111
|
+
)
|
|
112
|
+
self.revision = self.config.revision
|
|
113
|
+
self.initial_application = self.composition.application.model_dump()
|
|
114
|
+
self.stores = await RetainedStores.load(self.state)
|
|
115
|
+
self.listen_host = bind_host(
|
|
116
|
+
listen_host
|
|
117
|
+
if listen_host is not None
|
|
118
|
+
else self.composition.application.host
|
|
119
|
+
)
|
|
120
|
+
self.host_run = uuid.uuid4().hex
|
|
121
|
+
self.tracing = (
|
|
122
|
+
None
|
|
123
|
+
if scratch
|
|
124
|
+
else configure_tracing(
|
|
125
|
+
"toolframe.core",
|
|
126
|
+
{
|
|
127
|
+
"service.namespace": self.composition.application.id,
|
|
128
|
+
"service.instance.id": self.host_run,
|
|
129
|
+
"toolframe.application.id": self.composition.application.id,
|
|
130
|
+
},
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
# A private logger keeps multiple embedded hosts and their exporters isolated.
|
|
134
|
+
self.logging = LoggingSession(
|
|
135
|
+
logging.Logger("toolframe.core", logging.DEBUG), # noqa: LOG001 - host-owned, never registered globally
|
|
136
|
+
{
|
|
137
|
+
"toolframe.application.id": self.composition.application.id,
|
|
138
|
+
"service.instance.id": self.host_run,
|
|
139
|
+
},
|
|
140
|
+
tracing=self.tracing,
|
|
141
|
+
)
|
|
142
|
+
self.log = self.logging.log
|
|
143
|
+
if (
|
|
144
|
+
self.state.settings.backend == "postgresql"
|
|
145
|
+
and self.composition.application.id != self.state.settings.application_id
|
|
146
|
+
):
|
|
147
|
+
raise PluginError(
|
|
148
|
+
"core_state_identity_mismatch",
|
|
149
|
+
"Composition and Core database identities differ",
|
|
150
|
+
)
|
|
151
|
+
self.token = management_token(self.home)
|
|
152
|
+
self.packages = Packages(self.home, self.composition.packages)
|
|
153
|
+
self._package_overrides()
|
|
154
|
+
self.manager = PluginManager(
|
|
155
|
+
drain_timeout=self.composition.runtime.drain_timeout
|
|
156
|
+
)
|
|
157
|
+
self.graph = Graph({}, self.composition.bindings)
|
|
158
|
+
self.runtimes: dict[str, Runtime] = {}
|
|
159
|
+
self.registered = set()
|
|
160
|
+
self.gated = set()
|
|
161
|
+
self.roots: dict[str, Root] = {}
|
|
162
|
+
self.changed = asyncio.Event()
|
|
163
|
+
self.mutation = asyncio.Lock()
|
|
164
|
+
self.operations = {}
|
|
165
|
+
self.cancellations = set()
|
|
166
|
+
self.tasks = []
|
|
167
|
+
self.closing = False
|
|
168
|
+
self._closed = False
|
|
169
|
+
self.scratch = scratch
|
|
170
|
+
self.epoch = None
|
|
171
|
+
self.publication = {}
|
|
172
|
+
self.health_publication_pending = False
|
|
173
|
+
self.gateway_sessions = {}
|
|
174
|
+
self.startup_error = None
|
|
175
|
+
self.schema_changed = False
|
|
176
|
+
self.schema_events = []
|
|
177
|
+
self.optional_failures = {}
|
|
178
|
+
self.optional_new = set()
|
|
179
|
+
self.orphans = {}
|
|
180
|
+
self.failed_roots = set()
|
|
181
|
+
self.cleanup_blocked = set()
|
|
182
|
+
self.worker_owners = await self.state.meta("worker_owners", {})
|
|
183
|
+
self.starting = True
|
|
184
|
+
self.transitioning = set()
|
|
185
|
+
self.access = Access(self)
|
|
186
|
+
self.mcp_access = McpAccess(self)
|
|
187
|
+
self.operation_identities = {}
|
|
188
|
+
|
|
189
|
+
async def start(self, *, apply=True, wait=True):
|
|
190
|
+
previous_node = await self.state.meta("host_node")
|
|
191
|
+
node = self.state.settings.node_id
|
|
192
|
+
if node and previous_node and previous_node != node and self.worker_owners:
|
|
193
|
+
raise PluginError(
|
|
194
|
+
"core_state_recovery_required",
|
|
195
|
+
"Worker history belongs to another node; verify cleanup before recovery",
|
|
196
|
+
)
|
|
197
|
+
await self.reconcile_workers()
|
|
198
|
+
(await self.state.reconcile_startup(self.host_run))
|
|
199
|
+
(await self.state.set_meta("host_run", self.host_run))
|
|
200
|
+
await self.state.set_meta("host_node", node)
|
|
201
|
+
await self.manager.start()
|
|
202
|
+
atomic_write(
|
|
203
|
+
self.home / ".toolframe/endpoint.json",
|
|
204
|
+
json.dumps(
|
|
205
|
+
{
|
|
206
|
+
"host": self.listen_host,
|
|
207
|
+
"port": self.composition.application.port,
|
|
208
|
+
}
|
|
209
|
+
),
|
|
210
|
+
)
|
|
211
|
+
if apply:
|
|
212
|
+
operation = await self.submit("apply", {"startup": True})
|
|
213
|
+
if wait:
|
|
214
|
+
await self.wait_operation(operation["id"])
|
|
215
|
+
else:
|
|
216
|
+
self.starting = False
|
|
217
|
+
if not self.scratch:
|
|
218
|
+
self.tasks = [
|
|
219
|
+
asyncio.create_task(self._health_loop()),
|
|
220
|
+
asyncio.create_task(self._watch_loop()),
|
|
221
|
+
asyncio.create_task(self._exit_loop()),
|
|
222
|
+
]
|
|
223
|
+
if self.state.settings.backend == "postgresql":
|
|
224
|
+
self.tasks.append(asyncio.create_task(self._journal_loop()))
|
|
225
|
+
|
|
226
|
+
async def _journal_loop(self):
|
|
227
|
+
while not self.closing:
|
|
228
|
+
await asyncio.sleep(0.5)
|
|
229
|
+
try:
|
|
230
|
+
await self.state.ping()
|
|
231
|
+
except PluginError as error:
|
|
232
|
+
self.journal_error = error.as_dict()
|
|
233
|
+
await self._stop_after_journal_loss()
|
|
234
|
+
return
|
|
235
|
+
|
|
236
|
+
async def _stop_after_journal_loss(self):
|
|
237
|
+
self.closing = True
|
|
238
|
+
self.gated.update(self.runtimes)
|
|
239
|
+
current = asyncio.current_task()
|
|
240
|
+
pending = [
|
|
241
|
+
task
|
|
242
|
+
for task in [*self.tasks, *self.operations.values()]
|
|
243
|
+
if task is not current
|
|
244
|
+
]
|
|
245
|
+
for task in pending:
|
|
246
|
+
task.cancel()
|
|
247
|
+
await asyncio.gather(*pending, return_exceptions=True)
|
|
248
|
+
for sessions in self.gateway_sessions.values():
|
|
249
|
+
for task in list(sessions):
|
|
250
|
+
task.cancel()
|
|
251
|
+
# EOF cleanup may finish gracefully; failures remain visible and leave the
|
|
252
|
+
# durable ownership marker dirty. There is never an automatic takeover.
|
|
253
|
+
for runtime in list(self.runtimes.values()):
|
|
254
|
+
runtime.admitted = False
|
|
255
|
+
if runtime.worker:
|
|
256
|
+
try:
|
|
257
|
+
await runtime.worker.close(
|
|
258
|
+
timeout=self.composition.runtime.disposal_timeout,
|
|
259
|
+
allow_exited=True,
|
|
260
|
+
)
|
|
261
|
+
except BaseException as error:
|
|
262
|
+
if isinstance(error, asyncio.CancelledError):
|
|
263
|
+
raise
|
|
264
|
+
self.orphans[runtime.name] = {
|
|
265
|
+
"pid": runtime.worker.pid,
|
|
266
|
+
"reason": "journal_loss_cleanup_unproven",
|
|
267
|
+
}
|
|
268
|
+
try:
|
|
269
|
+
await self.manager.close()
|
|
270
|
+
except Exception: # noqa: BLE001 - preserve a failed cleanup for explicit recovery
|
|
271
|
+
# Cleanup failures keep the durable marker dirty for operator recovery.
|
|
272
|
+
self.journal_error = {**(self.journal_error or {}), "cleanup": "unproven"}
|
|
273
|
+
|
|
274
|
+
async def reconcile_workers(self):
|
|
275
|
+
previous_run = await self.state.meta("host_run")
|
|
276
|
+
inventory = await process_inventory()
|
|
277
|
+
# Also find the narrow spawn-before-journal window using the previous run's
|
|
278
|
+
# token, which is durable before any worker is launched.
|
|
279
|
+
if previous_run:
|
|
280
|
+
for pid, item in inventory.items():
|
|
281
|
+
marker = f"--host-token {previous_run}/"
|
|
282
|
+
if (
|
|
283
|
+
"toolframe_sdk.worker" in item["command"]
|
|
284
|
+
and marker in item["command"]
|
|
285
|
+
):
|
|
286
|
+
token = item["command"].split("--host-token ", 1)[1].split()[0]
|
|
287
|
+
self.worker_owners[str(pid)] = {
|
|
288
|
+
"name": token.split("/")[1],
|
|
289
|
+
"token": token,
|
|
290
|
+
}
|
|
291
|
+
if any(group_exists(int(pid)) for pid in self.worker_owners):
|
|
292
|
+
deadline = (
|
|
293
|
+
asyncio.get_running_loop().time()
|
|
294
|
+
+ self.composition.runtime.disposal_timeout
|
|
295
|
+
)
|
|
296
|
+
while (
|
|
297
|
+
any(group_exists(int(pid)) for pid in self.worker_owners)
|
|
298
|
+
and asyncio.get_running_loop().time() < deadline
|
|
299
|
+
):
|
|
300
|
+
await asyncio.sleep(
|
|
301
|
+
0.1
|
|
302
|
+
) # EOF cleanup is cooperative, never an implicit kill.
|
|
303
|
+
for pid, owner in list(self.worker_owners.items()):
|
|
304
|
+
if group_exists(int(pid)):
|
|
305
|
+
self.orphans[owner["name"]] = {"pid": int(pid), **owner}
|
|
306
|
+
else:
|
|
307
|
+
self.worker_owners.pop(pid)
|
|
308
|
+
(await self.state.set_meta("worker_owners", self.worker_owners))
|
|
309
|
+
|
|
310
|
+
async def stop_orphan(self, name):
|
|
311
|
+
orphan = self.orphans[name]
|
|
312
|
+
current = (await process_inventory()).get(orphan["pid"])
|
|
313
|
+
if group_exists(orphan["pid"]):
|
|
314
|
+
if (
|
|
315
|
+
not current
|
|
316
|
+
or f"--host-token {orphan['token']}" not in current["command"]
|
|
317
|
+
):
|
|
318
|
+
raise PluginError(
|
|
319
|
+
"ownership_unproven",
|
|
320
|
+
"Surviving process ownership cannot be verified; resolve it before retrying",
|
|
321
|
+
orphan,
|
|
322
|
+
)
|
|
323
|
+
await stop_group(orphan["pid"])
|
|
324
|
+
self.orphans.pop(name)
|
|
325
|
+
self.worker_owners.pop(str(orphan["pid"]), None)
|
|
326
|
+
(await self.state.set_meta("worker_owners", self.worker_owners))
|
|
327
|
+
(await self.state.stopped(name, True))
|
|
328
|
+
|
|
329
|
+
async def close(self):
|
|
330
|
+
if self._closed:
|
|
331
|
+
return
|
|
332
|
+
self.closing = True
|
|
333
|
+
for task in self.tasks:
|
|
334
|
+
task.cancel()
|
|
335
|
+
await asyncio.gather(*self.tasks, return_exceptions=True)
|
|
336
|
+
if self.state.failed:
|
|
337
|
+
await self._stop_after_journal_loss()
|
|
338
|
+
else:
|
|
339
|
+
for operation in self.operations.values():
|
|
340
|
+
if not operation.done():
|
|
341
|
+
await asyncio.shield(operation)
|
|
342
|
+
self.gated.update(self.runtimes)
|
|
343
|
+
clean = False
|
|
344
|
+
try:
|
|
345
|
+
if not self.state.failed:
|
|
346
|
+
await self._drain(
|
|
347
|
+
set(self.runtimes), self.composition.runtime.drain_timeout
|
|
348
|
+
)
|
|
349
|
+
await self.manager.close()
|
|
350
|
+
clean = not self.orphans and not self.worker_owners
|
|
351
|
+
finally:
|
|
352
|
+
self.lock.close()
|
|
353
|
+
await self.state.close(clean=clean)
|
|
354
|
+
self._closed = True
|
|
355
|
+
self.logging.close()
|
|
356
|
+
if self.tracing:
|
|
357
|
+
await asyncio.to_thread(self.tracing.shutdown)
|
|
358
|
+
|
|
359
|
+
async def submit(self, action, params, *, idempotency=None, identity=None):
|
|
360
|
+
if self.state.failed:
|
|
361
|
+
raise PluginError("core_state_unavailable", "Core journal is unavailable")
|
|
362
|
+
if self.closing:
|
|
363
|
+
raise PluginError("host_closing", "Core is shutting down")
|
|
364
|
+
actions = {
|
|
365
|
+
"add",
|
|
366
|
+
"validate",
|
|
367
|
+
"update",
|
|
368
|
+
"reload",
|
|
369
|
+
"enable",
|
|
370
|
+
"disable",
|
|
371
|
+
"remove",
|
|
372
|
+
"stop",
|
|
373
|
+
"apply",
|
|
374
|
+
"plan",
|
|
375
|
+
"migrate-plan",
|
|
376
|
+
"migrate-apply",
|
|
377
|
+
}
|
|
378
|
+
if (
|
|
379
|
+
not isinstance(action, str)
|
|
380
|
+
or action not in actions
|
|
381
|
+
or not isinstance(params, dict)
|
|
382
|
+
):
|
|
383
|
+
raise PluginError(
|
|
384
|
+
"invalid_request", "Supply a known action and object parameters"
|
|
385
|
+
)
|
|
386
|
+
for flag in ("force", "runtime", "enable", "watch", "startup"):
|
|
387
|
+
if flag in params and not isinstance(params[flag], bool):
|
|
388
|
+
raise PluginError("invalid_request", f"{flag} must be boolean")
|
|
389
|
+
if action not in {"add", "apply", "plan"} and not isinstance(
|
|
390
|
+
params.get("id"), str
|
|
391
|
+
):
|
|
392
|
+
raise PluginError("invalid_request", "This action requires a plugin ID")
|
|
393
|
+
if action == "add" and not isinstance(params.get("source"), str):
|
|
394
|
+
raise PluginError("invalid_request", "Add requires a package source")
|
|
395
|
+
# A retry refers to the original operation, whose own successful write
|
|
396
|
+
# may already have changed the desired revision.
|
|
397
|
+
existing = await self.state.idempotent_operation(
|
|
398
|
+
action, params.get("id"), params, idempotency
|
|
399
|
+
)
|
|
400
|
+
if existing is not None:
|
|
401
|
+
return existing
|
|
402
|
+
if (
|
|
403
|
+
params.get("expected_revision")
|
|
404
|
+
and params["expected_revision"] != self.config.revision
|
|
405
|
+
):
|
|
406
|
+
raise PluginError(
|
|
407
|
+
"stale_revision", "Composition changed; refresh before submitting"
|
|
408
|
+
)
|
|
409
|
+
operation, created = await self.state.new_operation(
|
|
410
|
+
action, params.get("id"), params, idempotency
|
|
411
|
+
)
|
|
412
|
+
if created:
|
|
413
|
+
if identity is not None:
|
|
414
|
+
self.operation_identities[operation["id"]] = identity
|
|
415
|
+
self.operations[operation["id"]] = asyncio.create_task(
|
|
416
|
+
self._operate(operation)
|
|
417
|
+
)
|
|
418
|
+
return operation
|
|
419
|
+
|
|
420
|
+
async def wait_operation(self, identifier):
|
|
421
|
+
task = self.operations.get(identifier)
|
|
422
|
+
if task:
|
|
423
|
+
await asyncio.shield(task)
|
|
424
|
+
return await self.state.operation(identifier)
|
|
425
|
+
|
|
426
|
+
async def phase(self, operation, phase, result=None):
|
|
427
|
+
(await self.state.update_operation(operation, phase=phase, result=result))
|
|
428
|
+
|
|
429
|
+
def check_cancel(self, operation):
|
|
430
|
+
if operation in self.cancellations:
|
|
431
|
+
raise PluginError("cancelled", "Operation was cancelled before disposal")
|
|
432
|
+
|
|
433
|
+
async def _operate(self, operation):
|
|
434
|
+
identifier = operation["id"]
|
|
435
|
+
async with self.mutation:
|
|
436
|
+
(
|
|
437
|
+
await self.state.update_operation(
|
|
438
|
+
identifier, status="running", phase="planning"
|
|
439
|
+
)
|
|
440
|
+
)
|
|
441
|
+
try:
|
|
442
|
+
self.check_cancel(identifier)
|
|
443
|
+
if identity := self.operation_identities.get(identifier):
|
|
444
|
+
await self.access.admit(
|
|
445
|
+
identity,
|
|
446
|
+
operation_scopes(operation["kind"], operation["request"]),
|
|
447
|
+
activity=True,
|
|
448
|
+
effective=False,
|
|
449
|
+
)
|
|
450
|
+
result = await self._dispatch(
|
|
451
|
+
operation["kind"], operation["request"], identifier
|
|
452
|
+
)
|
|
453
|
+
(
|
|
454
|
+
await self.state.update_operation(
|
|
455
|
+
identifier, status="succeeded", phase="complete", result=result
|
|
456
|
+
)
|
|
457
|
+
)
|
|
458
|
+
except Exception as error: # noqa: BLE001 - durable boundary, keep management alive
|
|
459
|
+
detail = (
|
|
460
|
+
error.as_dict()
|
|
461
|
+
if isinstance(error, PluginError)
|
|
462
|
+
else {
|
|
463
|
+
"code": "operation_failed",
|
|
464
|
+
"message": str(error),
|
|
465
|
+
"details": {},
|
|
466
|
+
}
|
|
467
|
+
)
|
|
468
|
+
status = (
|
|
469
|
+
"failed_recovered"
|
|
470
|
+
if detail["code"] == "failed_recovered"
|
|
471
|
+
else "cancelled"
|
|
472
|
+
if detail["code"] == "cancelled"
|
|
473
|
+
else "failed"
|
|
474
|
+
)
|
|
475
|
+
(
|
|
476
|
+
await self.state.update_operation(
|
|
477
|
+
identifier, status=status, error=self.redact(detail)
|
|
478
|
+
)
|
|
479
|
+
)
|
|
480
|
+
finally:
|
|
481
|
+
self.cancellations.discard(identifier)
|
|
482
|
+
self.operation_identities.pop(identifier, None)
|
|
483
|
+
if operation["request"].get("startup"):
|
|
484
|
+
self.starting = False
|
|
485
|
+
|
|
486
|
+
async def refresh(self):
|
|
487
|
+
composition, revision = self.config.read()
|
|
488
|
+
if composition.application.model_dump() != self.initial_application:
|
|
489
|
+
raise PluginError(
|
|
490
|
+
"restart_required",
|
|
491
|
+
"Application identity/listen configuration changed; restart core explicitly",
|
|
492
|
+
)
|
|
493
|
+
self.composition, self.revision = composition, revision
|
|
494
|
+
self.configuration_error = None
|
|
495
|
+
self.packages.settings = composition.packages
|
|
496
|
+
self._package_overrides()
|
|
497
|
+
self.manager.drain_timeout = composition.runtime.drain_timeout
|
|
498
|
+
(await self.state.set_meta("last_valid_composition", composition.model_dump()))
|
|
499
|
+
|
|
500
|
+
def _package_overrides(self):
|
|
501
|
+
wheelhouse = os.environ.get("TOOLFRAME_WHEELHOUSE")
|
|
502
|
+
if wheelhouse and wheelhouse not in self.packages.settings.find_links:
|
|
503
|
+
self.packages.settings.find_links.append(wheelhouse)
|
|
504
|
+
if os.environ.get("TOOLFRAME_OFFLINE") == "1":
|
|
505
|
+
self.packages.settings.offline = True
|
|
506
|
+
|
|
507
|
+
async def _prepare(self, name, operation, *, installed=False):
|
|
508
|
+
(await self.phase(operation, "capture_validate", {"plugin_id": name}))
|
|
509
|
+
request = self.composition.plugins[name]
|
|
510
|
+
stored = await self.state.plugin(name)
|
|
511
|
+
previous = stored["last_candidate"] or stored["current_candidate"]
|
|
512
|
+
retained = (await self.state.candidate(previous)) if previous else None
|
|
513
|
+
if (
|
|
514
|
+
not retained
|
|
515
|
+
or retained["source"]["request"] != request.source
|
|
516
|
+
or not (installed or pinned_index_source(request.source))
|
|
517
|
+
):
|
|
518
|
+
retained = None
|
|
519
|
+
candidate = await self.packages.prepare(
|
|
520
|
+
name, request.source, request.config, retained=retained
|
|
521
|
+
)
|
|
522
|
+
if self.config.revision != self.revision:
|
|
523
|
+
raise PluginError(
|
|
524
|
+
"stale_plan", "Composition changed while preparing the candidate"
|
|
525
|
+
)
|
|
526
|
+
(await self.state.save_candidate(candidate))
|
|
527
|
+
return candidate
|
|
528
|
+
|
|
529
|
+
async def selected(self):
|
|
530
|
+
selected = {}
|
|
531
|
+
for name, request in self.composition.plugins.items():
|
|
532
|
+
if request.enabled and not (await self.state.plugin(name))["stopped"]:
|
|
533
|
+
stored = await self.state.plugin(name)
|
|
534
|
+
candidate = stored["current_candidate"] or stored["last_candidate"]
|
|
535
|
+
if candidate:
|
|
536
|
+
selected[name] = await self.state.candidate(candidate)
|
|
537
|
+
return selected
|
|
538
|
+
|
|
539
|
+
async def _dispatch(self, action, params, operation):
|
|
540
|
+
(await self.refresh())
|
|
541
|
+
name = params.get("id")
|
|
542
|
+
if params.get("startup") and (await self.state.meta("forward_repair")):
|
|
543
|
+
raise PluginError(
|
|
544
|
+
"forward_repair_required",
|
|
545
|
+
"Interrupted schema operation requires explicit inspection and repair",
|
|
546
|
+
(await self.state.meta("forward_repair")),
|
|
547
|
+
)
|
|
548
|
+
if (
|
|
549
|
+
params.get("expected_revision")
|
|
550
|
+
and params["expected_revision"] != self.revision
|
|
551
|
+
):
|
|
552
|
+
raise PluginError(
|
|
553
|
+
"stale_revision", "Composition changed before operation started"
|
|
554
|
+
)
|
|
555
|
+
if action == "plan":
|
|
556
|
+
return await self.plan(params, operation)
|
|
557
|
+
approved = None
|
|
558
|
+
if params.get("plan_id") and action != "migrate-apply":
|
|
559
|
+
approved = await self.state.meta(f"plan/{params['plan_id']}")
|
|
560
|
+
if (
|
|
561
|
+
not approved
|
|
562
|
+
or approved["action"] != action
|
|
563
|
+
or approved.get("target") != name
|
|
564
|
+
or approved["basis"] != await self.plan_basis()
|
|
565
|
+
):
|
|
566
|
+
raise PluginError(
|
|
567
|
+
"stale_plan",
|
|
568
|
+
"Review a fresh plan; composition, runtime or schema changed",
|
|
569
|
+
)
|
|
570
|
+
if action == "add":
|
|
571
|
+
source = params["source"]
|
|
572
|
+
if not name:
|
|
573
|
+
name = (await self.packages.capture(source))["plugin_id"]
|
|
574
|
+
if name in self.composition.plugins:
|
|
575
|
+
raise PluginError(
|
|
576
|
+
"already_installed",
|
|
577
|
+
"Plugin already exists in composition; validate/reload or edit its source",
|
|
578
|
+
)
|
|
579
|
+
request = PluginRequest(
|
|
580
|
+
source=source,
|
|
581
|
+
enabled=params.get("enable", False),
|
|
582
|
+
config=params.get("config", {}),
|
|
583
|
+
)
|
|
584
|
+
candidate = await self.packages.prepare(name, source, request.config)
|
|
585
|
+
await self.validate_candidate(candidate, operation)
|
|
586
|
+
self.composition, self.revision = self.config.put_plugin(
|
|
587
|
+
name, request, self.revision
|
|
588
|
+
)
|
|
589
|
+
(await self.state.save_candidate(candidate))
|
|
590
|
+
if request.enabled:
|
|
591
|
+
proposed = await self.selected()
|
|
592
|
+
proposed[name] = candidate
|
|
593
|
+
await self.replace(proposed, {name}, operation)
|
|
594
|
+
(await self.write_lock())
|
|
595
|
+
return {
|
|
596
|
+
"plugin_id": name,
|
|
597
|
+
"candidate": candidate["id"],
|
|
598
|
+
"enabled": request.enabled,
|
|
599
|
+
}
|
|
600
|
+
if (
|
|
601
|
+
action
|
|
602
|
+
in (
|
|
603
|
+
"validate",
|
|
604
|
+
"update",
|
|
605
|
+
"reload",
|
|
606
|
+
"enable",
|
|
607
|
+
"disable",
|
|
608
|
+
"remove",
|
|
609
|
+
"stop",
|
|
610
|
+
"migrate-plan",
|
|
611
|
+
"migrate-apply",
|
|
612
|
+
)
|
|
613
|
+
and name not in self.composition.plugins
|
|
614
|
+
):
|
|
615
|
+
raise PluginError(
|
|
616
|
+
"plugin_missing", "Plugin is not declared in toolframe.toml"
|
|
617
|
+
)
|
|
618
|
+
if action == "update":
|
|
619
|
+
request = self.composition.plugins[name]
|
|
620
|
+
source = params.get("source", request.source)
|
|
621
|
+
candidate = await self.packages.prepare(name, source, request.config)
|
|
622
|
+
if source != request.source:
|
|
623
|
+
self.composition, self.revision = self.config.put_plugin(
|
|
624
|
+
name, request.model_copy(update={"source": source}), self.revision
|
|
625
|
+
)
|
|
626
|
+
elif self.config.revision != self.revision:
|
|
627
|
+
raise PluginError(
|
|
628
|
+
"stale_plan", "Composition changed while staging update"
|
|
629
|
+
)
|
|
630
|
+
(await self.state.save_candidate(candidate))
|
|
631
|
+
(await self.write_lock())
|
|
632
|
+
return {
|
|
633
|
+
"candidate": candidate["id"],
|
|
634
|
+
"validation": candidate["validation"],
|
|
635
|
+
"activated": False,
|
|
636
|
+
}
|
|
637
|
+
if action == "validate":
|
|
638
|
+
candidate = await self._prepare(name, operation)
|
|
639
|
+
await self.validate_candidate(candidate, operation)
|
|
640
|
+
if params.get("runtime"):
|
|
641
|
+
await self.runtime_validate(name, candidate)
|
|
642
|
+
candidate["validation"]["runtime"] = "passed_scratch"
|
|
643
|
+
(await self.state.save_candidate(candidate))
|
|
644
|
+
(await self.write_lock())
|
|
645
|
+
return {"candidate": candidate["id"], "validation": candidate["validation"]}
|
|
646
|
+
if action == "stop" and params.get("force"):
|
|
647
|
+
if name in self.orphans:
|
|
648
|
+
await self.stop_orphan(name)
|
|
649
|
+
return {"plugin_id": name, "orphan_stopped": True}
|
|
650
|
+
affected = self.graph.affected(self.graph, {name})
|
|
651
|
+
self.gated.update(affected)
|
|
652
|
+
for member in affected:
|
|
653
|
+
runtime = self.runtimes.get(member)
|
|
654
|
+
if runtime:
|
|
655
|
+
runtime.admitted = False
|
|
656
|
+
await runtime.worker.force_stop()
|
|
657
|
+
# A failed Cordis disposer has already consumed its scope;
|
|
658
|
+
# record confirmed termination even when it cannot run again.
|
|
659
|
+
await self._dispose(runtime)
|
|
660
|
+
(await self.state.stopped(member, True))
|
|
661
|
+
for root in list(self.roots.values()):
|
|
662
|
+
if affected & root.runtimes.keys():
|
|
663
|
+
await self.end_root(root)
|
|
664
|
+
for member in reversed(self.graph.order):
|
|
665
|
+
if member in affected and member in self.registered:
|
|
666
|
+
with contextlib.suppress(Exception):
|
|
667
|
+
await self.manager.unregister(member)
|
|
668
|
+
self.registered.discard(member)
|
|
669
|
+
await self.publish()
|
|
670
|
+
return {
|
|
671
|
+
"affected": sorted(affected),
|
|
672
|
+
"warning": "Interrupted write acknowledgements may be unknown; inspect mutation receipts",
|
|
673
|
+
}
|
|
674
|
+
if action in ("migrate-plan", "migrate-apply"):
|
|
675
|
+
candidate = await self._prepare(name, operation)
|
|
676
|
+
schema = candidate["definition"].get("store")
|
|
677
|
+
if not schema:
|
|
678
|
+
raise PluginError(
|
|
679
|
+
"store_not_declared", "Plugin does not declare a store schema"
|
|
680
|
+
)
|
|
681
|
+
proposed = {**(await self.selected()), name: candidate}
|
|
682
|
+
graph = Graph(proposed, self.composition.bindings)
|
|
683
|
+
storage = self._storage_runtime(name, graph=graph)
|
|
684
|
+
plan = await self._migration_plan(storage, name, schema)
|
|
685
|
+
if action == "migrate-plan":
|
|
686
|
+
(
|
|
687
|
+
await self.state.set_meta(
|
|
688
|
+
f"migration/{name}",
|
|
689
|
+
{
|
|
690
|
+
"plan": plan,
|
|
691
|
+
"candidate": candidate["id"],
|
|
692
|
+
"revision": self.revision,
|
|
693
|
+
},
|
|
694
|
+
)
|
|
695
|
+
)
|
|
696
|
+
return {
|
|
697
|
+
**plan,
|
|
698
|
+
"candidate": candidate["id"],
|
|
699
|
+
"desired_revision": self.revision,
|
|
700
|
+
}
|
|
701
|
+
saved = await self.state.meta(f"migration/{name}")
|
|
702
|
+
if (
|
|
703
|
+
not saved
|
|
704
|
+
or params.get("plan_id") != plan["plan_id"]
|
|
705
|
+
or saved["candidate"] != candidate["id"]
|
|
706
|
+
or saved["revision"] != self.revision
|
|
707
|
+
or saved["plan"]["plan_id"] != plan["plan_id"]
|
|
708
|
+
):
|
|
709
|
+
raise PluginError(
|
|
710
|
+
"stale_plan", "Review a current migration plan before applying"
|
|
711
|
+
)
|
|
712
|
+
proposed = await self.selected()
|
|
713
|
+
proposed[name] = candidate
|
|
714
|
+
await self.replace(
|
|
715
|
+
proposed,
|
|
716
|
+
{name},
|
|
717
|
+
operation,
|
|
718
|
+
migration={
|
|
719
|
+
"owner": name,
|
|
720
|
+
"schema": schema,
|
|
721
|
+
"provider": storage.name,
|
|
722
|
+
"plan_id": plan["provider_plan_id"],
|
|
723
|
+
},
|
|
724
|
+
)
|
|
725
|
+
return {"migration": plan["plan_id"], "candidate": candidate["id"]}
|
|
726
|
+
if action in ("reload", "enable", "disable", "remove", "stop"):
|
|
727
|
+
if action in ("reload", "enable"):
|
|
728
|
+
candidate = (
|
|
729
|
+
(
|
|
730
|
+
await self.state.candidate(
|
|
731
|
+
approved.get("target_candidate")
|
|
732
|
+
or approved["candidates"][name]
|
|
733
|
+
)
|
|
734
|
+
)
|
|
735
|
+
if approved
|
|
736
|
+
else await self._prepare(
|
|
737
|
+
name, operation, installed=action == "enable"
|
|
738
|
+
)
|
|
739
|
+
)
|
|
740
|
+
if action == "stop":
|
|
741
|
+
(await self.state.stopped(name, True))
|
|
742
|
+
if action in ("enable", "disable", "remove"):
|
|
743
|
+
|
|
744
|
+
def edit(document):
|
|
745
|
+
if action == "remove":
|
|
746
|
+
del document["plugins"][name]
|
|
747
|
+
else:
|
|
748
|
+
document["plugins"][name]["enabled"] = action == "enable"
|
|
749
|
+
|
|
750
|
+
self.composition, self.revision = self.config.edit(self.revision, edit)
|
|
751
|
+
proposed = await self.selected()
|
|
752
|
+
if action in ("reload", "enable"):
|
|
753
|
+
(await self.state.stopped(name, False))
|
|
754
|
+
if self.composition.plugins[name].enabled:
|
|
755
|
+
proposed[name] = candidate
|
|
756
|
+
else:
|
|
757
|
+
proposed.pop(name, None)
|
|
758
|
+
await self.replace(proposed, {name}, operation)
|
|
759
|
+
return {"plugin_id": name, "affected_action": action}
|
|
760
|
+
if action == "apply":
|
|
761
|
+
proposed = {}
|
|
762
|
+
for member, request in self.composition.plugins.items():
|
|
763
|
+
self.check_cancel(operation)
|
|
764
|
+
(await self.state.stopped(member, False))
|
|
765
|
+
if request.enabled:
|
|
766
|
+
proposed[member] = (
|
|
767
|
+
(await self.state.candidate(approved["candidates"][member]))
|
|
768
|
+
if approved
|
|
769
|
+
else await self._prepare(member, operation, installed=True)
|
|
770
|
+
)
|
|
771
|
+
changed = {
|
|
772
|
+
member
|
|
773
|
+
for member in proposed.keys() | self.graph.candidates.keys()
|
|
774
|
+
if proposed.get(member, {}).get("id")
|
|
775
|
+
!= self.graph.candidates.get(member, {}).get("id")
|
|
776
|
+
}
|
|
777
|
+
changed |= {
|
|
778
|
+
member
|
|
779
|
+
for member in proposed
|
|
780
|
+
if member not in self.runtimes or not self.runtimes[member].admitted
|
|
781
|
+
}
|
|
782
|
+
graph = Graph(proposed, self.composition.bindings)
|
|
783
|
+
changed |= {
|
|
784
|
+
member
|
|
785
|
+
for member in graph.bindings
|
|
786
|
+
if graph.bindings[member] != self.graph.bindings.get(member)
|
|
787
|
+
}
|
|
788
|
+
await self.replace(proposed, changed, operation)
|
|
789
|
+
return {"plugins": sorted(proposed)}
|
|
790
|
+
raise PluginError("action_unknown", f"Unknown management action {action}")
|
|
791
|
+
|
|
792
|
+
async def validate_candidate(self, candidate, operation):
|
|
793
|
+
"""Validate a hypothetical target without publishing or changing live stores."""
|
|
794
|
+
name = candidate["plugin_id"]
|
|
795
|
+
proposed = {**(await self.selected()), name: candidate}
|
|
796
|
+
validation = candidate["validation"]
|
|
797
|
+
stages = validation.setdefault("stages", {})
|
|
798
|
+
stage = "dependency_graph"
|
|
799
|
+
try:
|
|
800
|
+
(await self.phase(operation, "preflight", {"plugin_id": name}))
|
|
801
|
+
graph = Graph(proposed, self.composition.bindings)
|
|
802
|
+
blockers = {
|
|
803
|
+
member: graph.missing[member]
|
|
804
|
+
for member in graph.closure(name)
|
|
805
|
+
if graph.missing[member]
|
|
806
|
+
}
|
|
807
|
+
stages[stage] = {
|
|
808
|
+
"status": "blocked" if blockers else "passed",
|
|
809
|
+
"reasons": blockers,
|
|
810
|
+
}
|
|
811
|
+
validation["blockers"] = blockers
|
|
812
|
+
definition = graph.definitions[name]
|
|
813
|
+
stage = "store_history"
|
|
814
|
+
stages[stage] = {"status": "skipped", "reason": "No managed store declared"}
|
|
815
|
+
requires_migration = False
|
|
816
|
+
if definition.store:
|
|
817
|
+
storage_owner = graph.stores.get(name)
|
|
818
|
+
if storage_owner:
|
|
819
|
+
self.stores.check(name, storage_owner)
|
|
820
|
+
if storage_owner and self.healthy_runtime(storage_owner):
|
|
821
|
+
plan = await self._migration_plan(
|
|
822
|
+
self.runtimes[storage_owner],
|
|
823
|
+
name,
|
|
824
|
+
definition.store.model_dump(),
|
|
825
|
+
)
|
|
826
|
+
requires_migration = plan["requires_apply"]
|
|
827
|
+
stages[stage] = {
|
|
828
|
+
"status": "blocked" if requires_migration else "passed",
|
|
829
|
+
"plan": plan,
|
|
830
|
+
}
|
|
831
|
+
else:
|
|
832
|
+
stages[stage] = {
|
|
833
|
+
"status": "blocked",
|
|
834
|
+
"reason": "Storage provider is unavailable",
|
|
835
|
+
}
|
|
836
|
+
blockers.setdefault(name, []).append("live_storage_history")
|
|
837
|
+
stage = "dashboard_environment"
|
|
838
|
+
stages[stage] = {"status": "skipped", "reason": "No Dashboard provider"}
|
|
839
|
+
dashboard = graph.providers.get("dashboard")
|
|
840
|
+
if dashboard:
|
|
841
|
+
cohort = [
|
|
842
|
+
item
|
|
843
|
+
for owner, item in proposed.items()
|
|
844
|
+
if owner == dashboard
|
|
845
|
+
or any(
|
|
846
|
+
resource.kind == "dashboard.page"
|
|
847
|
+
for resource in graph.definitions[owner].resources
|
|
848
|
+
)
|
|
849
|
+
]
|
|
850
|
+
environment = await self.packages.environment(cohort, ui=True)
|
|
851
|
+
proposed[dashboard] = {
|
|
852
|
+
**proposed[dashboard],
|
|
853
|
+
"ui_environment": environment,
|
|
854
|
+
}
|
|
855
|
+
stages[stage] = {"status": "passed", "environment": environment["id"]}
|
|
856
|
+
resources = graph.resources()
|
|
857
|
+
for kind, resource_kind in {
|
|
858
|
+
"dashboard": "dashboard.page",
|
|
859
|
+
"mcp": "mcp.tool",
|
|
860
|
+
"llms": "llms.document",
|
|
861
|
+
}.items():
|
|
862
|
+
stage = f"provider_{kind}"
|
|
863
|
+
owned = [
|
|
864
|
+
resource
|
|
865
|
+
for resource in resources
|
|
866
|
+
if resource.resource.kind == resource_kind
|
|
867
|
+
]
|
|
868
|
+
provider = graph.providers.get(kind)
|
|
869
|
+
if owned and provider:
|
|
870
|
+
await self.prepare_provider(proposed[provider], owned)
|
|
871
|
+
stages[stage] = {"status": "passed"}
|
|
872
|
+
else:
|
|
873
|
+
stages[stage] = {
|
|
874
|
+
"status": "skipped",
|
|
875
|
+
"reason": "No matching resources or provider",
|
|
876
|
+
}
|
|
877
|
+
if self.config.revision != self.revision:
|
|
878
|
+
raise PluginError("stale_plan", "Composition changed during validation")
|
|
879
|
+
self.check_cancel(operation)
|
|
880
|
+
validation.update(
|
|
881
|
+
preflight="passed",
|
|
882
|
+
ready_to_activate=not blockers and not requires_migration,
|
|
883
|
+
migration_required=requires_migration,
|
|
884
|
+
observed_at=time.time(),
|
|
885
|
+
)
|
|
886
|
+
except Exception as error:
|
|
887
|
+
stages[stage] = {"status": "failed", "reason": str(error)}
|
|
888
|
+
validation.update(
|
|
889
|
+
preflight="failed", ready_to_activate=False, observed_at=time.time()
|
|
890
|
+
)
|
|
891
|
+
raise
|
|
892
|
+
finally:
|
|
893
|
+
(await self.state.save_candidate(candidate))
|
|
894
|
+
|
|
895
|
+
async def plan_basis(self):
|
|
896
|
+
schemas = {}
|
|
897
|
+
for name, definition in self.graph.definitions.items():
|
|
898
|
+
if definition.store and name in self.runtimes:
|
|
899
|
+
schemas[name] = await self.admin(
|
|
900
|
+
self._storage_runtime(name), "inspect", {"owner": name}
|
|
901
|
+
)
|
|
902
|
+
return {
|
|
903
|
+
"revision": self.config.revision,
|
|
904
|
+
"generations": {
|
|
905
|
+
name: item.generation for name, item in self.runtimes.items()
|
|
906
|
+
},
|
|
907
|
+
"epoch": self.epoch,
|
|
908
|
+
"schemas": schemas,
|
|
909
|
+
"store_bindings": dict(self.stores.bindings),
|
|
910
|
+
"stopped": sorted(
|
|
911
|
+
[row["id"] for row in (await self.state.plugins()) if row["stopped"]]
|
|
912
|
+
),
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
async def plan(self, params, operation):
|
|
916
|
+
action, name = params.get("action", "apply"), params.get("id")
|
|
917
|
+
if action not in {"apply", "reload", "enable", "disable", "remove", "stop"}:
|
|
918
|
+
raise PluginError("invalid_request", "This action cannot be planned")
|
|
919
|
+
if action != "apply" and name not in self.composition.plugins:
|
|
920
|
+
raise PluginError("plugin_missing", "Plan requires a declared plugin ID")
|
|
921
|
+
basis = await self.plan_basis()
|
|
922
|
+
proposed = await self.selected()
|
|
923
|
+
if action == "apply":
|
|
924
|
+
proposed = {}
|
|
925
|
+
for member, request in self.composition.plugins.items():
|
|
926
|
+
if request.enabled:
|
|
927
|
+
proposed[member] = await self._prepare(
|
|
928
|
+
member, operation, installed=True
|
|
929
|
+
)
|
|
930
|
+
elif action in {"reload", "enable"}:
|
|
931
|
+
candidate = await self._prepare(
|
|
932
|
+
name, operation, installed=action == "enable"
|
|
933
|
+
)
|
|
934
|
+
if action == "enable" or self.composition.plugins[name].enabled:
|
|
935
|
+
proposed[name] = candidate
|
|
936
|
+
else:
|
|
937
|
+
proposed.pop(name, None)
|
|
938
|
+
replacement = Graph(proposed, self.composition.bindings)
|
|
939
|
+
changed = {
|
|
940
|
+
member
|
|
941
|
+
for member in proposed.keys() | self.graph.candidates.keys()
|
|
942
|
+
if proposed.get(member, {}).get("id")
|
|
943
|
+
!= self.graph.candidates.get(member, {}).get("id")
|
|
944
|
+
or replacement.bindings.get(member) != self.graph.bindings.get(member)
|
|
945
|
+
or member not in self.runtimes
|
|
946
|
+
or not self.runtimes[member].admitted
|
|
947
|
+
}
|
|
948
|
+
if name:
|
|
949
|
+
changed.add(name)
|
|
950
|
+
result = {
|
|
951
|
+
"action": action,
|
|
952
|
+
"target": name,
|
|
953
|
+
"basis": basis,
|
|
954
|
+
"candidates": {
|
|
955
|
+
member: candidate["id"] for member, candidate in proposed.items()
|
|
956
|
+
},
|
|
957
|
+
"affected": sorted(self.graph.affected(replacement, changed)),
|
|
958
|
+
"missing_dependencies": {
|
|
959
|
+
member: missing
|
|
960
|
+
for member, missing in replacement.missing.items()
|
|
961
|
+
if missing
|
|
962
|
+
},
|
|
963
|
+
"migrations": {},
|
|
964
|
+
"stores": self.stores.describe(replacement),
|
|
965
|
+
}
|
|
966
|
+
if action in {"reload", "enable"}:
|
|
967
|
+
result["target_candidate"] = candidate["id"]
|
|
968
|
+
for member in changed & proposed.keys():
|
|
969
|
+
definition = replacement.definitions[member]
|
|
970
|
+
if definition.store and member in self.runtimes:
|
|
971
|
+
provider = replacement.stores.get(member)
|
|
972
|
+
if provider and self.healthy_runtime(provider):
|
|
973
|
+
result["migrations"][member] = await self._migration_plan(
|
|
974
|
+
self.runtimes[provider], member, definition.store.model_dump()
|
|
975
|
+
)
|
|
976
|
+
if basis != await self.plan_basis():
|
|
977
|
+
raise PluginError("stale_plan", "Runtime changed while planning")
|
|
978
|
+
result["plan_id"] = digest(result)
|
|
979
|
+
(await self.state.set_meta(f"plan/{result['plan_id']}", result))
|
|
980
|
+
return result
|
|
981
|
+
|
|
982
|
+
def _storage_runtime(self, owner, *, graph=None):
|
|
983
|
+
provider = (graph or self.graph).stores.get(owner)
|
|
984
|
+
if not provider or provider not in self.runtimes:
|
|
985
|
+
raise PluginError(
|
|
986
|
+
"storage_unavailable",
|
|
987
|
+
"The owner's declared storage provider must be active",
|
|
988
|
+
)
|
|
989
|
+
self.stores.check(owner, provider)
|
|
990
|
+
return self.runtimes[provider]
|
|
991
|
+
|
|
992
|
+
async def _migration_plan(self, storage, owner, schema):
|
|
993
|
+
plan = await self.admin(storage, "plan", {"owner": owner, "schema": schema})
|
|
994
|
+
identity = {
|
|
995
|
+
"provider": storage.name,
|
|
996
|
+
"provider_generation": storage.generation,
|
|
997
|
+
"provider_plan_id": plan["plan_id"],
|
|
998
|
+
}
|
|
999
|
+
return {**plan, **identity, "plan_id": digest({**plan, **identity})}
|
|
1000
|
+
|
|
1001
|
+
async def admin(self, storage, method, params):
|
|
1002
|
+
return await storage.worker.request(
|
|
1003
|
+
"runtime.invoke",
|
|
1004
|
+
{
|
|
1005
|
+
"service": "toolframe.storage.admin",
|
|
1006
|
+
"method": method,
|
|
1007
|
+
"params": params,
|
|
1008
|
+
"context": {
|
|
1009
|
+
"management": True,
|
|
1010
|
+
"application_id": self.composition.application.id,
|
|
1011
|
+
"deadline": time.time()
|
|
1012
|
+
+ self.composition.runtime.activation_timeout,
|
|
1013
|
+
},
|
|
1014
|
+
},
|
|
1015
|
+
timeout=self.composition.runtime.activation_timeout,
|
|
1016
|
+
)
|
|
1017
|
+
|
|
1018
|
+
async def change_schema(self, storage, method, params):
|
|
1019
|
+
owner = params["owner"]
|
|
1020
|
+
self.stores.check(owner, storage.name)
|
|
1021
|
+
before = await self.admin(storage, "inspect", {"owner": owner})
|
|
1022
|
+
remembered = owner in self.stores.bindings
|
|
1023
|
+
await self.stores.remember(owner, storage.name)
|
|
1024
|
+
previous_repair = await self.state.meta("forward_repair")
|
|
1025
|
+
(
|
|
1026
|
+
await self.state.set_meta(
|
|
1027
|
+
"forward_repair",
|
|
1028
|
+
{
|
|
1029
|
+
"owner": owner,
|
|
1030
|
+
"provider": storage.name,
|
|
1031
|
+
"before": before,
|
|
1032
|
+
"target": params["schema"],
|
|
1033
|
+
"state": "in_progress",
|
|
1034
|
+
},
|
|
1035
|
+
)
|
|
1036
|
+
)
|
|
1037
|
+
try:
|
|
1038
|
+
result = await self.admin(storage, method, params)
|
|
1039
|
+
except BaseException:
|
|
1040
|
+
try:
|
|
1041
|
+
after = await self.admin(storage, "inspect", {"owner": owner})
|
|
1042
|
+
changed = after["history"] != before["history"]
|
|
1043
|
+
except BaseException: # noqa: BLE001 - unverified commit forbids automatic rollback
|
|
1044
|
+
changed = (
|
|
1045
|
+
True # Lost acknowledgements never authorize an old-code restart.
|
|
1046
|
+
)
|
|
1047
|
+
if changed:
|
|
1048
|
+
self.schema_changed = True
|
|
1049
|
+
self.schema_events.append(
|
|
1050
|
+
{
|
|
1051
|
+
"owner": owner,
|
|
1052
|
+
"provider": storage.name,
|
|
1053
|
+
"state": "committed_or_unknown",
|
|
1054
|
+
}
|
|
1055
|
+
)
|
|
1056
|
+
else:
|
|
1057
|
+
(await self.state.set_meta("forward_repair", previous_repair))
|
|
1058
|
+
if not remembered and not before["exists"] and not after["exists"]:
|
|
1059
|
+
await self.stores.forget_empty(owner)
|
|
1060
|
+
raise
|
|
1061
|
+
if result.get("committed"):
|
|
1062
|
+
self.schema_changed = True
|
|
1063
|
+
self.schema_events.append(
|
|
1064
|
+
{
|
|
1065
|
+
**result,
|
|
1066
|
+
"owner": owner,
|
|
1067
|
+
"provider": storage.name,
|
|
1068
|
+
"state": "committed",
|
|
1069
|
+
}
|
|
1070
|
+
)
|
|
1071
|
+
else:
|
|
1072
|
+
(await self.state.set_meta("forward_repair", previous_repair))
|
|
1073
|
+
return result
|
|
1074
|
+
|
|
1075
|
+
async def _activate(self, name, context):
|
|
1076
|
+
candidate = self.graph.candidates[name]
|
|
1077
|
+
generation = uuid.uuid4().hex
|
|
1078
|
+
runtime = Runtime(name, generation, candidate)
|
|
1079
|
+
self.runtimes[name] = runtime
|
|
1080
|
+
definition = self.graph.definitions[name]
|
|
1081
|
+
# Cordis cleanup owns even partially activated workers.
|
|
1082
|
+
context.on_dispose(self._dispose, runtime)
|
|
1083
|
+
environment = candidate.get("ui_environment", candidate["environment"])
|
|
1084
|
+
runtime.worker = await WorkerProcess.start(
|
|
1085
|
+
self.home / environment["python"],
|
|
1086
|
+
candidate["entrypoint"],
|
|
1087
|
+
lambda method, params: self.worker_call(runtime, method, params),
|
|
1088
|
+
cwd=self.home,
|
|
1089
|
+
host_token=f"{self.host_run}/{name}/{generation}",
|
|
1090
|
+
tracing=self.tracing,
|
|
1091
|
+
trace_attributes={
|
|
1092
|
+
"service.name": f"toolframe.plugin.{name}",
|
|
1093
|
+
"service.namespace": self.composition.application.id,
|
|
1094
|
+
"service.instance.id": generation,
|
|
1095
|
+
"toolframe.application.id": self.composition.application.id,
|
|
1096
|
+
"toolframe.plugin.id": name,
|
|
1097
|
+
"toolframe.plugin.generation": generation,
|
|
1098
|
+
},
|
|
1099
|
+
)
|
|
1100
|
+
self.worker_owners[str(runtime.worker.pid)] = {
|
|
1101
|
+
"name": name,
|
|
1102
|
+
"token": f"{self.host_run}/{name}/{generation}",
|
|
1103
|
+
}
|
|
1104
|
+
(await self.state.set_meta("worker_owners", self.worker_owners))
|
|
1105
|
+
(await self.record(runtime, "starting"))
|
|
1106
|
+
await runtime.worker.request("runtime.describe")
|
|
1107
|
+
store_exists = None
|
|
1108
|
+
if definition.private_secrets and definition.store:
|
|
1109
|
+
observed_store = await self.admin(
|
|
1110
|
+
self._storage_runtime(name), "inspect", {"owner": name}
|
|
1111
|
+
)
|
|
1112
|
+
store_exists = observed_store["exists"]
|
|
1113
|
+
private_secrets = self.access.private_secrets(
|
|
1114
|
+
definition, store_exists=store_exists
|
|
1115
|
+
)
|
|
1116
|
+
if definition.store:
|
|
1117
|
+
await self.change_schema(
|
|
1118
|
+
self._storage_runtime(name),
|
|
1119
|
+
"ensure_store",
|
|
1120
|
+
{"owner": name, "schema": definition.store.model_dump()},
|
|
1121
|
+
)
|
|
1122
|
+
runtime.health = await self.probe(
|
|
1123
|
+
runtime,
|
|
1124
|
+
"runtime.activate",
|
|
1125
|
+
{
|
|
1126
|
+
"generation": generation,
|
|
1127
|
+
"config": candidate["config"],
|
|
1128
|
+
"private_secrets": private_secrets,
|
|
1129
|
+
"state_directory": str(self.home / "data/plugins")
|
|
1130
|
+
if definition.provider_kind == "storage"
|
|
1131
|
+
else str(self.home / "data/objects" / name)
|
|
1132
|
+
if definition.provider_kind == "object-store"
|
|
1133
|
+
else None,
|
|
1134
|
+
},
|
|
1135
|
+
)
|
|
1136
|
+
runtime.observed = time.time()
|
|
1137
|
+
if runtime.health.get("runtime") != "ready" or runtime.health.get(
|
|
1138
|
+
"business"
|
|
1139
|
+
) in ("failed", "not_ready"):
|
|
1140
|
+
raise PluginError(
|
|
1141
|
+
"health_failed", f"{name} did not become ready", runtime.health
|
|
1142
|
+
)
|
|
1143
|
+
(await self.record(runtime, "prepared"))
|
|
1144
|
+
return runtime
|
|
1145
|
+
|
|
1146
|
+
async def probe(self, runtime, method="runtime.health", params=None):
|
|
1147
|
+
members = self.graph.closure(
|
|
1148
|
+
runtime.name,
|
|
1149
|
+
optional=True,
|
|
1150
|
+
available=self.available_dependencies(),
|
|
1151
|
+
)
|
|
1152
|
+
leases = ExitStack()
|
|
1153
|
+
root = None
|
|
1154
|
+
try:
|
|
1155
|
+
for name in members - {runtime.name}:
|
|
1156
|
+
leases.enter_context(self.manager.lease(name))
|
|
1157
|
+
root = Root(
|
|
1158
|
+
uuid.uuid4().hex,
|
|
1159
|
+
runtime.name,
|
|
1160
|
+
time.time() + self.composition.runtime.activation_timeout,
|
|
1161
|
+
{name: self.runtimes[name] for name in members},
|
|
1162
|
+
leases,
|
|
1163
|
+
read_only=True,
|
|
1164
|
+
bindings={name: dict(self.graph.bindings[name]) for name in members},
|
|
1165
|
+
)
|
|
1166
|
+
root.paths[(runtime.name,)] = 1
|
|
1167
|
+
self.roots[root.id] = root
|
|
1168
|
+
try:
|
|
1169
|
+
payload = {
|
|
1170
|
+
**(params or {}),
|
|
1171
|
+
"context": {
|
|
1172
|
+
"root_id": root.id,
|
|
1173
|
+
"deadline": root.deadline,
|
|
1174
|
+
"chain": [runtime.name],
|
|
1175
|
+
},
|
|
1176
|
+
}
|
|
1177
|
+
return await runtime.worker.request(
|
|
1178
|
+
method, payload, timeout=self.composition.runtime.activation_timeout
|
|
1179
|
+
)
|
|
1180
|
+
finally:
|
|
1181
|
+
await self.end_root(root)
|
|
1182
|
+
finally:
|
|
1183
|
+
if root is None or root.id not in self.roots:
|
|
1184
|
+
leases.close()
|
|
1185
|
+
|
|
1186
|
+
async def _dispose(self, runtime):
|
|
1187
|
+
runtime.admitted = False
|
|
1188
|
+
for session in list(self.gateway_sessions.get(runtime.name, ())):
|
|
1189
|
+
session.cancel()
|
|
1190
|
+
try:
|
|
1191
|
+
if runtime.worker:
|
|
1192
|
+
await runtime.worker.close(
|
|
1193
|
+
timeout=self.composition.runtime.disposal_timeout,
|
|
1194
|
+
allow_exited=True,
|
|
1195
|
+
)
|
|
1196
|
+
except BaseException:
|
|
1197
|
+
if not self.state.failed:
|
|
1198
|
+
await self.record(runtime, "cleanup_failed")
|
|
1199
|
+
raise
|
|
1200
|
+
else:
|
|
1201
|
+
if runtime.worker:
|
|
1202
|
+
self.worker_owners.pop(str(runtime.worker.pid), None)
|
|
1203
|
+
if not self.state.failed:
|
|
1204
|
+
await self.state.set_meta("worker_owners", self.worker_owners)
|
|
1205
|
+
if self.runtimes.get(runtime.name) is runtime:
|
|
1206
|
+
self.runtimes.pop(runtime.name)
|
|
1207
|
+
if not self.state.failed:
|
|
1208
|
+
await self.record(runtime, "stopped")
|
|
1209
|
+
|
|
1210
|
+
async def record(self, runtime, state):
|
|
1211
|
+
(
|
|
1212
|
+
await self.state.runtime(
|
|
1213
|
+
runtime.name,
|
|
1214
|
+
{
|
|
1215
|
+
"state": state,
|
|
1216
|
+
"generation": runtime.generation,
|
|
1217
|
+
"candidate": runtime.candidate["id"],
|
|
1218
|
+
"pid": runtime.worker.pid if runtime.worker else None,
|
|
1219
|
+
"health": runtime.health,
|
|
1220
|
+
"observed": runtime.observed,
|
|
1221
|
+
"host_run": self.host_run,
|
|
1222
|
+
"epoch": runtime.epoch,
|
|
1223
|
+
},
|
|
1224
|
+
)
|
|
1225
|
+
)
|
|
1226
|
+
if runtime._logged_state != state:
|
|
1227
|
+
self.log.info(
|
|
1228
|
+
"Plugin state changed",
|
|
1229
|
+
extra={
|
|
1230
|
+
"toolframe.target.plugin.id": runtime.name,
|
|
1231
|
+
"toolframe.target.generation": runtime.generation,
|
|
1232
|
+
"toolframe.plugin.state": state,
|
|
1233
|
+
},
|
|
1234
|
+
)
|
|
1235
|
+
runtime._logged_state = state
|
|
1236
|
+
|
|
1237
|
+
async def _register_set(self, names):
|
|
1238
|
+
for name in self.graph.order:
|
|
1239
|
+
if name not in names or name in self.optional_failures:
|
|
1240
|
+
continue
|
|
1241
|
+
missing = tuple(
|
|
1242
|
+
f"missing:{name}:{service}" for service in self.graph.missing[name]
|
|
1243
|
+
)
|
|
1244
|
+
try:
|
|
1245
|
+
await self.manager.register(
|
|
1246
|
+
CordisPlugin(
|
|
1247
|
+
name=name,
|
|
1248
|
+
setup=lambda context, name=name: self._activate(name, context),
|
|
1249
|
+
requires=tuple(self.graph.dependencies[name]) + missing,
|
|
1250
|
+
)
|
|
1251
|
+
)
|
|
1252
|
+
except Exception as error:
|
|
1253
|
+
if (
|
|
1254
|
+
self.graph.definitions[name].provider_kind
|
|
1255
|
+
not in {
|
|
1256
|
+
"dashboard",
|
|
1257
|
+
"mcp",
|
|
1258
|
+
"llms",
|
|
1259
|
+
"auth",
|
|
1260
|
+
"dashboard-auth",
|
|
1261
|
+
"mcp-auth",
|
|
1262
|
+
"oidc-auth",
|
|
1263
|
+
}
|
|
1264
|
+
or name not in self.optional_new
|
|
1265
|
+
):
|
|
1266
|
+
raise
|
|
1267
|
+
# A newly installed presentation must not prevent independent
|
|
1268
|
+
# business services from starting. Cleanup still has to succeed.
|
|
1269
|
+
if name in {item["name"] for item in self.manager.snapshot()}:
|
|
1270
|
+
await self.manager.unregister(name)
|
|
1271
|
+
self.optional_failures[name] = str(error)
|
|
1272
|
+
(
|
|
1273
|
+
await self.state.runtime(
|
|
1274
|
+
name,
|
|
1275
|
+
{
|
|
1276
|
+
"state": "failed",
|
|
1277
|
+
"error": str(error),
|
|
1278
|
+
"host_run": self.host_run,
|
|
1279
|
+
},
|
|
1280
|
+
)
|
|
1281
|
+
)
|
|
1282
|
+
finally:
|
|
1283
|
+
# Registration remains present when setup raises; cleanup/recovery must remove it.
|
|
1284
|
+
self.registered = {item["name"] for item in self.manager.snapshot()}
|
|
1285
|
+
runtime = self.runtimes.get(name)
|
|
1286
|
+
if (
|
|
1287
|
+
name not in self.optional_failures
|
|
1288
|
+
and not self.graph.missing[name]
|
|
1289
|
+
and all(
|
|
1290
|
+
dependency in self.runtimes
|
|
1291
|
+
for dependency in self.graph.dependencies[name]
|
|
1292
|
+
)
|
|
1293
|
+
and (runtime is None or runtime.health.get("runtime") != "ready")
|
|
1294
|
+
):
|
|
1295
|
+
raise PluginError(
|
|
1296
|
+
"activation_failed",
|
|
1297
|
+
f"{name} failed to activate",
|
|
1298
|
+
{"cordis": self.manager.snapshot()},
|
|
1299
|
+
)
|
|
1300
|
+
|
|
1301
|
+
async def _drain(self, affected, timeout, operation=None):
|
|
1302
|
+
try:
|
|
1303
|
+
async with asyncio.timeout(timeout):
|
|
1304
|
+
while any(
|
|
1305
|
+
affected & root.runtimes.keys() for root in self.roots.values()
|
|
1306
|
+
):
|
|
1307
|
+
if operation:
|
|
1308
|
+
self.check_cancel(operation)
|
|
1309
|
+
# Shutdown cancels the health/exit loops; a cancelled probe
|
|
1310
|
+
# can leave cleanup awaiting acknowledgement. The drain must
|
|
1311
|
+
# retry that idempotent cleanup before releasing its leases.
|
|
1312
|
+
for root_id in list(self.failed_roots):
|
|
1313
|
+
root = self.roots.get(root_id)
|
|
1314
|
+
if root and affected & root.runtimes.keys():
|
|
1315
|
+
try:
|
|
1316
|
+
await self.end_root(root)
|
|
1317
|
+
except Exception: # noqa: BLE001 - retain lease until cleanup is proven
|
|
1318
|
+
(
|
|
1319
|
+
await self.state.set_meta(
|
|
1320
|
+
"cleanup_pending", sorted(self.failed_roots)
|
|
1321
|
+
)
|
|
1322
|
+
)
|
|
1323
|
+
self.changed.clear()
|
|
1324
|
+
try:
|
|
1325
|
+
await asyncio.wait_for(self.changed.wait(), 0.1)
|
|
1326
|
+
except TimeoutError:
|
|
1327
|
+
pass
|
|
1328
|
+
except TimeoutError as error:
|
|
1329
|
+
raise PluginError(
|
|
1330
|
+
"drain_timeout",
|
|
1331
|
+
"Admitted calls have not completed; old workers are retained",
|
|
1332
|
+
) from error
|
|
1333
|
+
|
|
1334
|
+
async def _resume(self, affected):
|
|
1335
|
+
errors = {}
|
|
1336
|
+
for name in affected:
|
|
1337
|
+
runtime = self.runtimes.get(name)
|
|
1338
|
+
if runtime:
|
|
1339
|
+
try:
|
|
1340
|
+
await runtime.worker.request("runtime.resume")
|
|
1341
|
+
except Exception as error: # noqa: BLE001
|
|
1342
|
+
errors[name] = str(error)
|
|
1343
|
+
if errors:
|
|
1344
|
+
raise PluginError("resume_failed", "Some workers could not resume", errors)
|
|
1345
|
+
# All acknowledgements precede a single admission barrier. No partial cohort
|
|
1346
|
+
# may write while another member can still cause pre-admission recovery.
|
|
1347
|
+
for name in affected:
|
|
1348
|
+
runtime = self.runtimes.get(name)
|
|
1349
|
+
if runtime:
|
|
1350
|
+
runtime.admitted = True
|
|
1351
|
+
(await self.record(runtime, "active"))
|
|
1352
|
+
if name not in self.cleanup_blocked:
|
|
1353
|
+
self.gated.discard(name)
|
|
1354
|
+
self.changed.set()
|
|
1355
|
+
|
|
1356
|
+
async def replace(self, proposed, changed, operation, *, migration=None):
|
|
1357
|
+
try:
|
|
1358
|
+
return await self._replace(
|
|
1359
|
+
proposed, changed, operation, migration=migration
|
|
1360
|
+
)
|
|
1361
|
+
finally:
|
|
1362
|
+
self.transitioning.clear()
|
|
1363
|
+
|
|
1364
|
+
async def _replace(self, proposed, changed, operation, *, migration=None):
|
|
1365
|
+
repair = await self.state.meta("forward_repair")
|
|
1366
|
+
self.schema_changed = bool(repair)
|
|
1367
|
+
self.schema_events = (
|
|
1368
|
+
[repair]
|
|
1369
|
+
if repair and repair.get("owner")
|
|
1370
|
+
else (repair or {}).get("schemas", [])
|
|
1371
|
+
).copy()
|
|
1372
|
+
if self.orphans:
|
|
1373
|
+
raise PluginError(
|
|
1374
|
+
"cleanup_failed",
|
|
1375
|
+
"Previous worker ownership has not been released; use explicit stop --force for verified survivors",
|
|
1376
|
+
self.orphans,
|
|
1377
|
+
)
|
|
1378
|
+
replacement = Graph(proposed, self.composition.bindings)
|
|
1379
|
+
old_graph = self.graph
|
|
1380
|
+
changed = set(changed) | {
|
|
1381
|
+
name
|
|
1382
|
+
for name in replacement.bindings
|
|
1383
|
+
if replacement.bindings[name] != old_graph.bindings.get(name)
|
|
1384
|
+
}
|
|
1385
|
+
for owner, provider in replacement.stores.items():
|
|
1386
|
+
self.stores.check(owner, provider)
|
|
1387
|
+
self.optional_new = {
|
|
1388
|
+
name
|
|
1389
|
+
for name, definition in replacement.definitions.items()
|
|
1390
|
+
if definition.provider_kind
|
|
1391
|
+
in {
|
|
1392
|
+
"dashboard",
|
|
1393
|
+
"mcp",
|
|
1394
|
+
"llms",
|
|
1395
|
+
"auth",
|
|
1396
|
+
"dashboard-auth",
|
|
1397
|
+
"mcp-auth",
|
|
1398
|
+
"oidc-auth",
|
|
1399
|
+
}
|
|
1400
|
+
and name not in self.runtimes
|
|
1401
|
+
}
|
|
1402
|
+
self.optional_failures = {}
|
|
1403
|
+
affected = old_graph.affected(replacement, changed)
|
|
1404
|
+
for pending in self.schema_events:
|
|
1405
|
+
owner = pending["owner"]
|
|
1406
|
+
provider = pending.get("provider")
|
|
1407
|
+
if (
|
|
1408
|
+
owner not in affected
|
|
1409
|
+
or owner not in replacement.stores
|
|
1410
|
+
or (provider and replacement.stores[owner] != provider)
|
|
1411
|
+
):
|
|
1412
|
+
raise PluginError(
|
|
1413
|
+
"forward_repair_required",
|
|
1414
|
+
"Recover the recorded store and its original provider before "
|
|
1415
|
+
"performing an unrelated replacement",
|
|
1416
|
+
{"owner": owner, "provider": provider},
|
|
1417
|
+
)
|
|
1418
|
+
if (
|
|
1419
|
+
migration
|
|
1420
|
+
and replacement.stores.get(migration["owner"]) != migration["provider"]
|
|
1421
|
+
):
|
|
1422
|
+
raise PluginError("stale_plan", "Migration storage binding changed")
|
|
1423
|
+
for name, definition in replacement.definitions.items():
|
|
1424
|
+
if definition.provider_kind == "dashboard" and (
|
|
1425
|
+
name in affected or name not in self.runtimes
|
|
1426
|
+
):
|
|
1427
|
+
cohort = [
|
|
1428
|
+
candidate
|
|
1429
|
+
for owner, candidate in proposed.items()
|
|
1430
|
+
if owner == name
|
|
1431
|
+
or any(
|
|
1432
|
+
resource.kind == "dashboard.page"
|
|
1433
|
+
for resource in replacement.definitions[owner].resources
|
|
1434
|
+
)
|
|
1435
|
+
]
|
|
1436
|
+
try:
|
|
1437
|
+
cohort_ids = {item["plugin_id"]: item["id"] for item in cohort}
|
|
1438
|
+
prior = proposed[name]
|
|
1439
|
+
environment = prior.get("ui_environment")
|
|
1440
|
+
if (
|
|
1441
|
+
not environment
|
|
1442
|
+
or prior.get("ui_cohort") != cohort_ids
|
|
1443
|
+
or not (self.home / environment["python"]).is_file()
|
|
1444
|
+
):
|
|
1445
|
+
environment = await self.packages.environment(cohort, ui=True)
|
|
1446
|
+
proposed[name] = {
|
|
1447
|
+
**prior,
|
|
1448
|
+
"ui_environment": environment,
|
|
1449
|
+
"ui_cohort": cohort_ids,
|
|
1450
|
+
}
|
|
1451
|
+
except Exception as error:
|
|
1452
|
+
if name not in self.optional_new:
|
|
1453
|
+
raise
|
|
1454
|
+
self.optional_failures[name] = str(error)
|
|
1455
|
+
(
|
|
1456
|
+
await self.state.runtime(
|
|
1457
|
+
name,
|
|
1458
|
+
{
|
|
1459
|
+
"state": "failed",
|
|
1460
|
+
"error": str(error),
|
|
1461
|
+
"host_run": self.host_run,
|
|
1462
|
+
},
|
|
1463
|
+
)
|
|
1464
|
+
)
|
|
1465
|
+
replacement = Graph(proposed, self.composition.bindings)
|
|
1466
|
+
(await self.phase(operation, "prepare_resources"))
|
|
1467
|
+
for kind, provider in replacement.providers.items():
|
|
1468
|
+
if (
|
|
1469
|
+
kind not in {"dashboard", "mcp", "llms"}
|
|
1470
|
+
or provider in self.optional_failures
|
|
1471
|
+
):
|
|
1472
|
+
continue
|
|
1473
|
+
resource_kind = {
|
|
1474
|
+
"dashboard": "dashboard.page",
|
|
1475
|
+
"mcp": "mcp.tool",
|
|
1476
|
+
"llms": "llms.document",
|
|
1477
|
+
}[kind]
|
|
1478
|
+
if provider in affected or old_graph.descriptor_digest(
|
|
1479
|
+
resource_kind
|
|
1480
|
+
) != replacement.descriptor_digest(resource_kind):
|
|
1481
|
+
try:
|
|
1482
|
+
await self.prepare_provider(
|
|
1483
|
+
proposed[provider],
|
|
1484
|
+
[
|
|
1485
|
+
resource
|
|
1486
|
+
for resource in replacement.resources()
|
|
1487
|
+
if resource.resource.kind == resource_kind
|
|
1488
|
+
],
|
|
1489
|
+
)
|
|
1490
|
+
except Exception as error:
|
|
1491
|
+
if provider not in self.optional_new:
|
|
1492
|
+
raise
|
|
1493
|
+
self.optional_failures[provider] = str(error)
|
|
1494
|
+
(
|
|
1495
|
+
await self.state.runtime(
|
|
1496
|
+
provider,
|
|
1497
|
+
{
|
|
1498
|
+
"state": "failed",
|
|
1499
|
+
"error": str(error),
|
|
1500
|
+
"host_run": self.host_run,
|
|
1501
|
+
},
|
|
1502
|
+
)
|
|
1503
|
+
)
|
|
1504
|
+
# Existing stores are inspected before quiescence. A schema change is never hidden in reload.
|
|
1505
|
+
for name in changed & replacement.candidates.keys():
|
|
1506
|
+
definition = replacement.definitions[name]
|
|
1507
|
+
if (
|
|
1508
|
+
definition.store
|
|
1509
|
+
and name in self.runtimes
|
|
1510
|
+
and replacement.stores.get(name) in self.runtimes
|
|
1511
|
+
):
|
|
1512
|
+
plan = await self.admin(
|
|
1513
|
+
self._storage_runtime(name, graph=replacement),
|
|
1514
|
+
"plan",
|
|
1515
|
+
{"owner": name, "schema": definition.store.model_dump()},
|
|
1516
|
+
)
|
|
1517
|
+
if plan["requires_apply"] and migration is None:
|
|
1518
|
+
raise PluginError(
|
|
1519
|
+
"migration_required",
|
|
1520
|
+
"Review and explicitly apply the existing-data migration",
|
|
1521
|
+
plan,
|
|
1522
|
+
)
|
|
1523
|
+
if self.config.revision != self.revision:
|
|
1524
|
+
raise PluginError("stale_plan", "Composition changed before cutover")
|
|
1525
|
+
self.check_cancel(operation)
|
|
1526
|
+
(
|
|
1527
|
+
await self.phase(
|
|
1528
|
+
operation,
|
|
1529
|
+
"quiesce",
|
|
1530
|
+
{
|
|
1531
|
+
"affected": sorted(affected),
|
|
1532
|
+
"old": {
|
|
1533
|
+
name: value["id"]
|
|
1534
|
+
for name, value in old_graph.candidates.items()
|
|
1535
|
+
},
|
|
1536
|
+
"candidate": {
|
|
1537
|
+
name: value["id"] for name, value in proposed.items()
|
|
1538
|
+
},
|
|
1539
|
+
"revision": self.revision,
|
|
1540
|
+
},
|
|
1541
|
+
)
|
|
1542
|
+
)
|
|
1543
|
+
self.gated.update(affected)
|
|
1544
|
+
self.transitioning.update(affected)
|
|
1545
|
+
try:
|
|
1546
|
+
for name in affected:
|
|
1547
|
+
if name in self.runtimes:
|
|
1548
|
+
self.runtimes[name].admitted = False
|
|
1549
|
+
worker = self.runtimes[name].worker
|
|
1550
|
+
if worker.process.returncode is None:
|
|
1551
|
+
await worker.request("runtime.quiesce")
|
|
1552
|
+
(await self.phase(operation, "draining"))
|
|
1553
|
+
await self._drain(
|
|
1554
|
+
affected, self.composition.runtime.drain_timeout, operation
|
|
1555
|
+
)
|
|
1556
|
+
self.check_cancel(operation)
|
|
1557
|
+
if self.config.revision != self.revision:
|
|
1558
|
+
raise PluginError(
|
|
1559
|
+
"stale_plan",
|
|
1560
|
+
"Composition changed while draining; old workers are retained",
|
|
1561
|
+
)
|
|
1562
|
+
except BaseException:
|
|
1563
|
+
await self._resume(affected)
|
|
1564
|
+
raise
|
|
1565
|
+
(await self.phase(operation, "disposing"))
|
|
1566
|
+
for name in reversed(old_graph.order):
|
|
1567
|
+
if name in affected and name in self.registered:
|
|
1568
|
+
try:
|
|
1569
|
+
await self.manager.unregister(name)
|
|
1570
|
+
self.registered.remove(name)
|
|
1571
|
+
except BaseException as error:
|
|
1572
|
+
raise PluginError(
|
|
1573
|
+
"cleanup_failed",
|
|
1574
|
+
"Old worker cleanup failed; affected admission remains closed",
|
|
1575
|
+
{"plugin_id": name, "error": str(error)},
|
|
1576
|
+
) from error
|
|
1577
|
+
try:
|
|
1578
|
+
if migration:
|
|
1579
|
+
(await self.phase(operation, "migrating"))
|
|
1580
|
+
storage = self._storage_runtime(migration["owner"], graph=replacement)
|
|
1581
|
+
result = await self.change_schema(storage, "apply", migration)
|
|
1582
|
+
(await self.phase(operation, "migration_committed", result))
|
|
1583
|
+
self.graph = replacement
|
|
1584
|
+
(await self.phase(operation, "activating"))
|
|
1585
|
+
await self._register_set(set(proposed) - self.registered)
|
|
1586
|
+
(await self.phase(operation, "publishing"))
|
|
1587
|
+
await self.publish()
|
|
1588
|
+
for name, candidate in proposed.items():
|
|
1589
|
+
(await self.state.save_candidate(candidate))
|
|
1590
|
+
if (await self.state.plugin(name))["current_candidate"] != candidate[
|
|
1591
|
+
"id"
|
|
1592
|
+
]:
|
|
1593
|
+
(await self.state.select(name, candidate["id"]))
|
|
1594
|
+
(await self.write_lock())
|
|
1595
|
+
await self._resume(affected | (set(proposed) - set(old_graph.candidates)))
|
|
1596
|
+
self.gated.difference_update(affected - self.runtimes.keys())
|
|
1597
|
+
(await self.state.set_meta("forward_repair", None))
|
|
1598
|
+
except Exception as failure:
|
|
1599
|
+
self.gated.update(affected)
|
|
1600
|
+
for name in reversed(self.graph.order):
|
|
1601
|
+
if name in affected and name in self.registered:
|
|
1602
|
+
try:
|
|
1603
|
+
await self.manager.unregister(name)
|
|
1604
|
+
self.registered.remove(name)
|
|
1605
|
+
except BaseException as cleanup:
|
|
1606
|
+
raise PluginError(
|
|
1607
|
+
"cleanup_failed",
|
|
1608
|
+
"Candidate cleanup could not be proven",
|
|
1609
|
+
{"error": str(cleanup)},
|
|
1610
|
+
) from cleanup
|
|
1611
|
+
if self.schema_changed:
|
|
1612
|
+
(
|
|
1613
|
+
await self.state.set_meta(
|
|
1614
|
+
"forward_repair",
|
|
1615
|
+
{
|
|
1616
|
+
"operation": operation,
|
|
1617
|
+
"schemas": self.schema_events,
|
|
1618
|
+
"candidates": {
|
|
1619
|
+
name: item["id"] for name, item in proposed.items()
|
|
1620
|
+
},
|
|
1621
|
+
},
|
|
1622
|
+
)
|
|
1623
|
+
)
|
|
1624
|
+
raise PluginError(
|
|
1625
|
+
"forward_repair_required",
|
|
1626
|
+
"Schema committed or acknowledgement is unknown; data retained and old code was not restarted",
|
|
1627
|
+
{"error": str(failure), "schemas": self.schema_events},
|
|
1628
|
+
) from failure
|
|
1629
|
+
self.graph = old_graph
|
|
1630
|
+
recover = affected & old_graph.candidates.keys()
|
|
1631
|
+
if not recover:
|
|
1632
|
+
raise
|
|
1633
|
+
(await self.phase(operation, "recovering"))
|
|
1634
|
+
try:
|
|
1635
|
+
await self._register_set(recover - self.registered)
|
|
1636
|
+
await self.publish()
|
|
1637
|
+
await self._resume(recover)
|
|
1638
|
+
for name in recover:
|
|
1639
|
+
old = old_graph.candidates[name]
|
|
1640
|
+
if (await self.state.plugin(name))["current_candidate"] != old[
|
|
1641
|
+
"id"
|
|
1642
|
+
]:
|
|
1643
|
+
(await self.state.select(name, old["id"]))
|
|
1644
|
+
(await self.write_lock())
|
|
1645
|
+
except Exception as recovery:
|
|
1646
|
+
raise PluginError(
|
|
1647
|
+
"recovery_failed",
|
|
1648
|
+
"One old-snapshot recovery failed",
|
|
1649
|
+
{"error": str(recovery), "original": str(failure)},
|
|
1650
|
+
) from recovery
|
|
1651
|
+
raise PluginError(
|
|
1652
|
+
"failed_recovered",
|
|
1653
|
+
"Candidate failed; the old snapshot cohort was restarted once",
|
|
1654
|
+
{"error": str(failure), "affected": sorted(recover)},
|
|
1655
|
+
) from failure
|
|
1656
|
+
if self.optional_failures:
|
|
1657
|
+
raise PluginError(
|
|
1658
|
+
"optional_provider_failed",
|
|
1659
|
+
"Business services started; some presentation providers need repair",
|
|
1660
|
+
self.optional_failures,
|
|
1661
|
+
)
|
|
1662
|
+
failures = {
|
|
1663
|
+
kind: status
|
|
1664
|
+
for kind, status in self.publication.items()
|
|
1665
|
+
if status["state"] == "failed"
|
|
1666
|
+
}
|
|
1667
|
+
if failures:
|
|
1668
|
+
raise PluginError(
|
|
1669
|
+
"publication_failed",
|
|
1670
|
+
"Business services are selected; failed presentation entrances remain unavailable",
|
|
1671
|
+
failures,
|
|
1672
|
+
)
|
|
1673
|
+
|
|
1674
|
+
async def prepare_provider(self, candidate, resources):
|
|
1675
|
+
async def unavailable(method, params):
|
|
1676
|
+
raise PluginError(
|
|
1677
|
+
"validation_only",
|
|
1678
|
+
"Resource preparation cannot call application services",
|
|
1679
|
+
)
|
|
1680
|
+
|
|
1681
|
+
environment = candidate.get("ui_environment", candidate["environment"])
|
|
1682
|
+
worker = await WorkerProcess.start(
|
|
1683
|
+
self.home / environment["python"],
|
|
1684
|
+
candidate["entrypoint"],
|
|
1685
|
+
unavailable,
|
|
1686
|
+
cwd=self.home,
|
|
1687
|
+
)
|
|
1688
|
+
activated = False
|
|
1689
|
+
try:
|
|
1690
|
+
await worker.request("runtime.describe")
|
|
1691
|
+
await worker.request(
|
|
1692
|
+
"runtime.activate",
|
|
1693
|
+
{"generation": "preflight", "config": candidate["config"]},
|
|
1694
|
+
)
|
|
1695
|
+
activated = True
|
|
1696
|
+
await worker.request(
|
|
1697
|
+
"provider.prepare",
|
|
1698
|
+
{
|
|
1699
|
+
"resources": [resource.model_dump() for resource in resources],
|
|
1700
|
+
"epoch": "preflight",
|
|
1701
|
+
},
|
|
1702
|
+
)
|
|
1703
|
+
await worker.request("provider.abort", {"epoch": "preflight"})
|
|
1704
|
+
finally:
|
|
1705
|
+
try:
|
|
1706
|
+
await worker.close(activated=activated)
|
|
1707
|
+
except BaseException:
|
|
1708
|
+
await worker.force_stop() # validation has no live service/data access
|
|
1709
|
+
raise
|
|
1710
|
+
|
|
1711
|
+
async def publish(self):
|
|
1712
|
+
epoch = uuid.uuid4().hex
|
|
1713
|
+
generations = {
|
|
1714
|
+
name: runtime.generation for name, runtime in self.runtimes.items()
|
|
1715
|
+
}
|
|
1716
|
+
resources = [
|
|
1717
|
+
resource
|
|
1718
|
+
for resource in self.graph.resources(generations)
|
|
1719
|
+
if resource.owner in generations
|
|
1720
|
+
and all(
|
|
1721
|
+
member in self.runtimes
|
|
1722
|
+
and self.runtimes[member].health.get("runtime") == "ready"
|
|
1723
|
+
and self.runtimes[member].health.get("business")
|
|
1724
|
+
not in ("failed", "not_ready")
|
|
1725
|
+
for member in self.graph.closure(resource.owner)
|
|
1726
|
+
)
|
|
1727
|
+
]
|
|
1728
|
+
self.epoch = epoch
|
|
1729
|
+
(
|
|
1730
|
+
await self.state.set_meta(
|
|
1731
|
+
"catalog",
|
|
1732
|
+
{
|
|
1733
|
+
"epoch": epoch,
|
|
1734
|
+
"resources": [resource.model_dump() for resource in resources],
|
|
1735
|
+
},
|
|
1736
|
+
)
|
|
1737
|
+
)
|
|
1738
|
+
for kind, resource_kind in (
|
|
1739
|
+
("dashboard", "dashboard.page"),
|
|
1740
|
+
("mcp", "mcp.tool"),
|
|
1741
|
+
("llms", "llms.document"),
|
|
1742
|
+
):
|
|
1743
|
+
name = self.graph.providers.get(kind)
|
|
1744
|
+
runtime = self.runtimes.get(name)
|
|
1745
|
+
if not runtime:
|
|
1746
|
+
self.publication[kind] = {"state": "unavailable", "epoch": epoch}
|
|
1747
|
+
continue
|
|
1748
|
+
try:
|
|
1749
|
+
await runtime.worker.request(
|
|
1750
|
+
"provider.prepare",
|
|
1751
|
+
{
|
|
1752
|
+
"resources": [
|
|
1753
|
+
resource.model_dump()
|
|
1754
|
+
for resource in resources
|
|
1755
|
+
if resource.resource.kind == resource_kind
|
|
1756
|
+
],
|
|
1757
|
+
"epoch": epoch,
|
|
1758
|
+
},
|
|
1759
|
+
)
|
|
1760
|
+
result = await runtime.worker.request(
|
|
1761
|
+
"provider.commit", {"epoch": epoch}
|
|
1762
|
+
)
|
|
1763
|
+
if result.get("epoch") != epoch:
|
|
1764
|
+
raise PluginError(
|
|
1765
|
+
"epoch_mismatch",
|
|
1766
|
+
"Provider did not acknowledge selected catalog",
|
|
1767
|
+
)
|
|
1768
|
+
runtime.epoch = epoch
|
|
1769
|
+
self.publication[kind] = {
|
|
1770
|
+
"state": "available",
|
|
1771
|
+
"epoch": epoch,
|
|
1772
|
+
"plugin_id": name,
|
|
1773
|
+
}
|
|
1774
|
+
except Exception as error: # noqa: BLE001 - optional provider failure must not stop business
|
|
1775
|
+
runtime.epoch = None
|
|
1776
|
+
self.publication[kind] = {
|
|
1777
|
+
"state": "failed",
|
|
1778
|
+
"epoch": epoch,
|
|
1779
|
+
"error": str(error),
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
async def write_lock(self):
|
|
1783
|
+
plugins = {}
|
|
1784
|
+
for name in self.composition.plugins:
|
|
1785
|
+
row = await self.state.plugin(name)
|
|
1786
|
+
candidates = {}
|
|
1787
|
+
for selection in (
|
|
1788
|
+
"current_candidate",
|
|
1789
|
+
"last_candidate",
|
|
1790
|
+
"previous_candidate",
|
|
1791
|
+
):
|
|
1792
|
+
if row[selection]:
|
|
1793
|
+
candidate = await self.state.candidate(row[selection])
|
|
1794
|
+
candidates[selection] = {
|
|
1795
|
+
key: candidate[key]
|
|
1796
|
+
for key in (
|
|
1797
|
+
"id",
|
|
1798
|
+
"version",
|
|
1799
|
+
"artifact",
|
|
1800
|
+
"artifact_hash",
|
|
1801
|
+
"source",
|
|
1802
|
+
"environment",
|
|
1803
|
+
)
|
|
1804
|
+
}
|
|
1805
|
+
plugins[name] = candidates
|
|
1806
|
+
atomic_write(
|
|
1807
|
+
self.home / "toolframe.lock",
|
|
1808
|
+
json.dumps(
|
|
1809
|
+
{"format": 1, "desired_revision": self.revision, "plugins": plugins},
|
|
1810
|
+
indent=2,
|
|
1811
|
+
)
|
|
1812
|
+
+ "\n",
|
|
1813
|
+
)
|
|
1814
|
+
(await self.state.set_meta("desired_revision", self.revision))
|
|
1815
|
+
(
|
|
1816
|
+
await self.state.set_meta(
|
|
1817
|
+
"last_valid_composition", self.composition.model_dump()
|
|
1818
|
+
)
|
|
1819
|
+
)
|
|
1820
|
+
|
|
1821
|
+
def begin_root(self, owner, *, deadline=None, principal=None):
|
|
1822
|
+
if (
|
|
1823
|
+
owner in self.gated
|
|
1824
|
+
or owner not in self.runtimes
|
|
1825
|
+
or not self.runtimes[owner].admitted
|
|
1826
|
+
):
|
|
1827
|
+
raise PluginError("plugin_reloading", f"{owner} is not admitting new calls")
|
|
1828
|
+
members = self.graph.closure(
|
|
1829
|
+
owner,
|
|
1830
|
+
optional=True,
|
|
1831
|
+
available=self.available_dependencies(),
|
|
1832
|
+
)
|
|
1833
|
+
leases = ExitStack()
|
|
1834
|
+
try:
|
|
1835
|
+
for name in members:
|
|
1836
|
+
if (
|
|
1837
|
+
name in self.gated
|
|
1838
|
+
or name not in self.runtimes
|
|
1839
|
+
or not self.healthy_runtime(name)
|
|
1840
|
+
or self.runtimes[name].worker.process.returncode is not None
|
|
1841
|
+
):
|
|
1842
|
+
raise PluginError(
|
|
1843
|
+
"dependency_unavailable", f"{name} is unavailable"
|
|
1844
|
+
)
|
|
1845
|
+
leases.enter_context(self.manager.lease(name))
|
|
1846
|
+
root = Root(
|
|
1847
|
+
uuid.uuid4().hex,
|
|
1848
|
+
owner,
|
|
1849
|
+
deadline or time.time() + 30,
|
|
1850
|
+
{name: self.runtimes[name] for name in members},
|
|
1851
|
+
leases,
|
|
1852
|
+
principal=principal,
|
|
1853
|
+
scope_catalog=self.access.catalog(),
|
|
1854
|
+
bindings={name: dict(self.graph.bindings[name]) for name in members},
|
|
1855
|
+
)
|
|
1856
|
+
self.roots[root.id] = root
|
|
1857
|
+
return root
|
|
1858
|
+
except BaseException:
|
|
1859
|
+
leases.close()
|
|
1860
|
+
raise
|
|
1861
|
+
|
|
1862
|
+
async def end_root(self, root):
|
|
1863
|
+
if root.id not in self.roots:
|
|
1864
|
+
return
|
|
1865
|
+
try:
|
|
1866
|
+
failure = None
|
|
1867
|
+
for runtime in root.runtimes.values():
|
|
1868
|
+
if (
|
|
1869
|
+
runtime.candidate["definition"].get("provider_kind") == "storage"
|
|
1870
|
+
and runtime.worker.process.returncode is None
|
|
1871
|
+
):
|
|
1872
|
+
try:
|
|
1873
|
+
await self.admin(runtime, "cleanup_root", {"root_id": root.id})
|
|
1874
|
+
except BaseException as error: # noqa: BLE001 - settle every leased store before propagating failure
|
|
1875
|
+
# A failed provider must not leave another provider's
|
|
1876
|
+
# transaction alive merely because it appeared first.
|
|
1877
|
+
if failure is None:
|
|
1878
|
+
failure = error
|
|
1879
|
+
if failure is not None:
|
|
1880
|
+
raise failure
|
|
1881
|
+
except BaseException:
|
|
1882
|
+
self.failed_roots.add(root.id)
|
|
1883
|
+
self.cleanup_blocked.add(root.owner)
|
|
1884
|
+
self.gated.add(root.owner)
|
|
1885
|
+
raise
|
|
1886
|
+
else:
|
|
1887
|
+
self.roots.pop(root.id, None)
|
|
1888
|
+
self.failed_roots.discard(root.id)
|
|
1889
|
+
if not any(
|
|
1890
|
+
self.roots[item].owner == root.owner for item in self.failed_roots
|
|
1891
|
+
):
|
|
1892
|
+
self.cleanup_blocked.discard(root.owner)
|
|
1893
|
+
root.leases.close()
|
|
1894
|
+
self.changed.set()
|
|
1895
|
+
|
|
1896
|
+
async def invoke(
|
|
1897
|
+
self, root, target, service, method, payload, *, owner=None, chain=()
|
|
1898
|
+
):
|
|
1899
|
+
if target in chain:
|
|
1900
|
+
raise PluginError(
|
|
1901
|
+
"call_cycle", "Recursive cross-plugin service call rejected"
|
|
1902
|
+
)
|
|
1903
|
+
runtime = root.runtimes.get(target)
|
|
1904
|
+
if runtime is None:
|
|
1905
|
+
raise PluginError(
|
|
1906
|
+
"undeclared_dependency",
|
|
1907
|
+
"Target is outside the admitted dependency closure",
|
|
1908
|
+
)
|
|
1909
|
+
specification = (
|
|
1910
|
+
runtime.candidate["definition"]["services"]
|
|
1911
|
+
.get(service, {})
|
|
1912
|
+
.get("methods", {})
|
|
1913
|
+
.get(method)
|
|
1914
|
+
)
|
|
1915
|
+
if not specification:
|
|
1916
|
+
raise PluginError("method_not_found", "Method is not declared")
|
|
1917
|
+
if (
|
|
1918
|
+
root.principal is not None
|
|
1919
|
+
and specification.get("audience") != "internal"
|
|
1920
|
+
and not root.principal.allows(
|
|
1921
|
+
specification.get("required_scopes", ()), root.scope_catalog
|
|
1922
|
+
)
|
|
1923
|
+
):
|
|
1924
|
+
raise PluginError(
|
|
1925
|
+
"forbidden", "The admitted caller does not have this method's scopes"
|
|
1926
|
+
)
|
|
1927
|
+
if root.read_only and (not specification or not specification.get("read_only")):
|
|
1928
|
+
raise PluginError(
|
|
1929
|
+
"probe_write_forbidden",
|
|
1930
|
+
"Activation and health probes may only call declared read-only methods",
|
|
1931
|
+
)
|
|
1932
|
+
path = (*chain, target)
|
|
1933
|
+
root.paths[path] = root.paths.get(path, 0) + 1
|
|
1934
|
+
source = root.runtimes.get(owner or root.owner, runtime)
|
|
1935
|
+
try:
|
|
1936
|
+
return await runtime.worker.request(
|
|
1937
|
+
"runtime.invoke",
|
|
1938
|
+
{
|
|
1939
|
+
"service": service,
|
|
1940
|
+
"method": method,
|
|
1941
|
+
"params": payload,
|
|
1942
|
+
"context": {
|
|
1943
|
+
"root_id": root.id,
|
|
1944
|
+
"deadline": root.deadline,
|
|
1945
|
+
"chain": list(path),
|
|
1946
|
+
"owner": source.name,
|
|
1947
|
+
"owner_generation": source.generation,
|
|
1948
|
+
"application_id": self.composition.application.id,
|
|
1949
|
+
"principal": root.principal.model_dump()
|
|
1950
|
+
if root.principal
|
|
1951
|
+
else None,
|
|
1952
|
+
"scope_catalog": root.scope_catalog,
|
|
1953
|
+
},
|
|
1954
|
+
},
|
|
1955
|
+
timeout=max(0.01, root.deadline - time.time()),
|
|
1956
|
+
)
|
|
1957
|
+
finally:
|
|
1958
|
+
root.paths[path] -= 1
|
|
1959
|
+
if not root.paths[path]:
|
|
1960
|
+
del root.paths[path]
|
|
1961
|
+
|
|
1962
|
+
async def call_service(self, owner, service, method, params, *, principal=None):
|
|
1963
|
+
root = self.begin_root(owner, principal=principal)
|
|
1964
|
+
try:
|
|
1965
|
+
return await self.invoke(root, owner, service, method, params)
|
|
1966
|
+
finally:
|
|
1967
|
+
await self.end_root(root)
|
|
1968
|
+
|
|
1969
|
+
async def worker_call(self, runtime, method, params):
|
|
1970
|
+
if method == "task.end":
|
|
1971
|
+
root = self.roots.get(params["root_id"])
|
|
1972
|
+
if root is not None:
|
|
1973
|
+
if (
|
|
1974
|
+
root.owner != runtime.name
|
|
1975
|
+
or root.runtimes.get(runtime.name) is not runtime
|
|
1976
|
+
):
|
|
1977
|
+
raise PluginError(
|
|
1978
|
+
"stale_generation",
|
|
1979
|
+
"Task root belongs to another worker generation",
|
|
1980
|
+
)
|
|
1981
|
+
# A withdrawn worker may settle only the root admitted to that
|
|
1982
|
+
# exact runtime; it cannot start work or settle its replacement.
|
|
1983
|
+
await self.end_root(root)
|
|
1984
|
+
return {}
|
|
1985
|
+
if self.runtimes.get(runtime.name) is not runtime:
|
|
1986
|
+
raise PluginError(
|
|
1987
|
+
"stale_generation", "Worker generation is no longer current"
|
|
1988
|
+
)
|
|
1989
|
+
if method == "mcp.access":
|
|
1990
|
+
if runtime.candidate["definition"].get("provider_kind") != "mcp":
|
|
1991
|
+
raise PluginError(
|
|
1992
|
+
"forbidden", "Only the MCP provider may resolve MCP requests"
|
|
1993
|
+
)
|
|
1994
|
+
return await self.mcp_access.presentation(
|
|
1995
|
+
runtime, params.get("access_context")
|
|
1996
|
+
)
|
|
1997
|
+
if method in {"management.call", "access.call"}:
|
|
1998
|
+
if runtime.candidate["definition"].get("provider_kind") != "dashboard":
|
|
1999
|
+
raise PluginError(
|
|
2000
|
+
"forbidden",
|
|
2001
|
+
"Only the management dashboard may request management operations",
|
|
2002
|
+
)
|
|
2003
|
+
action = params["action"]
|
|
2004
|
+
identity = self.access.context(params.get("access_context"))
|
|
2005
|
+
if method == "access.call":
|
|
2006
|
+
return await self.access.manage(
|
|
2007
|
+
identity, action, params.get("params", {})
|
|
2008
|
+
)
|
|
2009
|
+
if action == "cancel":
|
|
2010
|
+
operation = await self.state.operation(params["params"]["id"])
|
|
2011
|
+
scopes = operation_scopes(operation["kind"], operation["request"])
|
|
2012
|
+
else:
|
|
2013
|
+
scopes = operation_scopes(action, params.get("params"))
|
|
2014
|
+
await self.access.admit(
|
|
2015
|
+
identity, scopes, effective=False, activity=action == "cancel"
|
|
2016
|
+
)
|
|
2017
|
+
if action == "status":
|
|
2018
|
+
return await self.status()
|
|
2019
|
+
if action == "operation":
|
|
2020
|
+
return self.redact(await self.state.operation(params["params"]["id"]))
|
|
2021
|
+
if action == "operations":
|
|
2022
|
+
return self.redact(
|
|
2023
|
+
await self.state.operation_page(params.get("params", {}))
|
|
2024
|
+
)
|
|
2025
|
+
if action == "cancel":
|
|
2026
|
+
identifier = params["params"]["id"]
|
|
2027
|
+
(await self.state.operation(identifier))
|
|
2028
|
+
self.cancellations.add(identifier)
|
|
2029
|
+
self.changed.set()
|
|
2030
|
+
return {"id": identifier, "requested": True}
|
|
2031
|
+
return self.redact(
|
|
2032
|
+
await self.submit(action, params.get("params", {}), identity=identity)
|
|
2033
|
+
)
|
|
2034
|
+
if method == "resource.call":
|
|
2035
|
+
resource = next(
|
|
2036
|
+
(
|
|
2037
|
+
resource
|
|
2038
|
+
for resource in self.graph.resources()
|
|
2039
|
+
if resource.key == params["key"]
|
|
2040
|
+
),
|
|
2041
|
+
None,
|
|
2042
|
+
)
|
|
2043
|
+
kind = runtime.candidate["definition"].get("provider_kind")
|
|
2044
|
+
if (
|
|
2045
|
+
not resource
|
|
2046
|
+
or {"mcp": "mcp.tool", "dashboard": "dashboard.page"}.get(kind)
|
|
2047
|
+
!= resource.resource.kind
|
|
2048
|
+
):
|
|
2049
|
+
raise PluginError(
|
|
2050
|
+
"resource_unavailable",
|
|
2051
|
+
"Resource is absent or not owned by this provider kind",
|
|
2052
|
+
)
|
|
2053
|
+
if params["epoch"] != runtime.epoch or runtime.epoch != self.epoch:
|
|
2054
|
+
raise PluginError(
|
|
2055
|
+
"stale_catalog",
|
|
2056
|
+
"Resource callback belongs to an unselected catalog",
|
|
2057
|
+
)
|
|
2058
|
+
if kind == "mcp":
|
|
2059
|
+
service = resource.resource.data["service"]
|
|
2060
|
+
name = resource.resource.data["method"]
|
|
2061
|
+
if params["method"] != name:
|
|
2062
|
+
raise PluginError(
|
|
2063
|
+
"undeclared_method",
|
|
2064
|
+
"MCP tool may call only its declared method",
|
|
2065
|
+
)
|
|
2066
|
+
version = None # The declared target service owns the tool's contract version.
|
|
2067
|
+
else:
|
|
2068
|
+
qualified, _, name = params["method"].partition("/")
|
|
2069
|
+
service, _, version = qualified.partition("@")
|
|
2070
|
+
version = int(version)
|
|
2071
|
+
definition = self.graph.definitions[resource.owner]
|
|
2072
|
+
target = resource.owner
|
|
2073
|
+
if service not in definition.services:
|
|
2074
|
+
options = [
|
|
2075
|
+
item
|
|
2076
|
+
for item in self.graph.bindings[resource.owner].values()
|
|
2077
|
+
if item[1] == service
|
|
2078
|
+
]
|
|
2079
|
+
if not options:
|
|
2080
|
+
raise PluginError(
|
|
2081
|
+
"undeclared_service",
|
|
2082
|
+
"Page/tool owner did not declare this service",
|
|
2083
|
+
)
|
|
2084
|
+
target = options[0][0]
|
|
2085
|
+
if (
|
|
2086
|
+
version is not None
|
|
2087
|
+
and self.graph.definitions[target].services[service].version != version
|
|
2088
|
+
):
|
|
2089
|
+
raise PluginError(
|
|
2090
|
+
"service_version_mismatch",
|
|
2091
|
+
"Requested service version is unavailable",
|
|
2092
|
+
)
|
|
2093
|
+
specification = (
|
|
2094
|
+
self.graph.definitions[target].services[service].methods.get(name)
|
|
2095
|
+
)
|
|
2096
|
+
if specification is None or specification.audience == "internal":
|
|
2097
|
+
raise PluginError(
|
|
2098
|
+
"forbidden", "Internal services are not page/tool entrances"
|
|
2099
|
+
)
|
|
2100
|
+
if kind == "mcp":
|
|
2101
|
+
if specification.audience != "business":
|
|
2102
|
+
raise PluginError(
|
|
2103
|
+
"forbidden", "MCP cannot publish system operations"
|
|
2104
|
+
)
|
|
2105
|
+
principal = await self.mcp_access.resolve(
|
|
2106
|
+
runtime,
|
|
2107
|
+
params.get("access_context"),
|
|
2108
|
+
scopes=(
|
|
2109
|
+
*resource.resource.required_scopes,
|
|
2110
|
+
*specification.required_scopes,
|
|
2111
|
+
),
|
|
2112
|
+
activity=True,
|
|
2113
|
+
)
|
|
2114
|
+
else:
|
|
2115
|
+
identity = self.access.context(params.get("access_context"))
|
|
2116
|
+
principal = await self.access.admit(
|
|
2117
|
+
identity,
|
|
2118
|
+
(
|
|
2119
|
+
*resource.resource.required_scopes,
|
|
2120
|
+
*specification.required_scopes,
|
|
2121
|
+
),
|
|
2122
|
+
activity=params.get("activity", True),
|
|
2123
|
+
)
|
|
2124
|
+
root = self.begin_root(resource.owner, principal=principal)
|
|
2125
|
+
|
|
2126
|
+
async def admitted():
|
|
2127
|
+
try:
|
|
2128
|
+
return await self.invoke(
|
|
2129
|
+
root, target, service, name, params.get("params", {})
|
|
2130
|
+
)
|
|
2131
|
+
finally:
|
|
2132
|
+
await self.end_root(root)
|
|
2133
|
+
|
|
2134
|
+
# A browser disconnect ends its UI, not work already admitted by core.
|
|
2135
|
+
task = asyncio.create_task(admitted())
|
|
2136
|
+
task.add_done_callback(
|
|
2137
|
+
lambda task: task.exception() if not task.cancelled() else None
|
|
2138
|
+
)
|
|
2139
|
+
return await asyncio.shield(task)
|
|
2140
|
+
if method == "task.begin":
|
|
2141
|
+
timeout = params.get("timeout", 30)
|
|
2142
|
+
if isinstance(timeout, bool) or not isinstance(timeout, (int, float)):
|
|
2143
|
+
raise PluginError(
|
|
2144
|
+
"invalid_task_timeout", "Task timeout must be finite and positive"
|
|
2145
|
+
)
|
|
2146
|
+
try:
|
|
2147
|
+
timeout = float(timeout)
|
|
2148
|
+
except OverflowError as error:
|
|
2149
|
+
raise PluginError(
|
|
2150
|
+
"invalid_task_timeout", "Task timeout must be finite and positive"
|
|
2151
|
+
) from error
|
|
2152
|
+
if not math.isfinite(timeout) or timeout <= 0:
|
|
2153
|
+
raise PluginError(
|
|
2154
|
+
"invalid_task_timeout", "Task timeout must be finite and positive"
|
|
2155
|
+
)
|
|
2156
|
+
|
|
2157
|
+
def check_policy():
|
|
2158
|
+
if timeout > self.composition.runtime.max_task_timeout:
|
|
2159
|
+
raise PluginError(
|
|
2160
|
+
"task_timeout_exceeded",
|
|
2161
|
+
"Task timeout exceeds the operator policy",
|
|
2162
|
+
{"max_task_timeout": self.composition.runtime.max_task_timeout},
|
|
2163
|
+
)
|
|
2164
|
+
|
|
2165
|
+
check_policy()
|
|
2166
|
+
async with asyncio.timeout(self.composition.runtime.activation_timeout):
|
|
2167
|
+
while runtime.name in self.gated or not runtime.admitted:
|
|
2168
|
+
if self.runtimes.get(runtime.name) is not runtime:
|
|
2169
|
+
raise PluginError(
|
|
2170
|
+
"stale_generation", "Task belongs to a withdrawn generation"
|
|
2171
|
+
)
|
|
2172
|
+
self.changed.clear()
|
|
2173
|
+
await self.changed.wait()
|
|
2174
|
+
if self.runtimes.get(runtime.name) is not runtime:
|
|
2175
|
+
raise PluginError(
|
|
2176
|
+
"stale_generation", "Task belongs to a withdrawn generation"
|
|
2177
|
+
)
|
|
2178
|
+
# Waiting requests use the policy in force when they are admitted.
|
|
2179
|
+
check_policy()
|
|
2180
|
+
root = self.begin_root(runtime.name, deadline=time.time() + timeout)
|
|
2181
|
+
root.paths[(runtime.name,)] = 1
|
|
2182
|
+
return {
|
|
2183
|
+
"root_id": root.id,
|
|
2184
|
+
"deadline": root.deadline,
|
|
2185
|
+
"chain": [runtime.name],
|
|
2186
|
+
}
|
|
2187
|
+
if method == "service.call":
|
|
2188
|
+
root = self.roots.get(params.get("root_id"))
|
|
2189
|
+
if root is None:
|
|
2190
|
+
raise PluginError(
|
|
2191
|
+
"root_required",
|
|
2192
|
+
"Services require an admitted root; activation/probes may not start business calls",
|
|
2193
|
+
)
|
|
2194
|
+
chain = tuple(params.get("chain", ()))
|
|
2195
|
+
if (
|
|
2196
|
+
root.runtimes.get(runtime.name) is not runtime
|
|
2197
|
+
or not chain
|
|
2198
|
+
or chain[-1] != runtime.name
|
|
2199
|
+
or not root.paths.get(chain)
|
|
2200
|
+
):
|
|
2201
|
+
raise PluginError(
|
|
2202
|
+
"stale_generation",
|
|
2203
|
+
"Nested call is outside its admitted root/generation",
|
|
2204
|
+
)
|
|
2205
|
+
binding = root.bindings.get(runtime.name, {}).get(params["alias"])
|
|
2206
|
+
if not binding:
|
|
2207
|
+
raise PluginError(
|
|
2208
|
+
"undeclared_dependency", "Dependency alias was not bound"
|
|
2209
|
+
)
|
|
2210
|
+
target, service = binding
|
|
2211
|
+
if service == "toolframe.storage.admin":
|
|
2212
|
+
raise PluginError(
|
|
2213
|
+
"forbidden", "Storage administrative interface is host-only"
|
|
2214
|
+
)
|
|
2215
|
+
return await self.invoke(
|
|
2216
|
+
root,
|
|
2217
|
+
target,
|
|
2218
|
+
service,
|
|
2219
|
+
params["method"],
|
|
2220
|
+
params.get("params", {}),
|
|
2221
|
+
owner=runtime.name,
|
|
2222
|
+
chain=chain,
|
|
2223
|
+
)
|
|
2224
|
+
raise PluginError("method_not_found", method)
|
|
2225
|
+
|
|
2226
|
+
async def runtime_validate(self, name, candidate):
|
|
2227
|
+
import tempfile
|
|
2228
|
+
|
|
2229
|
+
if self.state.settings.backend != "sqlite":
|
|
2230
|
+
raise PluginError(
|
|
2231
|
+
"runtime_validation_unsupported",
|
|
2232
|
+
"Use a separate application and Core database for runtime validation",
|
|
2233
|
+
)
|
|
2234
|
+
proposed = {**(await self.selected()), name: candidate}
|
|
2235
|
+
graph = Graph(proposed, self.composition.bindings)
|
|
2236
|
+
if any(
|
|
2237
|
+
definition.runtime_validation == "separate_application"
|
|
2238
|
+
for definition in graph.definitions.values()
|
|
2239
|
+
):
|
|
2240
|
+
raise PluginError(
|
|
2241
|
+
"runtime_validation_unsupported",
|
|
2242
|
+
"This composition requires a separately provisioned test application; use preflight validation on the live application",
|
|
2243
|
+
)
|
|
2244
|
+
with tempfile.TemporaryDirectory(prefix="toolframe-runtime-") as temporary:
|
|
2245
|
+
home = Path(temporary)
|
|
2246
|
+
composition = self.composition.model_copy(deep=True)
|
|
2247
|
+
composition.application.id = str(uuid.uuid4())
|
|
2248
|
+
composition.plugins[name].enabled = True
|
|
2249
|
+
atomic_write(
|
|
2250
|
+
home / "toolframe.toml", tomlkit.dumps(composition.model_dump())
|
|
2251
|
+
)
|
|
2252
|
+
scratch = await Host.create(
|
|
2253
|
+
home, scratch=True, state_settings=StateSettings()
|
|
2254
|
+
)
|
|
2255
|
+
cloned = copy.deepcopy(proposed)
|
|
2256
|
+
for item in cloned.values():
|
|
2257
|
+
item["artifact"] = str(self.home / item["artifact"])
|
|
2258
|
+
item["environment"]["python"] = str(
|
|
2259
|
+
self.home / item["environment"]["python"]
|
|
2260
|
+
)
|
|
2261
|
+
item.pop("ui_environment", None)
|
|
2262
|
+
try:
|
|
2263
|
+
await scratch.start(apply=False)
|
|
2264
|
+
operation, _ = await scratch.state.new_operation(
|
|
2265
|
+
"runtime-validation", name, {}
|
|
2266
|
+
)
|
|
2267
|
+
await scratch.replace(cloned, set(cloned), operation["id"])
|
|
2268
|
+
if name not in scratch.runtimes or not scratch.runtimes[name].admitted:
|
|
2269
|
+
raise PluginError(
|
|
2270
|
+
"runtime_validation_failed",
|
|
2271
|
+
"Scratch dependency cohort did not become ready",
|
|
2272
|
+
)
|
|
2273
|
+
finally:
|
|
2274
|
+
await scratch.close()
|
|
2275
|
+
|
|
2276
|
+
async def _health_loop(self):
|
|
2277
|
+
while True:
|
|
2278
|
+
await asyncio.sleep(self.composition.runtime.health_interval)
|
|
2279
|
+
for name, runtime in list(self.runtimes.items()):
|
|
2280
|
+
# Preparation may take minutes. Keep probing unrelated admitted
|
|
2281
|
+
# workers while the mutation writer owns its operation lock.
|
|
2282
|
+
members = self.graph.closure(
|
|
2283
|
+
name,
|
|
2284
|
+
optional=True,
|
|
2285
|
+
available=self.available_dependencies(),
|
|
2286
|
+
)
|
|
2287
|
+
if any(
|
|
2288
|
+
member in self.gated
|
|
2289
|
+
or member not in self.runtimes
|
|
2290
|
+
or not self.runtimes[member].admitted
|
|
2291
|
+
for member in members
|
|
2292
|
+
):
|
|
2293
|
+
continue
|
|
2294
|
+
try:
|
|
2295
|
+
health = await self.probe(runtime)
|
|
2296
|
+
if (
|
|
2297
|
+
self.runtimes.get(name) is not runtime
|
|
2298
|
+
or name in self.gated
|
|
2299
|
+
or not runtime.admitted
|
|
2300
|
+
):
|
|
2301
|
+
continue
|
|
2302
|
+
if health.get("runtime") != "ready" or health.get("business") in (
|
|
2303
|
+
"failed",
|
|
2304
|
+
"not_ready",
|
|
2305
|
+
):
|
|
2306
|
+
raise PluginError(
|
|
2307
|
+
"health_failed", "Worker probe is not ready", health
|
|
2308
|
+
)
|
|
2309
|
+
runtime.health, runtime.observed = health, time.time()
|
|
2310
|
+
(
|
|
2311
|
+
await self.record(
|
|
2312
|
+
runtime, "active" if runtime.admitted else "gated"
|
|
2313
|
+
)
|
|
2314
|
+
)
|
|
2315
|
+
except Exception as error: # noqa: BLE001 - failed workers stay repairable via core
|
|
2316
|
+
if (
|
|
2317
|
+
self.runtimes.get(name) is not runtime
|
|
2318
|
+
or name in self.gated
|
|
2319
|
+
or not runtime.admitted
|
|
2320
|
+
):
|
|
2321
|
+
continue
|
|
2322
|
+
runtime.health = (
|
|
2323
|
+
{**error.details, "error": str(error)}
|
|
2324
|
+
if isinstance(error, PluginError)
|
|
2325
|
+
and error.code == "health_failed"
|
|
2326
|
+
else {"runtime": "failed", "error": str(error)}
|
|
2327
|
+
)
|
|
2328
|
+
runtime.observed = time.time()
|
|
2329
|
+
affected = self.graph.dependents({name})
|
|
2330
|
+
self.gated.update(affected)
|
|
2331
|
+
for member in affected:
|
|
2332
|
+
if member in self.runtimes:
|
|
2333
|
+
self.runtimes[member].admitted = False
|
|
2334
|
+
(
|
|
2335
|
+
await self.record(
|
|
2336
|
+
self.runtimes[member],
|
|
2337
|
+
"failed" if member == name else "blocked",
|
|
2338
|
+
)
|
|
2339
|
+
)
|
|
2340
|
+
self.health_publication_pending = True
|
|
2341
|
+
if self.health_publication_pending and not self.mutation.locked():
|
|
2342
|
+
async with self.mutation:
|
|
2343
|
+
await self.publish()
|
|
2344
|
+
self.health_publication_pending = False
|
|
2345
|
+
|
|
2346
|
+
async def _exit_loop(self):
|
|
2347
|
+
while True:
|
|
2348
|
+
await asyncio.sleep(0.1)
|
|
2349
|
+
if self.mutation.locked():
|
|
2350
|
+
continue
|
|
2351
|
+
for root_id in list(self.failed_roots):
|
|
2352
|
+
root = self.roots[root_id]
|
|
2353
|
+
try:
|
|
2354
|
+
await self.end_root(root)
|
|
2355
|
+
except Exception: # noqa: BLE001 - lease remains held for a later cleanup acknowledgement
|
|
2356
|
+
(
|
|
2357
|
+
await self.state.set_meta(
|
|
2358
|
+
"cleanup_pending", sorted(self.failed_roots)
|
|
2359
|
+
)
|
|
2360
|
+
)
|
|
2361
|
+
continue
|
|
2362
|
+
runtime = self.runtimes.get(root.owner)
|
|
2363
|
+
if (
|
|
2364
|
+
runtime
|
|
2365
|
+
and runtime.admitted
|
|
2366
|
+
and root.owner not in self.cleanup_blocked
|
|
2367
|
+
):
|
|
2368
|
+
self.gated.discard(root.owner)
|
|
2369
|
+
failed = {
|
|
2370
|
+
name
|
|
2371
|
+
for name, runtime in self.runtimes.items()
|
|
2372
|
+
if runtime.admitted and runtime.worker.process.returncode is not None
|
|
2373
|
+
}
|
|
2374
|
+
if failed:
|
|
2375
|
+
for name in self.graph.dependents(failed):
|
|
2376
|
+
self.gated.add(name)
|
|
2377
|
+
if runtime := self.runtimes.get(name):
|
|
2378
|
+
runtime.admitted = False
|
|
2379
|
+
runtime.health = {
|
|
2380
|
+
"runtime": "failed",
|
|
2381
|
+
"error": "Worker or required dependency exited",
|
|
2382
|
+
}
|
|
2383
|
+
(
|
|
2384
|
+
await self.record(
|
|
2385
|
+
runtime, "failed" if name in failed else "blocked"
|
|
2386
|
+
)
|
|
2387
|
+
)
|
|
2388
|
+
await self.publish()
|
|
2389
|
+
|
|
2390
|
+
async def _watch_loop(self):
|
|
2391
|
+
seen, pending = {}, {}
|
|
2392
|
+
while True:
|
|
2393
|
+
await asyncio.sleep(self.composition.runtime.watch_debounce_ms / 1000)
|
|
2394
|
+
if self.mutation.locked():
|
|
2395
|
+
continue
|
|
2396
|
+
for name, request in list(self.composition.plugins.items()):
|
|
2397
|
+
if not request.watch or request.source.startswith(
|
|
2398
|
+
("git+", "https://", "pypi:")
|
|
2399
|
+
):
|
|
2400
|
+
continue
|
|
2401
|
+
root = (self.home / request.source.removeprefix("path:")).resolve()
|
|
2402
|
+
if not root.is_dir():
|
|
2403
|
+
continue
|
|
2404
|
+
try:
|
|
2405
|
+
current = digest(await asyncio.to_thread(source_files, root))
|
|
2406
|
+
except OSError, PluginError:
|
|
2407
|
+
continue
|
|
2408
|
+
if name not in seen:
|
|
2409
|
+
candidate_id = (await self.state.plugin(name))["last_candidate"]
|
|
2410
|
+
seen[name] = (
|
|
2411
|
+
(await self.state.candidate(candidate_id))["source"].get(
|
|
2412
|
+
"source_hash", current
|
|
2413
|
+
)
|
|
2414
|
+
if candidate_id
|
|
2415
|
+
else current
|
|
2416
|
+
)
|
|
2417
|
+
if current == seen[name]:
|
|
2418
|
+
pending.pop(name, None)
|
|
2419
|
+
continue
|
|
2420
|
+
observed, since = pending.get(name, (None, 0))
|
|
2421
|
+
if current != observed:
|
|
2422
|
+
pending[name] = (current, time.monotonic())
|
|
2423
|
+
continue
|
|
2424
|
+
if (
|
|
2425
|
+
time.monotonic() - since
|
|
2426
|
+
< self.composition.runtime.watch_debounce_ms / 1000
|
|
2427
|
+
):
|
|
2428
|
+
continue
|
|
2429
|
+
seen[name] = current
|
|
2430
|
+
pending.pop(name, None)
|
|
2431
|
+
action = (
|
|
2432
|
+
"reload"
|
|
2433
|
+
if request.enabled
|
|
2434
|
+
and not (await self.state.plugin(name))["stopped"]
|
|
2435
|
+
else "validate"
|
|
2436
|
+
)
|
|
2437
|
+
(await self.submit(action, {"id": name, "watch": True}))
|
|
2438
|
+
|
|
2439
|
+
def redact(self, value):
|
|
2440
|
+
secrets = {self.token}
|
|
2441
|
+
for name, request in self.composition.plugins.items():
|
|
2442
|
+
definition = self.graph.definitions.get(name)
|
|
2443
|
+
secrets.update(
|
|
2444
|
+
secret_values(
|
|
2445
|
+
request.config, definition.config_schema if definition else {}
|
|
2446
|
+
)
|
|
2447
|
+
)
|
|
2448
|
+
secrets.update(self.state.secrets)
|
|
2449
|
+
return redact(value, secrets)
|
|
2450
|
+
|
|
2451
|
+
def available_dependencies(self):
|
|
2452
|
+
return {name for name in self.graph.definitions if self.healthy_runtime(name)}
|
|
2453
|
+
|
|
2454
|
+
def healthy_runtime(self, name):
|
|
2455
|
+
if self.state.failed:
|
|
2456
|
+
return False
|
|
2457
|
+
runtime = self.runtimes.get(name)
|
|
2458
|
+
return bool(
|
|
2459
|
+
runtime
|
|
2460
|
+
and runtime.admitted
|
|
2461
|
+
and name not in self.gated
|
|
2462
|
+
and time.time() - runtime.observed <= self.composition.runtime.health_ttl
|
|
2463
|
+
and runtime.health.get("runtime") == "ready"
|
|
2464
|
+
and runtime.health.get("business") not in ("failed", "not_ready")
|
|
2465
|
+
and runtime.worker.process.returncode is None
|
|
2466
|
+
)
|
|
2467
|
+
|
|
2468
|
+
async def status(self):
|
|
2469
|
+
try:
|
|
2470
|
+
self.config.read()
|
|
2471
|
+
self.configuration_error = None
|
|
2472
|
+
except PluginError as error:
|
|
2473
|
+
self.configuration_error = error.as_dict()
|
|
2474
|
+
plugins = []
|
|
2475
|
+
candidates = {}
|
|
2476
|
+
for name in sorted(
|
|
2477
|
+
set(self.composition.plugins)
|
|
2478
|
+
| {row["id"] for row in (await self.state.plugins())}
|
|
2479
|
+
):
|
|
2480
|
+
row = await self.state.plugin(name)
|
|
2481
|
+
request = self.composition.plugins.get(name)
|
|
2482
|
+
runtime = self.runtimes.get(name)
|
|
2483
|
+
candidate = (
|
|
2484
|
+
(await self.state.candidate(row["last_candidate"]))
|
|
2485
|
+
if row["last_candidate"]
|
|
2486
|
+
else None
|
|
2487
|
+
)
|
|
2488
|
+
if candidate:
|
|
2489
|
+
candidates[name] = candidate
|
|
2490
|
+
plugins.append(
|
|
2491
|
+
{
|
|
2492
|
+
"id": name,
|
|
2493
|
+
"desired": request.model_dump()
|
|
2494
|
+
if request
|
|
2495
|
+
else {"installed": False},
|
|
2496
|
+
"installed": row["last_candidate"],
|
|
2497
|
+
"active": runtime.candidate["id"]
|
|
2498
|
+
if runtime and runtime.admitted and name not in self.gated
|
|
2499
|
+
else None,
|
|
2500
|
+
"active_status": {
|
|
2501
|
+
"version": runtime.candidate["version"],
|
|
2502
|
+
"artifact_hash": runtime.candidate["artifact_hash"],
|
|
2503
|
+
"environment": runtime.candidate["environment"]["id"],
|
|
2504
|
+
"generation": runtime.generation,
|
|
2505
|
+
}
|
|
2506
|
+
if runtime
|
|
2507
|
+
else None,
|
|
2508
|
+
"source_condition": "not_checked",
|
|
2509
|
+
"candidate": row["last_candidate"],
|
|
2510
|
+
"candidate_status": {
|
|
2511
|
+
key: candidate[key]
|
|
2512
|
+
for key in (
|
|
2513
|
+
"distribution",
|
|
2514
|
+
"version",
|
|
2515
|
+
"artifact_hash",
|
|
2516
|
+
"source",
|
|
2517
|
+
"environment",
|
|
2518
|
+
"validation",
|
|
2519
|
+
)
|
|
2520
|
+
}
|
|
2521
|
+
if candidate
|
|
2522
|
+
else None,
|
|
2523
|
+
"runtime": row["runtime"],
|
|
2524
|
+
"stopped_override": row["stopped"],
|
|
2525
|
+
"blocked_by": self.graph.missing.get(name, []),
|
|
2526
|
+
"health_fresh": bool(
|
|
2527
|
+
runtime
|
|
2528
|
+
and time.time() - runtime.observed
|
|
2529
|
+
<= self.composition.runtime.health_ttl
|
|
2530
|
+
),
|
|
2531
|
+
"healthy": self.healthy_runtime(name),
|
|
2532
|
+
}
|
|
2533
|
+
)
|
|
2534
|
+
available = set()
|
|
2535
|
+
for name, runtime in self.runtimes.items():
|
|
2536
|
+
if self.healthy_runtime(name):
|
|
2537
|
+
available.add(f"plugin:{name}")
|
|
2538
|
+
for service in self.graph.definitions[name].services:
|
|
2539
|
+
available.add(f"service:{service}")
|
|
2540
|
+
for resource in self.graph.definitions[name].resources:
|
|
2541
|
+
kind = {
|
|
2542
|
+
"dashboard.page": "dashboard",
|
|
2543
|
+
"mcp.tool": "mcp",
|
|
2544
|
+
"llms.document": "llms",
|
|
2545
|
+
}[resource.kind]
|
|
2546
|
+
if self.publication.get(kind, {}).get(
|
|
2547
|
+
"state"
|
|
2548
|
+
) == "available" and self.healthy_runtime(
|
|
2549
|
+
self.graph.providers.get(kind)
|
|
2550
|
+
):
|
|
2551
|
+
available.add(f"{name}/{resource.kind}/{resource.id}")
|
|
2552
|
+
missing = sorted(set(self.composition.required_capabilities) - available)
|
|
2553
|
+
needed_presentations = {
|
|
2554
|
+
{"dashboard.page": "dashboard", "mcp.tool": "mcp", "llms.document": "llms"}[
|
|
2555
|
+
resource.resource.kind
|
|
2556
|
+
]
|
|
2557
|
+
for resource in self.graph.resources()
|
|
2558
|
+
}
|
|
2559
|
+
degraded = any(
|
|
2560
|
+
self.publication.get(kind, {}).get("state") != "available"
|
|
2561
|
+
or not self.healthy_runtime(self.graph.providers.get(kind))
|
|
2562
|
+
for kind in needed_presentations
|
|
2563
|
+
) or any(
|
|
2564
|
+
request.enabled and not self.healthy_runtime(name)
|
|
2565
|
+
for name, request in self.composition.plugins.items()
|
|
2566
|
+
)
|
|
2567
|
+
ready = not (
|
|
2568
|
+
missing
|
|
2569
|
+
or self.configuration_error
|
|
2570
|
+
or self.orphans
|
|
2571
|
+
or (await self.state.meta("forward_repair"))
|
|
2572
|
+
)
|
|
2573
|
+
cordis = self.manager.snapshot()
|
|
2574
|
+
return self.redact(
|
|
2575
|
+
{
|
|
2576
|
+
"report_schema": 1,
|
|
2577
|
+
"core": "ready",
|
|
2578
|
+
"access": await self.access.mode(),
|
|
2579
|
+
"mcp_access": self.mcp_access.status(),
|
|
2580
|
+
"ready": ready,
|
|
2581
|
+
"degraded": degraded,
|
|
2582
|
+
"configuration_error": self.configuration_error,
|
|
2583
|
+
"orphaned_workers": self.orphans,
|
|
2584
|
+
"forward_repair": (await self.state.meta("forward_repair")),
|
|
2585
|
+
"stores": self.stores.describe(self.graph),
|
|
2586
|
+
"host_run": self.host_run,
|
|
2587
|
+
"pid": os.getpid(),
|
|
2588
|
+
"desired_revision": self.config.revision,
|
|
2589
|
+
"observed_revision": (await self.state.meta("desired_revision")),
|
|
2590
|
+
"catalog_epoch": self.epoch,
|
|
2591
|
+
"application": "not_ready"
|
|
2592
|
+
if not ready
|
|
2593
|
+
else "degraded"
|
|
2594
|
+
if degraded
|
|
2595
|
+
else "ready",
|
|
2596
|
+
"missing_required": missing,
|
|
2597
|
+
"plugins": plugins,
|
|
2598
|
+
"presentations": self.publication,
|
|
2599
|
+
"operations": (await self.state.operations()),
|
|
2600
|
+
"cordis": cordis,
|
|
2601
|
+
"dependency_graph": dependency_snapshot(
|
|
2602
|
+
self.graph,
|
|
2603
|
+
plugins,
|
|
2604
|
+
candidates,
|
|
2605
|
+
self.composition.bindings,
|
|
2606
|
+
cordis,
|
|
2607
|
+
self.publication,
|
|
2608
|
+
),
|
|
2609
|
+
}
|
|
2610
|
+
)
|