toolframe-core 0.6.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- toolframe_core/__init__.py +3 -0
- toolframe_core/_state_postgres.py +420 -0
- toolframe_core/_state_sqlite.py +339 -0
- toolframe_core/access.py +513 -0
- toolframe_core/api.py +700 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/LICENSE +21 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/SKILL.md +59 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/agents/openai.yaml +4 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/LICENSE +21 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/pyproject.toml +21 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/__init__.py +1 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/docs/usage.md +13 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/migrations/0001_init.sql +5 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/page.py +25 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/plugin.py +119 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/service.py +48 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/references/design.md +60 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/references/operations.md +133 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/references/plugin-api.md +186 -0
- toolframe_core/cli.py +681 -0
- toolframe_core/config.py +267 -0
- toolframe_core/diagnostics.py +200 -0
- toolframe_core/graph.py +207 -0
- toolframe_core/host.py +2610 -0
- toolframe_core/maintenance.py +868 -0
- toolframe_core/mcp_access.py +259 -0
- toolframe_core/network.py +97 -0
- toolframe_core/oidc_access.py +187 -0
- toolframe_core/packages.py +728 -0
- toolframe_core/process.py +212 -0
- toolframe_core/redaction.py +120 -0
- toolframe_core/scaffold.py +142 -0
- toolframe_core/skills.py +210 -0
- toolframe_core/state.py +226 -0
- toolframe_core/state_admin.py +270 -0
- toolframe_core/stores.py +88 -0
- toolframe_core/templates.json +6 -0
- toolframe_core/templates.py +56 -0
- toolframe_core-0.6.2.dist-info/METADATA +465 -0
- toolframe_core-0.6.2.dist-info/RECORD +46 -0
- toolframe_core-0.6.2.dist-info/WHEEL +4 -0
- toolframe_core-0.6.2.dist-info/entry_points.txt +2 -0
- toolframe_core-0.6.2.dist-info/licenses/LICENSE +202 -0
- toolframe_core-0.6.2.dist-info/licenses/NOTICE +14 -0
- toolframe_core-0.6.2.dist-info/licenses/src/toolframe_core/bundled_skills/toolframe-plugin-design/LICENSE +21 -0
- toolframe_core-0.6.2.dist-info/licenses/src/toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/LICENSE +21 -0
toolframe_core/config.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""TOML is desired state. Every managed edit is revision checked and atomically replaced."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import secrets
|
|
9
|
+
import tempfile
|
|
10
|
+
import uuid
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Literal
|
|
13
|
+
from urllib.parse import urlsplit
|
|
14
|
+
|
|
15
|
+
import tomlkit
|
|
16
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
17
|
+
from toolframe_sdk import PluginError
|
|
18
|
+
|
|
19
|
+
from .network import bind_host
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def validate_plugin_id(identifier: str) -> str:
|
|
23
|
+
if not re.fullmatch(r"[a-z][a-z0-9-]{0,63}", identifier):
|
|
24
|
+
raise ValueError(
|
|
25
|
+
"Plugin IDs must be normalized lowercase slugs (1–64 characters)"
|
|
26
|
+
)
|
|
27
|
+
return identifier
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Model(BaseModel):
|
|
31
|
+
model_config = ConfigDict(extra="forbid")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Application(Model):
|
|
35
|
+
id: str
|
|
36
|
+
name: str = "Toolframe"
|
|
37
|
+
host: str = "127.0.0.1"
|
|
38
|
+
port: int = Field(default=9876, ge=0, le=65535)
|
|
39
|
+
public_url: str = ""
|
|
40
|
+
|
|
41
|
+
@field_validator("public_url")
|
|
42
|
+
@classmethod
|
|
43
|
+
def public_origin(cls, value):
|
|
44
|
+
if not value:
|
|
45
|
+
return ""
|
|
46
|
+
uri = urlsplit(value)
|
|
47
|
+
if (
|
|
48
|
+
not uri.hostname
|
|
49
|
+
or uri.username
|
|
50
|
+
or uri.password
|
|
51
|
+
or uri.path not in {"", "/"}
|
|
52
|
+
or uri.query
|
|
53
|
+
or uri.fragment
|
|
54
|
+
or uri.port == 0
|
|
55
|
+
or (
|
|
56
|
+
uri.scheme != "https"
|
|
57
|
+
and not (
|
|
58
|
+
uri.scheme == "http"
|
|
59
|
+
and uri.hostname in {"localhost", "127.0.0.1", "::1"}
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
):
|
|
63
|
+
raise ValueError(
|
|
64
|
+
"public_url must be an HTTPS origin or HTTP loopback origin"
|
|
65
|
+
)
|
|
66
|
+
return value.rstrip("/")
|
|
67
|
+
|
|
68
|
+
@field_validator("host")
|
|
69
|
+
@classmethod
|
|
70
|
+
def listen_address(cls, value):
|
|
71
|
+
return bind_host(value)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class PluginRequest(Model):
|
|
75
|
+
source: str
|
|
76
|
+
enabled: bool = False
|
|
77
|
+
watch: bool = False
|
|
78
|
+
config: dict = Field(default_factory=dict)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class RuntimeSettings(Model):
|
|
82
|
+
drain_timeout: float = Field(default=30, gt=0)
|
|
83
|
+
activation_timeout: float = Field(default=30, gt=0)
|
|
84
|
+
max_task_timeout: float = Field(default=3600, ge=30, le=86400, allow_inf_nan=False)
|
|
85
|
+
disposal_timeout: float = Field(default=10, gt=0)
|
|
86
|
+
health_interval: float = Field(default=10, gt=0)
|
|
87
|
+
health_ttl: float = Field(default=30, gt=0)
|
|
88
|
+
watch_debounce_ms: int = Field(default=500, ge=50)
|
|
89
|
+
|
|
90
|
+
@field_validator("max_task_timeout", mode="before")
|
|
91
|
+
@classmethod
|
|
92
|
+
def task_timeout_policy(cls, value):
|
|
93
|
+
if isinstance(value, bool):
|
|
94
|
+
raise ValueError("max_task_timeout must be a number of seconds") # noqa: TRY004 - Pydantic validators report ValueError as validation failures
|
|
95
|
+
return value
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class PackageSettings(Model):
|
|
99
|
+
find_links: list[str] = Field(default_factory=list)
|
|
100
|
+
index_url: str = "https://pypi.org/simple"
|
|
101
|
+
offline: bool = False
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class AccessPolicy(Model):
|
|
105
|
+
mode: Literal["public", "protected"] = "public"
|
|
106
|
+
local_control: bool = True
|
|
107
|
+
auth_provider: str = "auth"
|
|
108
|
+
dashboard_auth: str = "dashboard-auth"
|
|
109
|
+
oidc_provider: str = ""
|
|
110
|
+
# Retain the historical interpretation of existing files without this field.
|
|
111
|
+
# New applications write an explicit closed policy during initialization.
|
|
112
|
+
failure_mode: Literal["public", "closed"] = "public"
|
|
113
|
+
|
|
114
|
+
@field_validator("oidc_provider")
|
|
115
|
+
@classmethod
|
|
116
|
+
def oidc_identity(cls, value):
|
|
117
|
+
return validate_plugin_id(value) if value else ""
|
|
118
|
+
|
|
119
|
+
@field_validator("auth_provider", "dashboard_auth")
|
|
120
|
+
@classmethod
|
|
121
|
+
def identities(cls, value):
|
|
122
|
+
return validate_plugin_id(value)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class McpAccessPolicy(Model):
|
|
126
|
+
mode: Literal["public", "protected"] = "public"
|
|
127
|
+
auth_provider: str = "mcp-auth"
|
|
128
|
+
|
|
129
|
+
@field_validator("auth_provider")
|
|
130
|
+
@classmethod
|
|
131
|
+
def identity(cls, value):
|
|
132
|
+
return validate_plugin_id(value)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class Composition(Model):
|
|
136
|
+
schema_version: Literal[1] = 1
|
|
137
|
+
application: Application
|
|
138
|
+
plugins: dict[str, PluginRequest] = Field(default_factory=dict)
|
|
139
|
+
bindings: dict[str, str] = Field(default_factory=dict)
|
|
140
|
+
required_capabilities: list[str] = Field(default_factory=list)
|
|
141
|
+
runtime: RuntimeSettings = Field(default_factory=RuntimeSettings)
|
|
142
|
+
packages: PackageSettings = Field(default_factory=PackageSettings)
|
|
143
|
+
access: AccessPolicy = Field(default_factory=AccessPolicy)
|
|
144
|
+
mcp_access: McpAccessPolicy = Field(default_factory=McpAccessPolicy)
|
|
145
|
+
|
|
146
|
+
@field_validator("plugins")
|
|
147
|
+
@classmethod
|
|
148
|
+
def plugin_ids(cls, value):
|
|
149
|
+
for identifier in value:
|
|
150
|
+
validate_plugin_id(identifier)
|
|
151
|
+
return value
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def atomic_write(path: Path, content: str, *, mode=0o600):
|
|
155
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
156
|
+
descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
157
|
+
try:
|
|
158
|
+
with os.fdopen(descriptor, "w") as handle:
|
|
159
|
+
os.fchmod(handle.fileno(), mode)
|
|
160
|
+
handle.write(content)
|
|
161
|
+
handle.flush()
|
|
162
|
+
os.fsync(handle.fileno())
|
|
163
|
+
os.replace(temporary, path)
|
|
164
|
+
directory = os.open(path.parent, os.O_RDONLY)
|
|
165
|
+
try:
|
|
166
|
+
os.fsync(directory)
|
|
167
|
+
finally:
|
|
168
|
+
os.close(directory)
|
|
169
|
+
finally:
|
|
170
|
+
if os.path.exists(temporary):
|
|
171
|
+
os.unlink(temporary)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class ConfigFile:
|
|
175
|
+
def __init__(self, home: Path):
|
|
176
|
+
self.home = home.resolve()
|
|
177
|
+
self.path = self.home / "toolframe.toml"
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def revision(self):
|
|
181
|
+
return (
|
|
182
|
+
hashlib.sha256(self.path.read_bytes()).hexdigest()
|
|
183
|
+
if self.path.exists()
|
|
184
|
+
else None
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
def read(self):
|
|
188
|
+
try:
|
|
189
|
+
text = self.path.read_text()
|
|
190
|
+
document = tomlkit.parse(text)
|
|
191
|
+
return Composition.model_validate(document.unwrap()), hashlib.sha256(
|
|
192
|
+
text.encode()
|
|
193
|
+
).hexdigest()
|
|
194
|
+
except (OSError, ValueError) as error:
|
|
195
|
+
raise PluginError("configuration_invalid", str(error)) from error
|
|
196
|
+
|
|
197
|
+
def initialize(self, *, name="Toolframe", host="127.0.0.1", port=9876):
|
|
198
|
+
if self.path.exists():
|
|
199
|
+
raise PluginError(
|
|
200
|
+
"configuration_exists", "Refusing to overwrite existing toolframe.toml"
|
|
201
|
+
)
|
|
202
|
+
value = Composition(
|
|
203
|
+
application=Application(
|
|
204
|
+
id=str(uuid.uuid4()), name=name, host=host, port=port
|
|
205
|
+
),
|
|
206
|
+
access=AccessPolicy(failure_mode="closed"),
|
|
207
|
+
)
|
|
208
|
+
atomic_write(self.path, tomlkit.dumps(value.model_dump()))
|
|
209
|
+
return value
|
|
210
|
+
|
|
211
|
+
def edit(self, expected_revision, change):
|
|
212
|
+
if self.revision != expected_revision:
|
|
213
|
+
raise PluginError("stale_plan", "toolframe.toml changed after planning")
|
|
214
|
+
document = tomlkit.parse(self.path.read_text())
|
|
215
|
+
change(document)
|
|
216
|
+
value = Composition.model_validate(document.unwrap())
|
|
217
|
+
# Recheck immediately before rename; host lock serializes managed writers.
|
|
218
|
+
if self.revision != expected_revision:
|
|
219
|
+
raise PluginError(
|
|
220
|
+
"stale_plan", "toolframe.toml changed while applying the edit"
|
|
221
|
+
)
|
|
222
|
+
atomic_write(self.path, tomlkit.dumps(document))
|
|
223
|
+
return value, self.revision
|
|
224
|
+
|
|
225
|
+
def put_plugin(self, identifier, request, expected_revision):
|
|
226
|
+
def change(document):
|
|
227
|
+
if "plugins" not in document:
|
|
228
|
+
document["plugins"] = tomlkit.table()
|
|
229
|
+
document["plugins"][identifier] = request.model_dump()
|
|
230
|
+
|
|
231
|
+
return self.edit(expected_revision, change)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class HostLock:
|
|
235
|
+
def __init__(self, home):
|
|
236
|
+
self.path = Path(home) / ".toolframe" / "host.lock"
|
|
237
|
+
self.file = None
|
|
238
|
+
|
|
239
|
+
def acquire(self):
|
|
240
|
+
import fcntl
|
|
241
|
+
|
|
242
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
243
|
+
self.file = self.path.open("a+")
|
|
244
|
+
try:
|
|
245
|
+
fcntl.flock(self.file, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
246
|
+
except BlockingIOError as error:
|
|
247
|
+
self.file.close()
|
|
248
|
+
self.file = None
|
|
249
|
+
raise PluginError(
|
|
250
|
+
"host_already_running", "Another core owns this application home"
|
|
251
|
+
) from error
|
|
252
|
+
self.file.seek(0)
|
|
253
|
+
self.file.truncate()
|
|
254
|
+
self.file.write(str(os.getpid()))
|
|
255
|
+
self.file.flush()
|
|
256
|
+
|
|
257
|
+
def close(self):
|
|
258
|
+
if self.file:
|
|
259
|
+
self.file.close()
|
|
260
|
+
self.file = None
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def management_token(home):
|
|
264
|
+
path = Path(home) / ".toolframe" / "management.token"
|
|
265
|
+
if not path.exists():
|
|
266
|
+
atomic_write(path, secrets.token_urlsafe(32))
|
|
267
|
+
return path.read_text().strip()
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Read-only topology from selected definitions and the lifecycle planner.
|
|
2
|
+
|
|
3
|
+
Never import plugin code or prepare candidates here. Inactive declarations are
|
|
4
|
+
useful for diagnosis, but must not replace the running graph with a newer wheel.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from toolframe_sdk import PluginDefinition
|
|
8
|
+
|
|
9
|
+
from .graph import Graph
|
|
10
|
+
|
|
11
|
+
PRESENTATIONS = {
|
|
12
|
+
"dashboard.page": "dashboard",
|
|
13
|
+
"mcp.tool": "mcp",
|
|
14
|
+
"llms.document": "llms",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def dependency_snapshot(graph, plugins, candidates, bindings, cordis, publication):
|
|
19
|
+
definitions = {
|
|
20
|
+
name: PluginDefinition.model_validate(candidate["definition"])
|
|
21
|
+
for name, candidate in candidates.items()
|
|
22
|
+
if name not in graph.definitions
|
|
23
|
+
}
|
|
24
|
+
definitions.update(graph.definitions)
|
|
25
|
+
lifecycle = {item["name"]: item for item in cordis}
|
|
26
|
+
nodes, edges = [], []
|
|
27
|
+
for item in plugins:
|
|
28
|
+
name = item["id"]
|
|
29
|
+
definition = definitions.get(name)
|
|
30
|
+
selected = name in graph.definitions
|
|
31
|
+
observed = lifecycle.get(name, {})
|
|
32
|
+
state = observed.get("state", item["runtime"].get("state", "installed"))
|
|
33
|
+
if not selected:
|
|
34
|
+
if item["desired"].get("installed") is False:
|
|
35
|
+
state = "removed"
|
|
36
|
+
elif item["stopped_override"]:
|
|
37
|
+
state = "stopped"
|
|
38
|
+
elif not item["desired"].get("enabled", False):
|
|
39
|
+
state = "disabled"
|
|
40
|
+
candidate = graph.candidates.get(name, candidates.get(name, {}))
|
|
41
|
+
nodes.append(
|
|
42
|
+
{
|
|
43
|
+
"id": name,
|
|
44
|
+
"label": name,
|
|
45
|
+
"kind": definition.kind if definition else "unknown",
|
|
46
|
+
"provider_kind": definition.provider_kind if definition else None,
|
|
47
|
+
"version": candidate.get("version"),
|
|
48
|
+
"state": state,
|
|
49
|
+
"healthy": item["healthy"],
|
|
50
|
+
"health_fresh": item["health_fresh"],
|
|
51
|
+
"enabled": item["desired"].get("enabled", False),
|
|
52
|
+
"selected": selected,
|
|
53
|
+
"definition_source": "selected"
|
|
54
|
+
if selected
|
|
55
|
+
else "last_candidate"
|
|
56
|
+
if definition
|
|
57
|
+
else "unavailable",
|
|
58
|
+
"in_flight": observed.get("inFlight", 0),
|
|
59
|
+
"blocked_by": observed.get("blockedBy", item["blocked_by"]),
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
if not definition:
|
|
63
|
+
continue
|
|
64
|
+
for alias, requirement in sorted(definition.requires.items()):
|
|
65
|
+
resolved = graph.bindings.get(name, {}).get(alias) if selected else None
|
|
66
|
+
# For an unresolved/inactive declaration, show a known supplier only
|
|
67
|
+
# if unambiguous. This is not a new runtime binding.
|
|
68
|
+
explicit = bindings.get(
|
|
69
|
+
f"{name}/{alias}", bindings.get(requirement.service)
|
|
70
|
+
)
|
|
71
|
+
matches = [
|
|
72
|
+
owner
|
|
73
|
+
for owner, target in definitions.items()
|
|
74
|
+
if requirement.service in target.services
|
|
75
|
+
and target.services[requirement.service].version == requirement.version
|
|
76
|
+
and (not explicit or owner == explicit)
|
|
77
|
+
]
|
|
78
|
+
target = (
|
|
79
|
+
resolved[0] if resolved else matches[0] if len(matches) == 1 else None
|
|
80
|
+
)
|
|
81
|
+
state = "resolved" if resolved else "missing" if selected else "inactive"
|
|
82
|
+
if target is None:
|
|
83
|
+
target = f"requirement:{name}:{alias}"
|
|
84
|
+
nodes.append(
|
|
85
|
+
{
|
|
86
|
+
"id": target,
|
|
87
|
+
"label": f"{requirement.service} v{requirement.version}",
|
|
88
|
+
"kind": "unresolved",
|
|
89
|
+
"state": "ambiguous" if len(matches) > 1 else "missing",
|
|
90
|
+
}
|
|
91
|
+
)
|
|
92
|
+
edges.append(
|
|
93
|
+
{
|
|
94
|
+
"source": name,
|
|
95
|
+
"target": target,
|
|
96
|
+
"kind": "optional" if requirement.optional else "required",
|
|
97
|
+
"label": f"{requirement.service} v{requirement.version}",
|
|
98
|
+
"alias": alias,
|
|
99
|
+
"state": state,
|
|
100
|
+
}
|
|
101
|
+
)
|
|
102
|
+
for resource_kind, provider_kind in PRESENTATIONS.items():
|
|
103
|
+
resources = sorted(
|
|
104
|
+
resource.id
|
|
105
|
+
for resource in definition.resources
|
|
106
|
+
if resource.kind == resource_kind
|
|
107
|
+
)
|
|
108
|
+
if not resources:
|
|
109
|
+
continue
|
|
110
|
+
target = graph.providers.get(provider_kind)
|
|
111
|
+
if target is None:
|
|
112
|
+
matches = [
|
|
113
|
+
owner
|
|
114
|
+
for owner, target_definition in definitions.items()
|
|
115
|
+
if target_definition.provider_kind == provider_kind
|
|
116
|
+
]
|
|
117
|
+
target = (
|
|
118
|
+
matches[0] if len(matches) == 1 else f"provider:{provider_kind}"
|
|
119
|
+
)
|
|
120
|
+
if target.startswith("provider:") and not any(
|
|
121
|
+
node["id"] == target for node in nodes
|
|
122
|
+
):
|
|
123
|
+
nodes.append(
|
|
124
|
+
{
|
|
125
|
+
"id": target,
|
|
126
|
+
"label": f"{provider_kind} provider",
|
|
127
|
+
"kind": "unresolved",
|
|
128
|
+
"state": "missing",
|
|
129
|
+
}
|
|
130
|
+
)
|
|
131
|
+
edges.append(
|
|
132
|
+
{
|
|
133
|
+
"source": name,
|
|
134
|
+
"target": target,
|
|
135
|
+
"kind": "presentation",
|
|
136
|
+
"label": resource_kind,
|
|
137
|
+
"resources": resources,
|
|
138
|
+
"state": "inactive"
|
|
139
|
+
if not selected
|
|
140
|
+
else "resolved"
|
|
141
|
+
if target in graph.definitions
|
|
142
|
+
else "missing",
|
|
143
|
+
"publication": publication.get(provider_kind, {}).get(
|
|
144
|
+
"state", "unavailable"
|
|
145
|
+
),
|
|
146
|
+
}
|
|
147
|
+
)
|
|
148
|
+
impacts = {}
|
|
149
|
+
for name in sorted(graph.definitions):
|
|
150
|
+
impacts[name] = {}
|
|
151
|
+
for action in ("reload", "disable"):
|
|
152
|
+
replacement = (
|
|
153
|
+
graph
|
|
154
|
+
if action == "reload"
|
|
155
|
+
else Graph(
|
|
156
|
+
{
|
|
157
|
+
key: value
|
|
158
|
+
for key, value in graph.candidates.items()
|
|
159
|
+
if key != name
|
|
160
|
+
},
|
|
161
|
+
graph.binding_preferences,
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
affected = graph.affected(replacement, {name})
|
|
165
|
+
dependents = graph.dependents({name}) - {name}
|
|
166
|
+
presentations = [
|
|
167
|
+
{
|
|
168
|
+
"owner": edge["source"],
|
|
169
|
+
"provider": edge["target"],
|
|
170
|
+
"kind": edge["label"],
|
|
171
|
+
"resources": edge["resources"],
|
|
172
|
+
}
|
|
173
|
+
for edge in edges
|
|
174
|
+
if edge["kind"] == "presentation"
|
|
175
|
+
and edge["source"] in graph.definitions
|
|
176
|
+
and (edge["source"] in affected or edge["target"] in affected)
|
|
177
|
+
]
|
|
178
|
+
impacts[name][action] = {
|
|
179
|
+
"affected": sorted(affected),
|
|
180
|
+
"required_dependents": sorted(dependents),
|
|
181
|
+
"presentation_providers": sorted(affected - dependents - {name}),
|
|
182
|
+
"optional_consumers": sorted(
|
|
183
|
+
owner
|
|
184
|
+
for owner, resolved in graph.bindings.items()
|
|
185
|
+
if owner not in affected
|
|
186
|
+
and any(
|
|
187
|
+
target in affected
|
|
188
|
+
and graph.definitions[owner].requires[alias].optional
|
|
189
|
+
for alias, (target, _) in resolved.items()
|
|
190
|
+
)
|
|
191
|
+
),
|
|
192
|
+
"presentations": presentations,
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
"schema_version": 1,
|
|
196
|
+
"basis": "selected",
|
|
197
|
+
"nodes": sorted(nodes, key=lambda node: node["id"]),
|
|
198
|
+
"edges": edges,
|
|
199
|
+
"impacts": impacts,
|
|
200
|
+
}
|
toolframe_core/graph.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Resolve explicit service identities independently of presentation attachment."""
|
|
2
|
+
|
|
3
|
+
from graphlib import CycleError, TopologicalSorter
|
|
4
|
+
|
|
5
|
+
from toolframe_sdk import OwnedResource, PluginDefinition, PluginError, digest
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Graph:
|
|
9
|
+
def __init__(self, candidates, bindings):
|
|
10
|
+
self.candidates = candidates
|
|
11
|
+
self.binding_preferences = dict(bindings)
|
|
12
|
+
self.definitions = {
|
|
13
|
+
name: PluginDefinition.model_validate(value["definition"])
|
|
14
|
+
for name, value in candidates.items()
|
|
15
|
+
}
|
|
16
|
+
self.dependencies = {name: set() for name in candidates}
|
|
17
|
+
self.bindings = {}
|
|
18
|
+
self.missing = {}
|
|
19
|
+
self.providers = {}
|
|
20
|
+
self.storage_providers = set()
|
|
21
|
+
self.stores = {}
|
|
22
|
+
for name, definition in self.definitions.items():
|
|
23
|
+
if definition.provider_kind == "storage":
|
|
24
|
+
self.storage_providers.add(name)
|
|
25
|
+
elif definition.provider_kind:
|
|
26
|
+
if definition.provider_kind in self.providers:
|
|
27
|
+
raise PluginError(
|
|
28
|
+
"provider_conflict",
|
|
29
|
+
f"Only one enabled {definition.provider_kind} provider can own its routes/store",
|
|
30
|
+
)
|
|
31
|
+
self.providers[definition.provider_kind] = name
|
|
32
|
+
for name, definition in self.definitions.items():
|
|
33
|
+
resolved = self.bindings[name] = {}
|
|
34
|
+
missing = self.missing[name] = []
|
|
35
|
+
for alias, requirement in definition.requires.items():
|
|
36
|
+
matches = [
|
|
37
|
+
owner
|
|
38
|
+
for owner, target in self.definitions.items()
|
|
39
|
+
if requirement.service in target.services
|
|
40
|
+
and target.services[requirement.service].version
|
|
41
|
+
== requirement.version
|
|
42
|
+
]
|
|
43
|
+
explicit = bindings.get(
|
|
44
|
+
f"{name}/{alias}", bindings.get(requirement.service)
|
|
45
|
+
)
|
|
46
|
+
if explicit:
|
|
47
|
+
matches = [owner for owner in matches if owner == explicit]
|
|
48
|
+
if len(matches) > 1:
|
|
49
|
+
raise PluginError(
|
|
50
|
+
"service_ambiguous",
|
|
51
|
+
f"Bind {name}/{alias} explicitly; several plugins provide {requirement.service}",
|
|
52
|
+
)
|
|
53
|
+
if not matches:
|
|
54
|
+
if not requirement.optional:
|
|
55
|
+
missing.append(requirement.service)
|
|
56
|
+
continue
|
|
57
|
+
resolved[alias] = (matches[0], requirement.service)
|
|
58
|
+
if not requirement.optional:
|
|
59
|
+
self.dependencies[name].add(matches[0])
|
|
60
|
+
sql_requirements = {
|
|
61
|
+
alias: requirement
|
|
62
|
+
for alias, requirement in definition.requires.items()
|
|
63
|
+
if requirement.service == "toolframe.storage.sql"
|
|
64
|
+
}
|
|
65
|
+
if definition.store and not any(
|
|
66
|
+
not requirement.optional for requirement in sql_requirements.values()
|
|
67
|
+
):
|
|
68
|
+
raise PluginError(
|
|
69
|
+
"invalid_store_dependency",
|
|
70
|
+
"A managed store requires a non-optional SQL storage dependency",
|
|
71
|
+
{"owner": name},
|
|
72
|
+
)
|
|
73
|
+
storage = {
|
|
74
|
+
resolved[alias][0] for alias in sql_requirements if alias in resolved
|
|
75
|
+
}
|
|
76
|
+
if len(storage) > 1:
|
|
77
|
+
raise PluginError(
|
|
78
|
+
"multiple_store_providers",
|
|
79
|
+
"Each plugin may bind its SQL store to only one provider",
|
|
80
|
+
{"owner": name, "providers": sorted(storage)},
|
|
81
|
+
)
|
|
82
|
+
if storage:
|
|
83
|
+
provider = next(iter(storage))
|
|
84
|
+
if provider not in self.storage_providers:
|
|
85
|
+
raise PluginError(
|
|
86
|
+
"invalid_storage_provider",
|
|
87
|
+
"The SQL service must be supplied by a storage provider",
|
|
88
|
+
{"owner": name, "provider": provider},
|
|
89
|
+
)
|
|
90
|
+
self.stores[name] = provider
|
|
91
|
+
for resource in definition.resources:
|
|
92
|
+
if resource.kind == "mcp.tool":
|
|
93
|
+
service = resource.data["service"]
|
|
94
|
+
if service in definition.services:
|
|
95
|
+
target = definition.services[service]
|
|
96
|
+
else:
|
|
97
|
+
matching = [
|
|
98
|
+
alias
|
|
99
|
+
for alias, item in definition.requires.items()
|
|
100
|
+
if item.service == service
|
|
101
|
+
]
|
|
102
|
+
if not matching:
|
|
103
|
+
raise PluginError(
|
|
104
|
+
"resource_target_invalid",
|
|
105
|
+
f"{name}/{resource.id} targets an undeclared service",
|
|
106
|
+
)
|
|
107
|
+
resolution = resolved.get(matching[0])
|
|
108
|
+
target = (
|
|
109
|
+
self.definitions[resolution[0]].services[service]
|
|
110
|
+
if resolution
|
|
111
|
+
else None
|
|
112
|
+
)
|
|
113
|
+
if target and resource.data["method"] not in target.methods:
|
|
114
|
+
raise PluginError(
|
|
115
|
+
"resource_target_invalid",
|
|
116
|
+
f"{name}/{resource.id} targets an unknown method",
|
|
117
|
+
)
|
|
118
|
+
if (
|
|
119
|
+
target
|
|
120
|
+
and target.methods[resource.data["method"]].audience
|
|
121
|
+
!= "business"
|
|
122
|
+
):
|
|
123
|
+
raise PluginError(
|
|
124
|
+
"resource_target_invalid",
|
|
125
|
+
"Public MCP tools must target business methods",
|
|
126
|
+
)
|
|
127
|
+
try:
|
|
128
|
+
self.order = tuple(TopologicalSorter(self.dependencies).static_order())
|
|
129
|
+
except CycleError as error:
|
|
130
|
+
raise PluginError(
|
|
131
|
+
"dependency_cycle", "Required service dependencies contain a cycle"
|
|
132
|
+
) from error
|
|
133
|
+
|
|
134
|
+
def closure(self, name, *, optional=False, available=None):
|
|
135
|
+
result = set()
|
|
136
|
+
|
|
137
|
+
def visit(owner):
|
|
138
|
+
if owner in result:
|
|
139
|
+
return
|
|
140
|
+
result.add(owner)
|
|
141
|
+
for dependency in self.dependencies.get(owner, ()):
|
|
142
|
+
visit(dependency)
|
|
143
|
+
if optional:
|
|
144
|
+
for dependency, _ in self.bindings.get(owner, {}).values():
|
|
145
|
+
if available is None or dependency in available:
|
|
146
|
+
visit(dependency)
|
|
147
|
+
|
|
148
|
+
visit(name)
|
|
149
|
+
return result
|
|
150
|
+
|
|
151
|
+
def dependents(self, names):
|
|
152
|
+
result = set(names)
|
|
153
|
+
for name in self.order:
|
|
154
|
+
if self.dependencies[name] & result:
|
|
155
|
+
result.add(name)
|
|
156
|
+
return result
|
|
157
|
+
|
|
158
|
+
def resources(self, generations=None):
|
|
159
|
+
generations = generations or {}
|
|
160
|
+
return [
|
|
161
|
+
OwnedResource(
|
|
162
|
+
owner=name,
|
|
163
|
+
generation=generations.get(name, "candidate"),
|
|
164
|
+
resource=resource,
|
|
165
|
+
content=self.candidates[name].get("documents", {}).get(resource.id)
|
|
166
|
+
if resource.kind == "llms.document"
|
|
167
|
+
else None,
|
|
168
|
+
)
|
|
169
|
+
for name in sorted(self.definitions)
|
|
170
|
+
for resource in self.definitions[name].resources
|
|
171
|
+
]
|
|
172
|
+
|
|
173
|
+
def descriptor_digest(self, kind):
|
|
174
|
+
return digest(
|
|
175
|
+
[
|
|
176
|
+
{
|
|
177
|
+
"owner": resource.owner,
|
|
178
|
+
"resource": resource.resource.model_dump(),
|
|
179
|
+
"content": resource.content,
|
|
180
|
+
}
|
|
181
|
+
for resource in self.resources()
|
|
182
|
+
if resource.resource.kind == kind
|
|
183
|
+
]
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
def affected(self, replacement, changed):
|
|
187
|
+
result = self.dependents(changed) | replacement.dependents(changed)
|
|
188
|
+
page_owners = {
|
|
189
|
+
resource.owner
|
|
190
|
+
for resource in self.resources() + replacement.resources()
|
|
191
|
+
if resource.resource.kind == "dashboard.page"
|
|
192
|
+
}
|
|
193
|
+
if result & page_owners:
|
|
194
|
+
result |= {
|
|
195
|
+
graph.providers["dashboard"]
|
|
196
|
+
for graph in (self, replacement)
|
|
197
|
+
if "dashboard" in graph.providers
|
|
198
|
+
}
|
|
199
|
+
if self.descriptor_digest("mcp.tool") != replacement.descriptor_digest(
|
|
200
|
+
"mcp.tool"
|
|
201
|
+
):
|
|
202
|
+
result |= {
|
|
203
|
+
graph.providers["mcp"]
|
|
204
|
+
for graph in (self, replacement)
|
|
205
|
+
if "mcp" in graph.providers
|
|
206
|
+
}
|
|
207
|
+
return result
|