agentplane-runtime 0.0.1__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.
- agentplane_runtime/__init__.py +8 -0
- agentplane_runtime/api.py +232 -0
- agentplane_runtime/app.py +85 -0
- agentplane_runtime/auth.py +137 -0
- agentplane_runtime/db.py +129 -0
- agentplane_runtime/definitions.py +280 -0
- agentplane_runtime/engine.py +407 -0
- agentplane_runtime/llm.py +121 -0
- agentplane_runtime/registration.py +145 -0
- agentplane_runtime/resources.py +237 -0
- agentplane_runtime/secrets.py +45 -0
- agentplane_runtime/serving.py +383 -0
- agentplane_runtime/settings.py +31 -0
- agentplane_runtime/tracing.py +52 -0
- agentplane_runtime/validation.py +91 -0
- agentplane_runtime/vector.py +149 -0
- agentplane_runtime-0.0.1.dist-info/METADATA +48 -0
- agentplane_runtime-0.0.1.dist-info/RECORD +19 -0
- agentplane_runtime-0.0.1.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""OpenAI-compatible LLM access — always through a configured base URL (the gateway)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import AsyncIterator
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from agentplane_core import JsonSchema
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LlmError(RuntimeError):
|
|
14
|
+
"""Chat completion or embedding request failed."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class OpenAICompatibleClient:
|
|
18
|
+
"""Minimal chat-completions + embeddings client (httpx, async, SSE streaming)."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, base_url: str, api_key: str = "", *, timeout: float = 60.0) -> None:
|
|
21
|
+
self._base_url = base_url.rstrip("/")
|
|
22
|
+
self._api_key = api_key
|
|
23
|
+
self._timeout = timeout
|
|
24
|
+
|
|
25
|
+
def _headers(self) -> dict[str, str]:
|
|
26
|
+
headers = {"Content-Type": "application/json"}
|
|
27
|
+
if self._api_key:
|
|
28
|
+
headers["Authorization"] = f"Bearer {self._api_key}"
|
|
29
|
+
return headers
|
|
30
|
+
|
|
31
|
+
def _chat_body(
|
|
32
|
+
self,
|
|
33
|
+
model: str,
|
|
34
|
+
prompt: str,
|
|
35
|
+
system_prompt: str,
|
|
36
|
+
structured_output: JsonSchema | None,
|
|
37
|
+
*,
|
|
38
|
+
stream: bool,
|
|
39
|
+
) -> dict[str, object]:
|
|
40
|
+
messages: list[dict[str, object]] = []
|
|
41
|
+
if system_prompt:
|
|
42
|
+
messages.append({"role": "system", "content": system_prompt})
|
|
43
|
+
messages.append({"role": "user", "content": prompt})
|
|
44
|
+
body: dict[str, object] = {"model": model, "messages": messages, "stream": stream}
|
|
45
|
+
if structured_output is not None:
|
|
46
|
+
body["response_format"] = {
|
|
47
|
+
"type": "json_schema",
|
|
48
|
+
"json_schema": {"name": "structured_output", "schema": structured_output},
|
|
49
|
+
}
|
|
50
|
+
return body
|
|
51
|
+
|
|
52
|
+
async def complete(
|
|
53
|
+
self,
|
|
54
|
+
model: str,
|
|
55
|
+
prompt: str,
|
|
56
|
+
system_prompt: str = "",
|
|
57
|
+
structured_output: JsonSchema | None = None,
|
|
58
|
+
) -> str:
|
|
59
|
+
body = self._chat_body(model, prompt, system_prompt, structured_output, stream=False)
|
|
60
|
+
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
|
61
|
+
response = await client.post(
|
|
62
|
+
f"{self._base_url}/chat/completions", json=body, headers=self._headers()
|
|
63
|
+
)
|
|
64
|
+
if response.status_code != httpx.codes.OK:
|
|
65
|
+
raise LlmError(f"chat completion failed: HTTP {response.status_code}")
|
|
66
|
+
try:
|
|
67
|
+
content = response.json()["choices"][0]["message"]["content"]
|
|
68
|
+
except (KeyError, IndexError, ValueError) as exc:
|
|
69
|
+
raise LlmError(f"malformed chat completion response: {exc}") from exc
|
|
70
|
+
return str(content or "")
|
|
71
|
+
|
|
72
|
+
async def stream(
|
|
73
|
+
self,
|
|
74
|
+
model: str,
|
|
75
|
+
prompt: str,
|
|
76
|
+
system_prompt: str = "",
|
|
77
|
+
structured_output: JsonSchema | None = None,
|
|
78
|
+
) -> AsyncIterator[str]:
|
|
79
|
+
"""Yield content deltas from an SSE chat-completions stream."""
|
|
80
|
+
body = self._chat_body(model, prompt, system_prompt, structured_output, stream=True)
|
|
81
|
+
async with (
|
|
82
|
+
httpx.AsyncClient(timeout=self._timeout) as client,
|
|
83
|
+
client.stream(
|
|
84
|
+
"POST",
|
|
85
|
+
f"{self._base_url}/chat/completions",
|
|
86
|
+
json=body,
|
|
87
|
+
headers=self._headers(),
|
|
88
|
+
) as response,
|
|
89
|
+
):
|
|
90
|
+
if response.status_code != httpx.codes.OK:
|
|
91
|
+
raise LlmError(f"chat completion stream failed: HTTP {response.status_code}")
|
|
92
|
+
async for line in response.aiter_lines():
|
|
93
|
+
if not line.startswith("data:"):
|
|
94
|
+
continue
|
|
95
|
+
data = line.removeprefix("data:").strip()
|
|
96
|
+
if data == "[DONE]":
|
|
97
|
+
break
|
|
98
|
+
try:
|
|
99
|
+
delta = json.loads(data)["choices"][0]["delta"].get("content")
|
|
100
|
+
except (KeyError, IndexError, ValueError):
|
|
101
|
+
continue
|
|
102
|
+
if delta:
|
|
103
|
+
yield str(delta)
|
|
104
|
+
|
|
105
|
+
async def embed(self, model: str, text: str) -> list[float]:
|
|
106
|
+
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
|
107
|
+
response = await client.post(
|
|
108
|
+
f"{self._base_url}/embeddings",
|
|
109
|
+
json={"model": model, "input": text},
|
|
110
|
+
headers=self._headers(),
|
|
111
|
+
)
|
|
112
|
+
if response.status_code != httpx.codes.OK:
|
|
113
|
+
raise LlmError(f"embedding failed: HTTP {response.status_code}")
|
|
114
|
+
try:
|
|
115
|
+
vector = response.json()["data"][0]["embedding"]
|
|
116
|
+
except (KeyError, IndexError, ValueError) as exc:
|
|
117
|
+
raise LlmError(f"malformed embeddings response: {exc}") from exc
|
|
118
|
+
return [float(v) for v in vector]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
__all__ = ["LlmError", "OpenAICompatibleClient"]
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Self-registration with the registry (SPEC §6.5).
|
|
2
|
+
|
|
3
|
+
Registry unreachable -> log + retry with backoff; serving is NOT blocked by
|
|
4
|
+
registry availability.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import logging
|
|
11
|
+
from collections.abc import Coroutine
|
|
12
|
+
from typing import Any
|
|
13
|
+
from uuid import UUID
|
|
14
|
+
|
|
15
|
+
from agentplane_core import (
|
|
16
|
+
FlowDefinition,
|
|
17
|
+
RegistryEntryCreate,
|
|
18
|
+
RegistryEntryPatch,
|
|
19
|
+
ToolCard,
|
|
20
|
+
ToolCardTool,
|
|
21
|
+
)
|
|
22
|
+
from agentplane_core.registry import Card
|
|
23
|
+
from agentplane_runtime.serving import build_agent_card
|
|
24
|
+
from agentplane_runtime.settings import RuntimeSettings
|
|
25
|
+
from agentplane_sdk import AgentplaneError, ConflictError, RegistryClient
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
_RETRY_DELAYS_S = (1.0, 5.0, 15.0, 60.0)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def build_card(defn: FlowDefinition, public_url: str) -> Card:
|
|
33
|
+
if defn.expose.kind == "mcp":
|
|
34
|
+
return ToolCard(
|
|
35
|
+
name=defn.name,
|
|
36
|
+
description=defn.description,
|
|
37
|
+
url=public_url,
|
|
38
|
+
tools=[
|
|
39
|
+
ToolCardTool(
|
|
40
|
+
name=defn.expose.tool_name or defn.name.replace("-", "_"),
|
|
41
|
+
description=defn.expose.tool_description or defn.description,
|
|
42
|
+
)
|
|
43
|
+
],
|
|
44
|
+
)
|
|
45
|
+
return build_agent_card(defn, public_url)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class RegistryRegistrar:
|
|
49
|
+
"""Registers/deregisters deployed flows, with background retry."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, settings: RuntimeSettings) -> None:
|
|
52
|
+
self._settings = settings
|
|
53
|
+
self._tasks: set[asyncio.Task[None]] = set()
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def enabled(self) -> bool:
|
|
57
|
+
return bool(self._settings.registry_url)
|
|
58
|
+
|
|
59
|
+
def _client(self) -> RegistryClient:
|
|
60
|
+
token = self._settings.registry_token or None
|
|
61
|
+
return RegistryClient(self._settings.registry_url, token)
|
|
62
|
+
|
|
63
|
+
async def register(
|
|
64
|
+
self, defn: FlowDefinition, public_url: str, existing_id: UUID | None
|
|
65
|
+
) -> UUID | None:
|
|
66
|
+
"""One immediate attempt; on failure, retries continue in the background."""
|
|
67
|
+
if not self.enabled:
|
|
68
|
+
return None
|
|
69
|
+
try:
|
|
70
|
+
return await self._register_once(defn, public_url, existing_id)
|
|
71
|
+
except AgentplaneError as exc:
|
|
72
|
+
logger.warning("registry registration failed (%s); retrying in background", exc)
|
|
73
|
+
self._spawn(self._retry_register(defn, public_url, existing_id))
|
|
74
|
+
return existing_id
|
|
75
|
+
|
|
76
|
+
async def _register_once(
|
|
77
|
+
self, defn: FlowDefinition, public_url: str, existing_id: UUID | None
|
|
78
|
+
) -> UUID:
|
|
79
|
+
card = build_card(defn, public_url)
|
|
80
|
+
kind = "mcp_server" if defn.expose.kind == "mcp" else "agent"
|
|
81
|
+
async with self._client() as client:
|
|
82
|
+
if existing_id is not None:
|
|
83
|
+
try:
|
|
84
|
+
entry = await client.update(
|
|
85
|
+
existing_id,
|
|
86
|
+
RegistryEntryPatch(card=card, url=public_url, tags=list(defn.tags)),
|
|
87
|
+
)
|
|
88
|
+
except AgentplaneError:
|
|
89
|
+
entry = await client.register(
|
|
90
|
+
RegistryEntryCreate(
|
|
91
|
+
kind=kind, card=card, url=public_url, tags=list(defn.tags)
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
return entry.id
|
|
95
|
+
try:
|
|
96
|
+
entry = await client.register(
|
|
97
|
+
RegistryEntryCreate(kind=kind, card=card, url=public_url, tags=list(defn.tags))
|
|
98
|
+
)
|
|
99
|
+
except ConflictError:
|
|
100
|
+
# entry exists from a previous run we lost track of; find + update
|
|
101
|
+
page = await client.search(defn.name, kind=kind, limit=200)
|
|
102
|
+
for candidate in page.items:
|
|
103
|
+
if candidate.card_name == defn.name:
|
|
104
|
+
return (
|
|
105
|
+
await client.update(
|
|
106
|
+
candidate.id,
|
|
107
|
+
RegistryEntryPatch(card=card, url=public_url, tags=list(defn.tags)),
|
|
108
|
+
)
|
|
109
|
+
).id
|
|
110
|
+
raise
|
|
111
|
+
return entry.id
|
|
112
|
+
|
|
113
|
+
async def _retry_register(
|
|
114
|
+
self, defn: FlowDefinition, public_url: str, existing_id: UUID | None
|
|
115
|
+
) -> None:
|
|
116
|
+
for delay in _RETRY_DELAYS_S:
|
|
117
|
+
await asyncio.sleep(delay)
|
|
118
|
+
try:
|
|
119
|
+
await self._register_once(defn, public_url, existing_id)
|
|
120
|
+
except AgentplaneError as exc:
|
|
121
|
+
logger.warning("registry retry failed (%s)", exc)
|
|
122
|
+
continue
|
|
123
|
+
return
|
|
124
|
+
logger.error("giving up registering %s with the registry", defn.name)
|
|
125
|
+
|
|
126
|
+
async def deregister(self, registry_id: UUID | None) -> None:
|
|
127
|
+
if not self.enabled or registry_id is None:
|
|
128
|
+
return
|
|
129
|
+
try:
|
|
130
|
+
async with self._client() as client:
|
|
131
|
+
await client.delete(registry_id)
|
|
132
|
+
except AgentplaneError as exc:
|
|
133
|
+
logger.warning("registry deregistration failed (%s)", exc)
|
|
134
|
+
|
|
135
|
+
def _spawn(self, coro: Coroutine[Any, Any, None]) -> None:
|
|
136
|
+
task: asyncio.Task[None] = asyncio.create_task(coro)
|
|
137
|
+
self._tasks.add(task)
|
|
138
|
+
task.add_done_callback(self._tasks.discard)
|
|
139
|
+
|
|
140
|
+
async def shutdown(self) -> None:
|
|
141
|
+
for task in self._tasks:
|
|
142
|
+
task.cancel()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
__all__ = ["RegistryRegistrar", "build_card"]
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Resource service (SPEC §6.3): CRUD, write-only secrets, E022 dimension check."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
|
|
8
|
+
from pydantic import ValidationError
|
|
9
|
+
from sqlalchemy import select
|
|
10
|
+
|
|
11
|
+
from agentplane_core import (
|
|
12
|
+
FlowDefinition,
|
|
13
|
+
LlmCallNode,
|
|
14
|
+
McpToolNode,
|
|
15
|
+
Resource,
|
|
16
|
+
RetrievalNode,
|
|
17
|
+
SecretsProvider,
|
|
18
|
+
ValidationIssue,
|
|
19
|
+
ValidationResult,
|
|
20
|
+
VectorDBResource,
|
|
21
|
+
)
|
|
22
|
+
from agentplane_runtime.db import (
|
|
23
|
+
RESOURCE_ADAPTER,
|
|
24
|
+
Database,
|
|
25
|
+
DefinitionRow,
|
|
26
|
+
ResourceRow,
|
|
27
|
+
VersionRow,
|
|
28
|
+
load_definition,
|
|
29
|
+
load_resource,
|
|
30
|
+
)
|
|
31
|
+
from agentplane_runtime.vector import VectorDBError, reader_for
|
|
32
|
+
|
|
33
|
+
_SECRET_FIELDS = ("api_key_secret", "dsn_secret", "auth_secret")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ResourceConflictError(Exception):
|
|
37
|
+
"""Resource exists (create) or is still referenced (delete)."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ResourceNotFoundError(Exception):
|
|
41
|
+
"""No resource with that name."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ResourceValidationError(Exception):
|
|
45
|
+
"""Resource failed a stateful check (e.g. E022)."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, result: ValidationResult) -> None:
|
|
48
|
+
super().__init__("resource validation failed")
|
|
49
|
+
self.result = result
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def secret_ref(resource_name: str, field: str) -> str:
|
|
53
|
+
return f"resource/{resource_name}/{field}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ResourceService:
|
|
57
|
+
"""Owns resource rows; secret values live only in the SecretsProvider."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, db: Database, secrets: SecretsProvider) -> None:
|
|
60
|
+
self._db = db
|
|
61
|
+
self._secrets = secrets
|
|
62
|
+
|
|
63
|
+
async def _store_secrets(self, resource: Resource) -> Resource:
|
|
64
|
+
"""Move secret values into the provider; keep placeholder refs in the row."""
|
|
65
|
+
data = RESOURCE_ADAPTER.dump_python(resource, mode="json", context={"reveal_secrets": True})
|
|
66
|
+
for field in _SECRET_FIELDS:
|
|
67
|
+
value = data.get(field)
|
|
68
|
+
if isinstance(value, str) and value:
|
|
69
|
+
await self._secrets.put(secret_ref(resource.name, field), value)
|
|
70
|
+
data[field] = secret_ref(resource.name, field)
|
|
71
|
+
return RESOURCE_ADAPTER.validate_python(data)
|
|
72
|
+
|
|
73
|
+
async def secret_value(self, resource_name: str, field: str) -> str:
|
|
74
|
+
return await self._secrets.get(secret_ref(resource_name, field))
|
|
75
|
+
|
|
76
|
+
async def check_collection_dimension(
|
|
77
|
+
self, resource: VectorDBResource, collection: str
|
|
78
|
+
) -> ValidationIssue | None:
|
|
79
|
+
"""E022 for a concrete collection (used by definition validation and create)."""
|
|
80
|
+
api_key, dsn = "", ""
|
|
81
|
+
try:
|
|
82
|
+
if resource.api_key_secret:
|
|
83
|
+
api_key = await self.secret_value(resource.name, "api_key_secret")
|
|
84
|
+
if resource.dsn_secret:
|
|
85
|
+
dsn = await self.secret_value(resource.name, "dsn_secret")
|
|
86
|
+
except KeyError:
|
|
87
|
+
pass
|
|
88
|
+
try:
|
|
89
|
+
reader = await reader_for(resource, api_key=api_key, dsn=dsn)
|
|
90
|
+
actual = await reader.collection_dimension(collection)
|
|
91
|
+
except VectorDBError:
|
|
92
|
+
return None # unreachable — runtime errors surface at execution time
|
|
93
|
+
if actual != resource.embedding.dimension:
|
|
94
|
+
return ValidationIssue(
|
|
95
|
+
code="E022",
|
|
96
|
+
severity="error",
|
|
97
|
+
path=f"resources/{resource.name}/embedding/dimension",
|
|
98
|
+
message=(
|
|
99
|
+
f"embedding dimension {resource.embedding.dimension} does not match "
|
|
100
|
+
f"collection {collection!r} dimension {actual}"
|
|
101
|
+
),
|
|
102
|
+
)
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
async def create(self, resource: Resource, owner: str) -> Resource:
|
|
106
|
+
async with self._db.session() as session:
|
|
107
|
+
existing = await session.get(ResourceRow, resource.name)
|
|
108
|
+
if existing is not None:
|
|
109
|
+
raise ResourceConflictError(f"resource {resource.name!r} already exists")
|
|
110
|
+
stored = await self._store_secrets(resource)
|
|
111
|
+
now = datetime.now(UTC)
|
|
112
|
+
row = ResourceRow(
|
|
113
|
+
name=resource.name,
|
|
114
|
+
kind=resource.kind,
|
|
115
|
+
owner=owner,
|
|
116
|
+
config_json=json.dumps(
|
|
117
|
+
RESOURCE_ADAPTER.dump_python(stored, mode="json", context={"reveal_secrets": True})
|
|
118
|
+
),
|
|
119
|
+
created_at=now,
|
|
120
|
+
updated_at=now,
|
|
121
|
+
)
|
|
122
|
+
async with self._db.session() as session, session.begin():
|
|
123
|
+
session.add(row)
|
|
124
|
+
return self._redacted(stored)
|
|
125
|
+
|
|
126
|
+
async def update(self, name: str, resource: Resource, owner: str) -> Resource:
|
|
127
|
+
if resource.name != name:
|
|
128
|
+
raise ResourceValidationError(
|
|
129
|
+
ValidationResult(
|
|
130
|
+
valid=False,
|
|
131
|
+
issues=[
|
|
132
|
+
ValidationIssue(
|
|
133
|
+
code="E011",
|
|
134
|
+
severity="error",
|
|
135
|
+
path="name",
|
|
136
|
+
message="resource name cannot be changed",
|
|
137
|
+
)
|
|
138
|
+
],
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
async with self._db.session() as session:
|
|
142
|
+
row = await session.get(ResourceRow, name)
|
|
143
|
+
if row is None:
|
|
144
|
+
raise ResourceNotFoundError(name)
|
|
145
|
+
stored = await self._store_secrets(resource)
|
|
146
|
+
async with self._db.session() as session, session.begin():
|
|
147
|
+
fresh = await session.get(ResourceRow, name)
|
|
148
|
+
if fresh is None:
|
|
149
|
+
raise ResourceNotFoundError(name)
|
|
150
|
+
fresh.config_json = json.dumps(
|
|
151
|
+
RESOURCE_ADAPTER.dump_python(stored, mode="json", context={"reveal_secrets": True})
|
|
152
|
+
)
|
|
153
|
+
fresh.updated_at = datetime.now(UTC)
|
|
154
|
+
return self._redacted(stored)
|
|
155
|
+
|
|
156
|
+
async def get(self, name: str) -> Resource:
|
|
157
|
+
async with self._db.session() as session:
|
|
158
|
+
row = await session.get(ResourceRow, name)
|
|
159
|
+
if row is None:
|
|
160
|
+
raise ResourceNotFoundError(name)
|
|
161
|
+
return self._redacted(load_resource(row))
|
|
162
|
+
|
|
163
|
+
async def get_raw(self, name: str) -> Resource:
|
|
164
|
+
"""Resource with secret *refs* intact — engine-internal use only."""
|
|
165
|
+
async with self._db.session() as session:
|
|
166
|
+
row = await session.get(ResourceRow, name)
|
|
167
|
+
if row is None:
|
|
168
|
+
raise ResourceNotFoundError(name)
|
|
169
|
+
return load_resource(row)
|
|
170
|
+
|
|
171
|
+
async def list(self, kind: str | None = None) -> list[Resource]:
|
|
172
|
+
stmt = select(ResourceRow).order_by(ResourceRow.name)
|
|
173
|
+
if kind is not None:
|
|
174
|
+
stmt = stmt.where(ResourceRow.kind == kind)
|
|
175
|
+
async with self._db.session() as session:
|
|
176
|
+
rows = (await session.execute(stmt)).scalars().all()
|
|
177
|
+
return [self._redacted(load_resource(row)) for row in rows]
|
|
178
|
+
|
|
179
|
+
async def delete(self, name: str) -> None:
|
|
180
|
+
referencing = await self._referencing_definitions(name)
|
|
181
|
+
if referencing:
|
|
182
|
+
raise ResourceConflictError(
|
|
183
|
+
f"resource {name!r} is referenced by: {', '.join(sorted(referencing))}"
|
|
184
|
+
)
|
|
185
|
+
async with self._db.session() as session, session.begin():
|
|
186
|
+
row = await session.get(ResourceRow, name)
|
|
187
|
+
if row is None:
|
|
188
|
+
raise ResourceNotFoundError(name)
|
|
189
|
+
await session.delete(row)
|
|
190
|
+
for field in _SECRET_FIELDS:
|
|
191
|
+
await self._secrets.delete(secret_ref(name, field))
|
|
192
|
+
|
|
193
|
+
async def _referencing_definitions(self, resource_name: str) -> set[str]:
|
|
194
|
+
names: set[str] = set()
|
|
195
|
+
async with self._db.session() as session:
|
|
196
|
+
drafts = (await session.execute(select(DefinitionRow))).scalars().all()
|
|
197
|
+
versions = (await session.execute(select(VersionRow))).scalars().all()
|
|
198
|
+
for row in drafts:
|
|
199
|
+
if _references(load_definition(row.draft_json), resource_name):
|
|
200
|
+
names.add(row.name)
|
|
201
|
+
for version in versions:
|
|
202
|
+
if _references(load_definition(version.definition_json), resource_name):
|
|
203
|
+
names.add(version.name)
|
|
204
|
+
return names
|
|
205
|
+
|
|
206
|
+
@staticmethod
|
|
207
|
+
def _redacted(resource: Resource) -> Resource:
|
|
208
|
+
"""Round-trip through the redacting serializer -> placeholders only."""
|
|
209
|
+
try:
|
|
210
|
+
return RESOURCE_ADAPTER.validate_python(
|
|
211
|
+
RESOURCE_ADAPTER.dump_python(resource, mode="json")
|
|
212
|
+
)
|
|
213
|
+
except ValidationError: # pragma: no cover - placeholder always validates
|
|
214
|
+
return resource
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _references(defn: FlowDefinition, resource_name: str) -> bool:
|
|
218
|
+
for node in defn.nodes:
|
|
219
|
+
match node:
|
|
220
|
+
case LlmCallNode() | RetrievalNode():
|
|
221
|
+
if node.config.resource == resource_name:
|
|
222
|
+
return True
|
|
223
|
+
case McpToolNode():
|
|
224
|
+
if node.config.resource == resource_name:
|
|
225
|
+
return True
|
|
226
|
+
case _:
|
|
227
|
+
pass
|
|
228
|
+
return False
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
__all__ = [
|
|
232
|
+
"ResourceConflictError",
|
|
233
|
+
"ResourceNotFoundError",
|
|
234
|
+
"ResourceService",
|
|
235
|
+
"ResourceValidationError",
|
|
236
|
+
"secret_ref",
|
|
237
|
+
]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Default SecretsProvider: Fernet-encrypted DB column (SPEC §3.3)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from cryptography.fernet import Fernet
|
|
6
|
+
|
|
7
|
+
from agentplane_core import SecretsProvider
|
|
8
|
+
from agentplane_runtime.db import Database, SecretRow
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SecretNotFoundError(KeyError):
|
|
12
|
+
"""No secret stored under the given ref."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FernetSecretsProvider(SecretsProvider):
|
|
16
|
+
"""Encrypts values with ``AGENTPLANE_RUNTIME_SECRET_KEY`` at rest."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, db: Database, secret_key: str) -> None:
|
|
19
|
+
self._db = db
|
|
20
|
+
self._fernet = Fernet(secret_key.encode("ascii"))
|
|
21
|
+
|
|
22
|
+
async def put(self, ref: str, value: str) -> None:
|
|
23
|
+
ciphertext = self._fernet.encrypt(value.encode("utf-8")).decode("ascii")
|
|
24
|
+
async with self._db.session() as session, session.begin():
|
|
25
|
+
row = await session.get(SecretRow, ref)
|
|
26
|
+
if row is None:
|
|
27
|
+
session.add(SecretRow(ref=ref, ciphertext=ciphertext))
|
|
28
|
+
else:
|
|
29
|
+
row.ciphertext = ciphertext
|
|
30
|
+
|
|
31
|
+
async def get(self, ref: str) -> str:
|
|
32
|
+
async with self._db.session() as session:
|
|
33
|
+
row = await session.get(SecretRow, ref)
|
|
34
|
+
if row is None:
|
|
35
|
+
raise SecretNotFoundError(ref)
|
|
36
|
+
return self._fernet.decrypt(row.ciphertext.encode("ascii")).decode("utf-8")
|
|
37
|
+
|
|
38
|
+
async def delete(self, ref: str) -> None:
|
|
39
|
+
async with self._db.session() as session, session.begin():
|
|
40
|
+
row = await session.get(SecretRow, ref)
|
|
41
|
+
if row is not None:
|
|
42
|
+
await session.delete(row)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
__all__ = ["FernetSecretsProvider", "SecretNotFoundError"]
|