vystak 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- vystak/__init__.py +126 -0
- vystak/hash/__init__.py +6 -0
- vystak/hash/hasher.py +18 -0
- vystak/hash/tree.py +82 -0
- vystak/ir/__init__.py +1 -0
- vystak/providers/__init__.py +23 -0
- vystak/providers/base.py +77 -0
- vystak/provisioning/__init__.py +37 -0
- vystak/provisioning/graph.py +122 -0
- vystak/provisioning/grouping.py +26 -0
- vystak/provisioning/health.py +62 -0
- vystak/provisioning/listener.py +68 -0
- vystak/provisioning/node.py +53 -0
- vystak/schema/__init__.py +93 -0
- vystak/schema/agent.py +49 -0
- vystak/schema/channel.py +23 -0
- vystak/schema/common.py +32 -0
- vystak/schema/config_loader.py +43 -0
- vystak/schema/gateway.py +19 -0
- vystak/schema/loader.py +42 -0
- vystak/schema/mcp.py +15 -0
- vystak/schema/model.py +20 -0
- vystak/schema/multi_loader.py +69 -0
- vystak/schema/openai.py +136 -0
- vystak/schema/platform.py +12 -0
- vystak/schema/provider.py +10 -0
- vystak/schema/resource.py +36 -0
- vystak/schema/secret.py +16 -0
- vystak/schema/service.py +65 -0
- vystak/schema/skill.py +24 -0
- vystak/schema/workspace.py +20 -0
- vystak/stores/__init__.py +5 -0
- vystak/stores/sqlite.py +117 -0
- vystak-0.1.0.dist-info/METADATA +63 -0
- vystak-0.1.0.dist-info/RECORD +36 -0
- vystak-0.1.0.dist-info/WHEEL +4 -0
vystak/__init__.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Vystak — declarative AI agent orchestration."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
# Schema models
|
|
6
|
+
# Hash engine
|
|
7
|
+
from vystak.hash import AgentHashTree, hash_agent, hash_dict, hash_model
|
|
8
|
+
|
|
9
|
+
# Provider ABCs and supporting types
|
|
10
|
+
from vystak.providers import (
|
|
11
|
+
AgentStatus,
|
|
12
|
+
ChannelAdapter,
|
|
13
|
+
DeployPlan,
|
|
14
|
+
DeployResult,
|
|
15
|
+
FrameworkAdapter,
|
|
16
|
+
GeneratedCode,
|
|
17
|
+
PlatformProvider,
|
|
18
|
+
ValidationError,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# Provisioning engine
|
|
22
|
+
from vystak.provisioning import (
|
|
23
|
+
CommandHealthCheck,
|
|
24
|
+
CycleError,
|
|
25
|
+
HealthCheck,
|
|
26
|
+
HttpHealthCheck,
|
|
27
|
+
NoopHealthCheck,
|
|
28
|
+
Provisionable,
|
|
29
|
+
ProvisionError,
|
|
30
|
+
ProvisionGraph,
|
|
31
|
+
ProvisionResult,
|
|
32
|
+
TcpHealthCheck,
|
|
33
|
+
)
|
|
34
|
+
from vystak.schema import (
|
|
35
|
+
Agent,
|
|
36
|
+
Cache,
|
|
37
|
+
Channel,
|
|
38
|
+
ChannelProvider,
|
|
39
|
+
ChannelType,
|
|
40
|
+
Database,
|
|
41
|
+
Embedding,
|
|
42
|
+
Gateway,
|
|
43
|
+
McpServer,
|
|
44
|
+
McpTransport,
|
|
45
|
+
Model,
|
|
46
|
+
NamedModel,
|
|
47
|
+
ObjectStore,
|
|
48
|
+
Platform,
|
|
49
|
+
Postgres,
|
|
50
|
+
Provider,
|
|
51
|
+
Qdrant,
|
|
52
|
+
Queue,
|
|
53
|
+
Redis,
|
|
54
|
+
Resource,
|
|
55
|
+
Secret,
|
|
56
|
+
Service,
|
|
57
|
+
SessionStore,
|
|
58
|
+
Skill,
|
|
59
|
+
SkillRequirements,
|
|
60
|
+
SlackChannel,
|
|
61
|
+
Sqlite,
|
|
62
|
+
VectorStore,
|
|
63
|
+
Workspace,
|
|
64
|
+
WorkspaceType,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# Loader
|
|
68
|
+
from vystak.schema.loader import dump_agent, load_agent
|
|
69
|
+
|
|
70
|
+
__all__ = [
|
|
71
|
+
"__version__",
|
|
72
|
+
"Agent",
|
|
73
|
+
"Cache",
|
|
74
|
+
"Channel",
|
|
75
|
+
"ChannelProvider",
|
|
76
|
+
"ChannelType",
|
|
77
|
+
"Database",
|
|
78
|
+
"Embedding",
|
|
79
|
+
"Gateway",
|
|
80
|
+
"McpServer",
|
|
81
|
+
"McpTransport",
|
|
82
|
+
"Model",
|
|
83
|
+
"NamedModel",
|
|
84
|
+
"ObjectStore",
|
|
85
|
+
"Platform",
|
|
86
|
+
"Postgres",
|
|
87
|
+
"Provider",
|
|
88
|
+
"Qdrant",
|
|
89
|
+
"Queue",
|
|
90
|
+
"Redis",
|
|
91
|
+
"Resource",
|
|
92
|
+
"Secret",
|
|
93
|
+
"Service",
|
|
94
|
+
"SessionStore",
|
|
95
|
+
"Skill",
|
|
96
|
+
"SkillRequirements",
|
|
97
|
+
"SlackChannel",
|
|
98
|
+
"Sqlite",
|
|
99
|
+
"VectorStore",
|
|
100
|
+
"Workspace",
|
|
101
|
+
"WorkspaceType",
|
|
102
|
+
"AgentHashTree",
|
|
103
|
+
"hash_agent",
|
|
104
|
+
"hash_dict",
|
|
105
|
+
"hash_model",
|
|
106
|
+
"dump_agent",
|
|
107
|
+
"load_agent",
|
|
108
|
+
"AgentStatus",
|
|
109
|
+
"ChannelAdapter",
|
|
110
|
+
"DeployPlan",
|
|
111
|
+
"DeployResult",
|
|
112
|
+
"FrameworkAdapter",
|
|
113
|
+
"GeneratedCode",
|
|
114
|
+
"PlatformProvider",
|
|
115
|
+
"ValidationError",
|
|
116
|
+
"CommandHealthCheck",
|
|
117
|
+
"CycleError",
|
|
118
|
+
"HealthCheck",
|
|
119
|
+
"HttpHealthCheck",
|
|
120
|
+
"NoopHealthCheck",
|
|
121
|
+
"Provisionable",
|
|
122
|
+
"ProvisionError",
|
|
123
|
+
"ProvisionGraph",
|
|
124
|
+
"ProvisionResult",
|
|
125
|
+
"TcpHealthCheck",
|
|
126
|
+
]
|
vystak/hash/__init__.py
ADDED
vystak/hash/hasher.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Leaf-level hashing for Pydantic models and dicts."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def hash_model(model: BaseModel) -> str:
|
|
10
|
+
"""SHA-256 of canonical JSON representation of a Pydantic model."""
|
|
11
|
+
canonical = json.dumps(model.model_dump(mode="python"), sort_keys=True, default=str)
|
|
12
|
+
return hashlib.sha256(canonical.encode()).hexdigest()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def hash_dict(data: dict) -> str:
|
|
16
|
+
"""SHA-256 of canonical JSON representation of a dict."""
|
|
17
|
+
canonical = json.dumps(data, sort_keys=True, default=str)
|
|
18
|
+
return hashlib.sha256(canonical.encode()).hexdigest()
|
vystak/hash/tree.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Hash tree composition for agent definitions."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from vystak.hash.hasher import hash_model
|
|
7
|
+
from vystak.schema.agent import Agent
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class AgentHashTree:
|
|
12
|
+
"""Per-section hashes for an agent, enabling partial deploy detection."""
|
|
13
|
+
|
|
14
|
+
brain: str
|
|
15
|
+
skills: str
|
|
16
|
+
mcp_servers: str
|
|
17
|
+
channels: str
|
|
18
|
+
workspace: str
|
|
19
|
+
resources: str
|
|
20
|
+
secrets: str
|
|
21
|
+
sessions: str
|
|
22
|
+
memory: str
|
|
23
|
+
services: str
|
|
24
|
+
root: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _hash_list(items: list) -> str:
|
|
28
|
+
if not items:
|
|
29
|
+
return hashlib.sha256(b"[]").hexdigest()
|
|
30
|
+
individual = sorted(hash_model(item) for item in items)
|
|
31
|
+
combined = "|".join(individual)
|
|
32
|
+
return hashlib.sha256(combined.encode()).hexdigest()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _hash_optional(item) -> str:
|
|
36
|
+
if item is None:
|
|
37
|
+
return hashlib.sha256(b"null").hexdigest()
|
|
38
|
+
return hash_model(item)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def hash_agent(agent: Agent) -> AgentHashTree:
|
|
42
|
+
"""Compute the full hash tree for an agent definition."""
|
|
43
|
+
brain = hash_model(agent.model)
|
|
44
|
+
skills = _hash_list(agent.skills)
|
|
45
|
+
mcp_servers = _hash_list(agent.mcp_servers)
|
|
46
|
+
channels = _hash_list(agent.channels)
|
|
47
|
+
workspace = _hash_optional(agent.workspace)
|
|
48
|
+
resources = _hash_list(agent.resources)
|
|
49
|
+
secrets = _hash_list(agent.secrets)
|
|
50
|
+
sessions = _hash_optional(agent.sessions)
|
|
51
|
+
memory = _hash_optional(agent.memory)
|
|
52
|
+
services = _hash_list(agent.services)
|
|
53
|
+
|
|
54
|
+
sections = "|".join(
|
|
55
|
+
[
|
|
56
|
+
brain,
|
|
57
|
+
skills,
|
|
58
|
+
mcp_servers,
|
|
59
|
+
channels,
|
|
60
|
+
workspace,
|
|
61
|
+
resources,
|
|
62
|
+
secrets,
|
|
63
|
+
sessions,
|
|
64
|
+
memory,
|
|
65
|
+
services,
|
|
66
|
+
]
|
|
67
|
+
)
|
|
68
|
+
root = hashlib.sha256(sections.encode()).hexdigest()
|
|
69
|
+
|
|
70
|
+
return AgentHashTree(
|
|
71
|
+
brain=brain,
|
|
72
|
+
skills=skills,
|
|
73
|
+
mcp_servers=mcp_servers,
|
|
74
|
+
channels=channels,
|
|
75
|
+
workspace=workspace,
|
|
76
|
+
resources=resources,
|
|
77
|
+
secrets=secrets,
|
|
78
|
+
sessions=sessions,
|
|
79
|
+
memory=memory,
|
|
80
|
+
services=services,
|
|
81
|
+
root=root,
|
|
82
|
+
)
|
vystak/ir/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Intermediate representation consumed by framework adapters."""
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Provider base classes for platform and resource provisioning."""
|
|
2
|
+
|
|
3
|
+
from vystak.providers.base import (
|
|
4
|
+
AgentStatus,
|
|
5
|
+
ChannelAdapter,
|
|
6
|
+
DeployPlan,
|
|
7
|
+
DeployResult,
|
|
8
|
+
FrameworkAdapter,
|
|
9
|
+
GeneratedCode,
|
|
10
|
+
PlatformProvider,
|
|
11
|
+
ValidationError,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"AgentStatus",
|
|
16
|
+
"ChannelAdapter",
|
|
17
|
+
"DeployPlan",
|
|
18
|
+
"DeployResult",
|
|
19
|
+
"FrameworkAdapter",
|
|
20
|
+
"GeneratedCode",
|
|
21
|
+
"PlatformProvider",
|
|
22
|
+
"ValidationError",
|
|
23
|
+
]
|
vystak/providers/base.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Abstract base classes for framework adapters, platform providers, and channel adapters."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
|
|
6
|
+
from vystak.schema.agent import Agent
|
|
7
|
+
from vystak.schema.channel import Channel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class GeneratedCode:
|
|
12
|
+
files: dict[str, str]
|
|
13
|
+
entrypoint: str
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class DeployPlan:
|
|
18
|
+
agent_name: str
|
|
19
|
+
actions: list[str]
|
|
20
|
+
current_hash: str | None
|
|
21
|
+
target_hash: str
|
|
22
|
+
changes: dict[str, tuple[str | None, str]]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class DeployResult:
|
|
27
|
+
agent_name: str
|
|
28
|
+
success: bool
|
|
29
|
+
hash: str
|
|
30
|
+
message: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class AgentStatus:
|
|
35
|
+
agent_name: str
|
|
36
|
+
running: bool
|
|
37
|
+
hash: str | None
|
|
38
|
+
info: dict = field(default_factory=dict)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class ValidationError:
|
|
43
|
+
field: str
|
|
44
|
+
message: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class FrameworkAdapter(ABC):
|
|
48
|
+
@abstractmethod
|
|
49
|
+
def generate(self, agent: Agent) -> GeneratedCode: ...
|
|
50
|
+
|
|
51
|
+
@abstractmethod
|
|
52
|
+
def validate(self, agent: Agent) -> list[ValidationError]: ...
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class PlatformProvider(ABC):
|
|
56
|
+
@abstractmethod
|
|
57
|
+
def plan(self, agent: Agent, current_hash: str | None) -> DeployPlan: ...
|
|
58
|
+
|
|
59
|
+
@abstractmethod
|
|
60
|
+
def apply(self, plan: DeployPlan) -> DeployResult: ...
|
|
61
|
+
|
|
62
|
+
@abstractmethod
|
|
63
|
+
def destroy(self, agent_name: str) -> None: ...
|
|
64
|
+
|
|
65
|
+
@abstractmethod
|
|
66
|
+
def status(self, agent_name: str) -> AgentStatus: ...
|
|
67
|
+
|
|
68
|
+
@abstractmethod
|
|
69
|
+
def get_hash(self, agent_name: str) -> str | None: ...
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ChannelAdapter(ABC):
|
|
73
|
+
@abstractmethod
|
|
74
|
+
def setup(self, agent: Agent, channel: Channel) -> None: ...
|
|
75
|
+
|
|
76
|
+
@abstractmethod
|
|
77
|
+
def teardown(self, channel: Channel) -> None: ...
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Provisioning engine — dependency graph, health checks, and provisionable protocol."""
|
|
2
|
+
|
|
3
|
+
from vystak.provisioning.graph import CycleError, ProvisionError, ProvisionGraph
|
|
4
|
+
from vystak.provisioning.grouping import group_agents_by_platform, platform_fingerprint
|
|
5
|
+
from vystak.provisioning.health import (
|
|
6
|
+
CommandHealthCheck,
|
|
7
|
+
HealthCheck,
|
|
8
|
+
HttpHealthCheck,
|
|
9
|
+
NoopHealthCheck,
|
|
10
|
+
TcpHealthCheck,
|
|
11
|
+
)
|
|
12
|
+
from vystak.provisioning.listener import (
|
|
13
|
+
NullListener,
|
|
14
|
+
PrintListener,
|
|
15
|
+
ProvisionEvent,
|
|
16
|
+
ProvisionListener,
|
|
17
|
+
)
|
|
18
|
+
from vystak.provisioning.node import Provisionable, ProvisionResult
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"CommandHealthCheck",
|
|
22
|
+
"CycleError",
|
|
23
|
+
"group_agents_by_platform",
|
|
24
|
+
"HealthCheck",
|
|
25
|
+
"HttpHealthCheck",
|
|
26
|
+
"NoopHealthCheck",
|
|
27
|
+
"NullListener",
|
|
28
|
+
"platform_fingerprint",
|
|
29
|
+
"PrintListener",
|
|
30
|
+
"Provisionable",
|
|
31
|
+
"ProvisionError",
|
|
32
|
+
"ProvisionEvent",
|
|
33
|
+
"ProvisionGraph",
|
|
34
|
+
"ProvisionListener",
|
|
35
|
+
"ProvisionResult",
|
|
36
|
+
"TcpHealthCheck",
|
|
37
|
+
]
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Dependency graph for provisioning resources."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
from vystak.provisioning.listener import NullListener, ProvisionEvent, ProvisionListener
|
|
6
|
+
from vystak.provisioning.node import Provisionable, ProvisionResult
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CycleError(Exception):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ProvisionError(Exception):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class ProvisionGraph:
|
|
19
|
+
_nodes: dict[str, Provisionable] = field(default_factory=dict)
|
|
20
|
+
_implicit_deps: dict[str, list[str]] = field(default_factory=dict)
|
|
21
|
+
_listener: ProvisionListener = field(default_factory=NullListener)
|
|
22
|
+
|
|
23
|
+
def set_listener(self, listener: ProvisionListener) -> None:
|
|
24
|
+
"""Set the event listener for provision progress."""
|
|
25
|
+
self._listener = listener
|
|
26
|
+
|
|
27
|
+
def add(self, node: Provisionable) -> None:
|
|
28
|
+
self._nodes[node.name] = node
|
|
29
|
+
|
|
30
|
+
def add_dependency(self, name: str, depends_on: str) -> None:
|
|
31
|
+
self._implicit_deps.setdefault(name, []).append(depends_on)
|
|
32
|
+
|
|
33
|
+
def _all_deps(self, name: str) -> list[str]:
|
|
34
|
+
node = self._nodes[name]
|
|
35
|
+
all_deps = list(node.depends_on)
|
|
36
|
+
all_deps.extend(self._implicit_deps.get(name, []))
|
|
37
|
+
return [d for d in all_deps if d in self._nodes]
|
|
38
|
+
|
|
39
|
+
def _resolve_order(self) -> list[str]:
|
|
40
|
+
reverse: dict[str, list[str]] = {name: [] for name in self._nodes}
|
|
41
|
+
in_degree: dict[str, int] = {name: 0 for name in self._nodes}
|
|
42
|
+
|
|
43
|
+
for name in self._nodes:
|
|
44
|
+
deps = self._all_deps(name)
|
|
45
|
+
in_degree[name] = len(deps)
|
|
46
|
+
for dep in deps:
|
|
47
|
+
reverse[dep].append(name)
|
|
48
|
+
|
|
49
|
+
queue = [n for n, d in in_degree.items() if d == 0]
|
|
50
|
+
order: list[str] = []
|
|
51
|
+
|
|
52
|
+
while queue:
|
|
53
|
+
node = queue.pop(0)
|
|
54
|
+
order.append(node)
|
|
55
|
+
for dependent in reverse[node]:
|
|
56
|
+
in_degree[dependent] -= 1
|
|
57
|
+
if in_degree[dependent] == 0:
|
|
58
|
+
queue.append(dependent)
|
|
59
|
+
|
|
60
|
+
if len(order) != len(self._nodes):
|
|
61
|
+
raise CycleError("Dependency cycle detected in provision graph")
|
|
62
|
+
|
|
63
|
+
return order
|
|
64
|
+
|
|
65
|
+
def execute(self) -> dict[str, ProvisionResult]:
|
|
66
|
+
order = self._resolve_order()
|
|
67
|
+
results: dict[str, ProvisionResult] = {}
|
|
68
|
+
|
|
69
|
+
for name in order:
|
|
70
|
+
node = self._nodes[name]
|
|
71
|
+
|
|
72
|
+
self._listener.on_start(
|
|
73
|
+
ProvisionEvent(
|
|
74
|
+
node_name=name,
|
|
75
|
+
event_type="start",
|
|
76
|
+
message=name,
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# Pass listener to node if it supports it
|
|
81
|
+
if hasattr(node, "set_listener"):
|
|
82
|
+
node.set_listener(self._listener)
|
|
83
|
+
|
|
84
|
+
result = node.provision(context=results)
|
|
85
|
+
results[name] = result
|
|
86
|
+
|
|
87
|
+
if not result.success:
|
|
88
|
+
self._listener.on_error(
|
|
89
|
+
ProvisionEvent(
|
|
90
|
+
node_name=name,
|
|
91
|
+
event_type="error",
|
|
92
|
+
message=name,
|
|
93
|
+
detail=result.error or "",
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
raise ProvisionError(f"Failed to provision {name}: {result.error}")
|
|
97
|
+
|
|
98
|
+
self._listener.on_complete(
|
|
99
|
+
ProvisionEvent(
|
|
100
|
+
node_name=name,
|
|
101
|
+
event_type="complete",
|
|
102
|
+
message=name,
|
|
103
|
+
detail=result.info.get("detail", ""),
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
check = node.health_check()
|
|
108
|
+
self._listener.on_health_check(
|
|
109
|
+
ProvisionEvent(
|
|
110
|
+
node_name=name,
|
|
111
|
+
event_type="health_check",
|
|
112
|
+
message=f"Waiting for {name}",
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
check.wait()
|
|
116
|
+
|
|
117
|
+
return results
|
|
118
|
+
|
|
119
|
+
def destroy_all(self) -> None:
|
|
120
|
+
order = self._resolve_order()
|
|
121
|
+
for name in reversed(order):
|
|
122
|
+
self._nodes[name].destroy()
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Group agents by platform fingerprint for shared infrastructure."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
from vystak.schema.agent import Agent
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def platform_fingerprint(agent: Agent) -> str:
|
|
10
|
+
if agent.platform is None:
|
|
11
|
+
return "docker:default"
|
|
12
|
+
key = {
|
|
13
|
+
"provider_type": agent.platform.provider.type,
|
|
14
|
+
"provider_config": agent.platform.provider.config,
|
|
15
|
+
"platform_type": agent.platform.type,
|
|
16
|
+
}
|
|
17
|
+
canonical = json.dumps(key, sort_keys=True, default=str)
|
|
18
|
+
return hashlib.md5(canonical.encode()).hexdigest()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def group_agents_by_platform(agents: list[Agent]) -> dict[str, list[Agent]]:
|
|
22
|
+
groups: dict[str, list[Agent]] = {}
|
|
23
|
+
for agent in agents:
|
|
24
|
+
fp = platform_fingerprint(agent)
|
|
25
|
+
groups.setdefault(fp, []).append(agent)
|
|
26
|
+
return groups
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Health check classes for resource readiness verification."""
|
|
2
|
+
|
|
3
|
+
import socket
|
|
4
|
+
import time
|
|
5
|
+
import urllib.request
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class HealthCheck(ABC):
|
|
10
|
+
@abstractmethod
|
|
11
|
+
def check(self) -> bool: ...
|
|
12
|
+
|
|
13
|
+
def wait(self, timeout: int = 60, interval: float = 1.0) -> None:
|
|
14
|
+
deadline = time.time() + timeout
|
|
15
|
+
while time.time() < deadline:
|
|
16
|
+
if self.check():
|
|
17
|
+
return
|
|
18
|
+
time.sleep(interval)
|
|
19
|
+
raise TimeoutError(f"Health check did not pass within {timeout}s")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class NoopHealthCheck(HealthCheck):
|
|
23
|
+
def check(self) -> bool:
|
|
24
|
+
return True
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TcpHealthCheck(HealthCheck):
|
|
28
|
+
def __init__(self, host: str, port: int):
|
|
29
|
+
self.host = host
|
|
30
|
+
self.port = port
|
|
31
|
+
|
|
32
|
+
def check(self) -> bool:
|
|
33
|
+
try:
|
|
34
|
+
with socket.create_connection((self.host, self.port), timeout=2):
|
|
35
|
+
return True
|
|
36
|
+
except (TimeoutError, ConnectionRefusedError, OSError):
|
|
37
|
+
return False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class CommandHealthCheck(HealthCheck):
|
|
41
|
+
def __init__(self, container, command: list[str]):
|
|
42
|
+
self.container = container
|
|
43
|
+
self.command = command
|
|
44
|
+
|
|
45
|
+
def check(self) -> bool:
|
|
46
|
+
try:
|
|
47
|
+
result = self.container.exec_run(self.command, demux=False)
|
|
48
|
+
return result.exit_code == 0
|
|
49
|
+
except Exception:
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class HttpHealthCheck(HealthCheck):
|
|
54
|
+
def __init__(self, url: str):
|
|
55
|
+
self.url = url
|
|
56
|
+
|
|
57
|
+
def check(self) -> bool:
|
|
58
|
+
try:
|
|
59
|
+
resp = urllib.request.urlopen(self.url, timeout=2)
|
|
60
|
+
return resp.status == 200
|
|
61
|
+
except Exception:
|
|
62
|
+
return False
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Provision event listener — callbacks for graph execution progress."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class ProvisionEvent:
|
|
8
|
+
"""An event emitted during provisioning."""
|
|
9
|
+
|
|
10
|
+
node_name: str
|
|
11
|
+
event_type: str # "start", "step", "complete", "error", "health_check"
|
|
12
|
+
message: str = ""
|
|
13
|
+
detail: str = ""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ProvisionListener:
|
|
17
|
+
"""Base listener for provision graph events. Override methods to handle events."""
|
|
18
|
+
|
|
19
|
+
def on_start(self, event: ProvisionEvent) -> None:
|
|
20
|
+
"""Called before a node starts provisioning."""
|
|
21
|
+
|
|
22
|
+
def on_step(self, event: ProvisionEvent) -> None:
|
|
23
|
+
"""Called during provisioning for sub-step progress (build, push, etc.)."""
|
|
24
|
+
|
|
25
|
+
def on_complete(self, event: ProvisionEvent) -> None:
|
|
26
|
+
"""Called after a node finishes provisioning successfully."""
|
|
27
|
+
|
|
28
|
+
def on_error(self, event: ProvisionEvent) -> None:
|
|
29
|
+
"""Called when a node fails to provision."""
|
|
30
|
+
|
|
31
|
+
def on_health_check(self, event: ProvisionEvent) -> None:
|
|
32
|
+
"""Called when waiting for a health check."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class PrintListener(ProvisionListener):
|
|
36
|
+
"""Prints provision events to stdout with indentation."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, indent: str = " "):
|
|
39
|
+
self._indent = indent
|
|
40
|
+
|
|
41
|
+
def on_start(self, event: ProvisionEvent) -> None:
|
|
42
|
+
print(f"{self._indent}{event.message}... ", end="", flush=True)
|
|
43
|
+
|
|
44
|
+
def on_step(self, event: ProvisionEvent) -> None:
|
|
45
|
+
if event.detail:
|
|
46
|
+
print(f"\n{self._indent}{event.message}: {event.detail}... ", end="", flush=True)
|
|
47
|
+
else:
|
|
48
|
+
print(f"\n{self._indent}{event.message}... ", end="", flush=True)
|
|
49
|
+
|
|
50
|
+
def on_complete(self, event: ProvisionEvent) -> None:
|
|
51
|
+
if event.detail:
|
|
52
|
+
print(f"OK ({event.detail})")
|
|
53
|
+
else:
|
|
54
|
+
print("OK")
|
|
55
|
+
|
|
56
|
+
def on_error(self, event: ProvisionEvent) -> None:
|
|
57
|
+
print("FAILED")
|
|
58
|
+
if event.detail:
|
|
59
|
+
print(f"{self._indent} Error: {event.detail}")
|
|
60
|
+
|
|
61
|
+
def on_health_check(self, event: ProvisionEvent) -> None:
|
|
62
|
+
pass # silent by default
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class NullListener(ProvisionListener):
|
|
66
|
+
"""Does nothing. Used when no listener is provided."""
|
|
67
|
+
|
|
68
|
+
pass
|