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.
Files changed (46) hide show
  1. toolframe_core/__init__.py +3 -0
  2. toolframe_core/_state_postgres.py +420 -0
  3. toolframe_core/_state_sqlite.py +339 -0
  4. toolframe_core/access.py +513 -0
  5. toolframe_core/api.py +700 -0
  6. toolframe_core/bundled_skills/toolframe-plugin-design/LICENSE +21 -0
  7. toolframe_core/bundled_skills/toolframe-plugin-design/SKILL.md +59 -0
  8. toolframe_core/bundled_skills/toolframe-plugin-design/agents/openai.yaml +4 -0
  9. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/LICENSE +21 -0
  10. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/pyproject.toml +21 -0
  11. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/__init__.py +1 -0
  12. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/docs/usage.md +13 -0
  13. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/migrations/0001_init.sql +5 -0
  14. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/page.py +25 -0
  15. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/plugin.py +119 -0
  16. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/service.py +48 -0
  17. toolframe_core/bundled_skills/toolframe-plugin-design/references/design.md +60 -0
  18. toolframe_core/bundled_skills/toolframe-plugin-design/references/operations.md +133 -0
  19. toolframe_core/bundled_skills/toolframe-plugin-design/references/plugin-api.md +186 -0
  20. toolframe_core/cli.py +681 -0
  21. toolframe_core/config.py +267 -0
  22. toolframe_core/diagnostics.py +200 -0
  23. toolframe_core/graph.py +207 -0
  24. toolframe_core/host.py +2610 -0
  25. toolframe_core/maintenance.py +868 -0
  26. toolframe_core/mcp_access.py +259 -0
  27. toolframe_core/network.py +97 -0
  28. toolframe_core/oidc_access.py +187 -0
  29. toolframe_core/packages.py +728 -0
  30. toolframe_core/process.py +212 -0
  31. toolframe_core/redaction.py +120 -0
  32. toolframe_core/scaffold.py +142 -0
  33. toolframe_core/skills.py +210 -0
  34. toolframe_core/state.py +226 -0
  35. toolframe_core/state_admin.py +270 -0
  36. toolframe_core/stores.py +88 -0
  37. toolframe_core/templates.json +6 -0
  38. toolframe_core/templates.py +56 -0
  39. toolframe_core-0.6.2.dist-info/METADATA +465 -0
  40. toolframe_core-0.6.2.dist-info/RECORD +46 -0
  41. toolframe_core-0.6.2.dist-info/WHEEL +4 -0
  42. toolframe_core-0.6.2.dist-info/entry_points.txt +2 -0
  43. toolframe_core-0.6.2.dist-info/licenses/LICENSE +202 -0
  44. toolframe_core-0.6.2.dist-info/licenses/NOTICE +14 -0
  45. toolframe_core-0.6.2.dist-info/licenses/src/toolframe_core/bundled_skills/toolframe-plugin-design/LICENSE +21 -0
  46. 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
+ )