soorma-core 0.3.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.
- soorma/__init__.py +138 -0
- soorma/agents/__init__.py +17 -0
- soorma/agents/base.py +523 -0
- soorma/agents/planner.py +391 -0
- soorma/agents/tool.py +373 -0
- soorma/agents/worker.py +385 -0
- soorma/ai/event_toolkit.py +281 -0
- soorma/ai/tools.py +280 -0
- soorma/cli/__init__.py +7 -0
- soorma/cli/commands/__init__.py +3 -0
- soorma/cli/commands/dev.py +780 -0
- soorma/cli/commands/init.py +717 -0
- soorma/cli/main.py +52 -0
- soorma/context.py +832 -0
- soorma/events.py +496 -0
- soorma/models.py +24 -0
- soorma/registry/client.py +186 -0
- soorma/utils/schema_utils.py +209 -0
- soorma_core-0.3.0.dist-info/METADATA +454 -0
- soorma_core-0.3.0.dist-info/RECORD +23 -0
- soorma_core-0.3.0.dist-info/WHEEL +4 -0
- soorma_core-0.3.0.dist-info/entry_points.txt +3 -0
- soorma_core-0.3.0.dist-info/licenses/LICENSE.txt +21 -0
|
@@ -0,0 +1,780 @@
|
|
|
1
|
+
"""
|
|
2
|
+
soorma dev - Start local development environment.
|
|
3
|
+
|
|
4
|
+
Implements the "Infra in Docker, Code on Host" pattern:
|
|
5
|
+
- Infrastructure (Registry, NATS) runs in Docker containers
|
|
6
|
+
- User's agent code runs natively on the host with hot reload
|
|
7
|
+
- Environment variables injected for connectivity
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
import subprocess
|
|
14
|
+
import shutil
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Optional, List
|
|
17
|
+
|
|
18
|
+
import typer
|
|
19
|
+
|
|
20
|
+
# Docker Compose template for local development infrastructure
|
|
21
|
+
DOCKER_COMPOSE_TEMPLATE = '''# Soorma Local Development Stack
|
|
22
|
+
# Generated by: soorma dev
|
|
23
|
+
# Infrastructure runs in Docker, your agent runs on the host
|
|
24
|
+
|
|
25
|
+
services:
|
|
26
|
+
# NATS - Event Bus
|
|
27
|
+
nats:
|
|
28
|
+
image: nats:2.10-alpine
|
|
29
|
+
container_name: soorma-nats
|
|
30
|
+
ports:
|
|
31
|
+
- "${NATS_PORT:-4222}:4222" # Client connections
|
|
32
|
+
- "${NATS_HTTP_PORT:-8222}:8222" # HTTP monitoring
|
|
33
|
+
command: ["--jetstream", "--http_port", "8222"]
|
|
34
|
+
healthcheck:
|
|
35
|
+
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8222/healthz"]
|
|
36
|
+
interval: 5s
|
|
37
|
+
timeout: 3s
|
|
38
|
+
retries: 3
|
|
39
|
+
|
|
40
|
+
# Registry Service - Agent & Event Registration
|
|
41
|
+
# Uses local image if available, falls back to public when published
|
|
42
|
+
registry:
|
|
43
|
+
image: ${REGISTRY_IMAGE:-registry-service:latest}
|
|
44
|
+
container_name: soorma-registry
|
|
45
|
+
ports:
|
|
46
|
+
- "${REGISTRY_PORT:-8081}:8000"
|
|
47
|
+
environment:
|
|
48
|
+
- DATABASE_URL=sqlite+aiosqlite:////tmp/registry.db
|
|
49
|
+
- SYNC_DATABASE_URL=sqlite:////tmp/registry.db
|
|
50
|
+
- NATS_URL=nats://nats:4222
|
|
51
|
+
depends_on:
|
|
52
|
+
nats:
|
|
53
|
+
condition: service_healthy
|
|
54
|
+
healthcheck:
|
|
55
|
+
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
|
56
|
+
interval: 10s
|
|
57
|
+
timeout: 5s
|
|
58
|
+
retries: 5
|
|
59
|
+
start_period: 10s
|
|
60
|
+
|
|
61
|
+
# Event Service - PubSub Proxy (SSE + REST)
|
|
62
|
+
event-service:
|
|
63
|
+
image: ${EVENT_SERVICE_IMAGE:-event-service:latest}
|
|
64
|
+
container_name: soorma-event-service
|
|
65
|
+
ports:
|
|
66
|
+
- "${EVENT_SERVICE_PORT:-8082}:8082"
|
|
67
|
+
environment:
|
|
68
|
+
- EVENT_ADAPTER=nats
|
|
69
|
+
- NATS_URL=nats://nats:4222
|
|
70
|
+
- DEBUG=false
|
|
71
|
+
depends_on:
|
|
72
|
+
nats:
|
|
73
|
+
condition: service_healthy
|
|
74
|
+
healthcheck:
|
|
75
|
+
test: ["CMD", "curl", "-f", "http://localhost:8082/health"]
|
|
76
|
+
interval: 10s
|
|
77
|
+
timeout: 5s
|
|
78
|
+
retries: 5
|
|
79
|
+
start_period: 5s
|
|
80
|
+
|
|
81
|
+
networks:
|
|
82
|
+
default:
|
|
83
|
+
name: soorma-dev
|
|
84
|
+
'''
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def check_docker() -> str:
|
|
88
|
+
"""Check if Docker is available and running. Returns compose command."""
|
|
89
|
+
# Check if docker command exists
|
|
90
|
+
if not shutil.which("docker"):
|
|
91
|
+
typer.echo("❌ Error: Docker is not installed.", err=True)
|
|
92
|
+
typer.echo("Please install Docker: https://docs.docker.com/get-docker/", err=True)
|
|
93
|
+
raise typer.Exit(1)
|
|
94
|
+
|
|
95
|
+
# Check if Docker daemon is running
|
|
96
|
+
try:
|
|
97
|
+
result = subprocess.run(
|
|
98
|
+
["docker", "info"],
|
|
99
|
+
capture_output=True,
|
|
100
|
+
text=True,
|
|
101
|
+
timeout=10,
|
|
102
|
+
)
|
|
103
|
+
if result.returncode != 0:
|
|
104
|
+
typer.echo("❌ Error: Docker daemon is not running.", err=True)
|
|
105
|
+
typer.echo("Please start Docker and try again.", err=True)
|
|
106
|
+
raise typer.Exit(1)
|
|
107
|
+
except subprocess.TimeoutExpired:
|
|
108
|
+
typer.echo("❌ Error: Docker is not responding.", err=True)
|
|
109
|
+
raise typer.Exit(1)
|
|
110
|
+
|
|
111
|
+
# Check if docker compose is available
|
|
112
|
+
try:
|
|
113
|
+
result = subprocess.run(
|
|
114
|
+
["docker", "compose", "version"],
|
|
115
|
+
capture_output=True,
|
|
116
|
+
text=True,
|
|
117
|
+
timeout=5,
|
|
118
|
+
)
|
|
119
|
+
if result.returncode != 0:
|
|
120
|
+
if shutil.which("docker-compose"):
|
|
121
|
+
return "docker-compose"
|
|
122
|
+
typer.echo("❌ Error: Docker Compose is not available.", err=True)
|
|
123
|
+
raise typer.Exit(1)
|
|
124
|
+
except subprocess.TimeoutExpired:
|
|
125
|
+
typer.echo("❌ Error: Docker Compose is not responding.", err=True)
|
|
126
|
+
raise typer.Exit(1)
|
|
127
|
+
|
|
128
|
+
return "docker compose"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# Service definitions for the dev stack
|
|
132
|
+
# Each service has: local_image, public_image, dockerfile (relative to soorma-core root)
|
|
133
|
+
# SOORMA_CORE_PATH should point to soorma-core root (soorma-platform/core/ in monorepo)
|
|
134
|
+
SERVICE_DEFINITIONS = {
|
|
135
|
+
"registry": {
|
|
136
|
+
"local_image": "registry-service:latest",
|
|
137
|
+
"public_image": "ghcr.io/soorma-ai/registry-service:latest",
|
|
138
|
+
"dockerfile": "services/registry/Dockerfile",
|
|
139
|
+
"name": "Registry Service",
|
|
140
|
+
},
|
|
141
|
+
"event-service": {
|
|
142
|
+
"local_image": "event-service:latest",
|
|
143
|
+
"public_image": "ghcr.io/soorma-ai/event-service:latest",
|
|
144
|
+
"dockerfile": "services/event-service/Dockerfile",
|
|
145
|
+
"name": "Event Service",
|
|
146
|
+
},
|
|
147
|
+
# Future services can be added here:
|
|
148
|
+
# "tracker": {
|
|
149
|
+
# "local_image": "tracker-service:latest",
|
|
150
|
+
# "public_image": "ghcr.io/soorma-ai/tracker-service:latest",
|
|
151
|
+
# "dockerfile": "services/tracker/Dockerfile",
|
|
152
|
+
# "name": "State Tracker",
|
|
153
|
+
# },
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def check_service_image(service_key: str) -> Optional[str]:
|
|
158
|
+
"""
|
|
159
|
+
Check for available service image.
|
|
160
|
+
|
|
161
|
+
Returns the image name to use, or None if not found.
|
|
162
|
+
Priority:
|
|
163
|
+
1. Local image
|
|
164
|
+
2. Public image (ghcr.io)
|
|
165
|
+
"""
|
|
166
|
+
service = SERVICE_DEFINITIONS.get(service_key)
|
|
167
|
+
if not service:
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
# Check for local image
|
|
171
|
+
result = subprocess.run(
|
|
172
|
+
["docker", "images", "-q", service["local_image"]],
|
|
173
|
+
capture_output=True,
|
|
174
|
+
text=True,
|
|
175
|
+
)
|
|
176
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
177
|
+
return service["local_image"]
|
|
178
|
+
|
|
179
|
+
# Check for public image (will fail if not published yet)
|
|
180
|
+
result = subprocess.run(
|
|
181
|
+
["docker", "manifest", "inspect", service["public_image"]],
|
|
182
|
+
capture_output=True,
|
|
183
|
+
text=True,
|
|
184
|
+
)
|
|
185
|
+
if result.returncode == 0:
|
|
186
|
+
return service["public_image"]
|
|
187
|
+
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def find_soorma_core_root() -> Optional[Path]:
|
|
192
|
+
"""
|
|
193
|
+
Find the soorma-core repository root.
|
|
194
|
+
|
|
195
|
+
Searches SOORMA_CORE_PATH env var and common locations.
|
|
196
|
+
The root should have services/ and libs/ directories directly.
|
|
197
|
+
|
|
198
|
+
For soorma-platform monorepo users, set:
|
|
199
|
+
export SOORMA_CORE_PATH=/path/to/soorma-platform/core
|
|
200
|
+
"""
|
|
201
|
+
search_paths = []
|
|
202
|
+
|
|
203
|
+
# Check SOORMA_CORE_PATH env var first (highest priority)
|
|
204
|
+
env_path = os.environ.get("SOORMA_CORE_PATH")
|
|
205
|
+
if env_path:
|
|
206
|
+
search_paths.append(Path(env_path))
|
|
207
|
+
|
|
208
|
+
# Common locations for standalone soorma-core repo
|
|
209
|
+
search_paths.extend([
|
|
210
|
+
Path.home() / "ws" / "github" / "soorma-ai" / "soorma-core",
|
|
211
|
+
Path.home() / "code" / "soorma-core",
|
|
212
|
+
Path.home() / "projects" / "soorma-core",
|
|
213
|
+
Path.home() / "soorma-core",
|
|
214
|
+
Path.cwd().parent / "soorma-core",
|
|
215
|
+
Path.cwd().parent.parent / "soorma-core",
|
|
216
|
+
])
|
|
217
|
+
|
|
218
|
+
for path in search_paths:
|
|
219
|
+
# Verify it's the right repo by checking for services/ directory
|
|
220
|
+
if (path / "services").exists() and (path / "libs").exists():
|
|
221
|
+
return path
|
|
222
|
+
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def build_service_image(service_key: str, soorma_core_root: Path) -> bool:
|
|
227
|
+
"""
|
|
228
|
+
Build a service image from source.
|
|
229
|
+
|
|
230
|
+
Returns True if build succeeded.
|
|
231
|
+
"""
|
|
232
|
+
service = SERVICE_DEFINITIONS.get(service_key)
|
|
233
|
+
if not service:
|
|
234
|
+
return False
|
|
235
|
+
|
|
236
|
+
dockerfile = soorma_core_root / service["dockerfile"]
|
|
237
|
+
if not dockerfile.exists():
|
|
238
|
+
typer.echo(f" ⚠️ Dockerfile not found: {service['dockerfile']}", err=True)
|
|
239
|
+
return False
|
|
240
|
+
|
|
241
|
+
typer.echo(f" Building {service['name']}...")
|
|
242
|
+
|
|
243
|
+
result = subprocess.run(
|
|
244
|
+
["docker", "build", "-f", service["dockerfile"], "-t", service["local_image"], "."],
|
|
245
|
+
cwd=soorma_core_root,
|
|
246
|
+
capture_output=False, # Show build output
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
return result.returncode == 0
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def build_all_services(soorma_core_root: Path) -> dict:
|
|
253
|
+
"""
|
|
254
|
+
Build all service images from source.
|
|
255
|
+
|
|
256
|
+
Returns dict of {service_key: success_bool}.
|
|
257
|
+
"""
|
|
258
|
+
results = {}
|
|
259
|
+
for service_key in SERVICE_DEFINITIONS:
|
|
260
|
+
results[service_key] = build_service_image(service_key, soorma_core_root)
|
|
261
|
+
return results
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def get_soorma_dir() -> Path:
|
|
265
|
+
"""Get or create the .soorma directory in the current project."""
|
|
266
|
+
soorma_dir = Path.cwd() / ".soorma"
|
|
267
|
+
soorma_dir.mkdir(exist_ok=True)
|
|
268
|
+
return soorma_dir
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def get_compose_cmd(compose_cmd: str, compose_file: Path) -> List[str]:
|
|
272
|
+
"""Build the base docker compose command."""
|
|
273
|
+
if " " in compose_cmd:
|
|
274
|
+
base_cmd = compose_cmd.split()
|
|
275
|
+
else:
|
|
276
|
+
base_cmd = [compose_cmd]
|
|
277
|
+
base_cmd.extend(["-f", str(compose_file)])
|
|
278
|
+
return base_cmd
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def find_agent_entry_point() -> Optional[Path]:
|
|
282
|
+
"""
|
|
283
|
+
Find the agent entry point in the current project.
|
|
284
|
+
|
|
285
|
+
Looks for (in order):
|
|
286
|
+
1. soorma.yaml config with entry point
|
|
287
|
+
2. agent.py in package directory
|
|
288
|
+
3. main.py
|
|
289
|
+
4. app.py
|
|
290
|
+
"""
|
|
291
|
+
cwd = Path.cwd()
|
|
292
|
+
|
|
293
|
+
# Check for soorma.yaml config
|
|
294
|
+
config_file = cwd / "soorma.yaml"
|
|
295
|
+
if config_file.exists():
|
|
296
|
+
try:
|
|
297
|
+
import yaml
|
|
298
|
+
with open(config_file) as f:
|
|
299
|
+
config = yaml.safe_load(f)
|
|
300
|
+
if config and "entry" in config:
|
|
301
|
+
entry = cwd / config["entry"]
|
|
302
|
+
if entry.exists():
|
|
303
|
+
return entry
|
|
304
|
+
except ImportError:
|
|
305
|
+
pass # yaml not installed, skip config
|
|
306
|
+
except Exception:
|
|
307
|
+
pass
|
|
308
|
+
|
|
309
|
+
# Look for package with agent.py
|
|
310
|
+
for item in cwd.iterdir():
|
|
311
|
+
if item.is_dir() and not item.name.startswith((".", "_")):
|
|
312
|
+
agent_file = item / "agent.py"
|
|
313
|
+
if agent_file.exists():
|
|
314
|
+
return agent_file
|
|
315
|
+
|
|
316
|
+
# Fallback to common entry points
|
|
317
|
+
for name in ["agent.py", "main.py", "app.py"]:
|
|
318
|
+
entry = cwd / name
|
|
319
|
+
if entry.exists():
|
|
320
|
+
return entry
|
|
321
|
+
|
|
322
|
+
return None
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def wait_for_infrastructure(registry_port: int, timeout: int = 60) -> bool:
|
|
326
|
+
"""Wait for infrastructure to be healthy."""
|
|
327
|
+
import urllib.request
|
|
328
|
+
import urllib.error
|
|
329
|
+
|
|
330
|
+
start = time.time()
|
|
331
|
+
registry_url = f"http://localhost:{registry_port}/health"
|
|
332
|
+
|
|
333
|
+
while time.time() - start < timeout:
|
|
334
|
+
try:
|
|
335
|
+
req = urllib.request.Request(registry_url, method="GET")
|
|
336
|
+
with urllib.request.urlopen(req, timeout=2) as resp:
|
|
337
|
+
if resp.status == 200:
|
|
338
|
+
return True
|
|
339
|
+
except Exception:
|
|
340
|
+
# Any error means the service isn't ready yet
|
|
341
|
+
pass
|
|
342
|
+
time.sleep(1)
|
|
343
|
+
|
|
344
|
+
return False
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
class AgentRunner:
|
|
348
|
+
"""
|
|
349
|
+
Runs the user's agent code with hot reload support.
|
|
350
|
+
|
|
351
|
+
Watches for file changes and restarts the agent process.
|
|
352
|
+
"""
|
|
353
|
+
|
|
354
|
+
def __init__(
|
|
355
|
+
self,
|
|
356
|
+
entry_point: Path,
|
|
357
|
+
registry_url: str,
|
|
358
|
+
event_service_url: str,
|
|
359
|
+
nats_url: str,
|
|
360
|
+
watch: bool = True,
|
|
361
|
+
):
|
|
362
|
+
self.entry_point = entry_point
|
|
363
|
+
self.registry_url = registry_url
|
|
364
|
+
self.event_service_url = event_service_url
|
|
365
|
+
self.nats_url = nats_url
|
|
366
|
+
self.watch = watch
|
|
367
|
+
self.process: Optional[subprocess.Popen] = None
|
|
368
|
+
self.running = False
|
|
369
|
+
self._file_mtimes: dict = {}
|
|
370
|
+
|
|
371
|
+
def _get_env(self) -> dict:
|
|
372
|
+
"""Get environment variables for the agent process."""
|
|
373
|
+
env = os.environ.copy()
|
|
374
|
+
env.update({
|
|
375
|
+
"SOORMA_REGISTRY_URL": self.registry_url,
|
|
376
|
+
"SOORMA_EVENT_SERVICE_URL": self.event_service_url,
|
|
377
|
+
"SOORMA_BUS_URL": self.nats_url,
|
|
378
|
+
"SOORMA_NATS_URL": self.nats_url,
|
|
379
|
+
"SOORMA_DEV_MODE": "true",
|
|
380
|
+
})
|
|
381
|
+
return env
|
|
382
|
+
|
|
383
|
+
def _get_watch_files(self) -> List[Path]:
|
|
384
|
+
"""Get list of Python files to watch for changes."""
|
|
385
|
+
files = []
|
|
386
|
+
cwd = Path.cwd()
|
|
387
|
+
|
|
388
|
+
# Watch all .py files in the project
|
|
389
|
+
for py_file in cwd.rglob("*.py"):
|
|
390
|
+
# Skip hidden dirs, venv, __pycache__, .soorma
|
|
391
|
+
parts = py_file.parts
|
|
392
|
+
if any(p.startswith(".") or p == "__pycache__" or p in ("venv", ".venv", "node_modules") for p in parts):
|
|
393
|
+
continue
|
|
394
|
+
files.append(py_file)
|
|
395
|
+
|
|
396
|
+
return files
|
|
397
|
+
|
|
398
|
+
def _check_for_changes(self) -> bool:
|
|
399
|
+
"""Check if any watched files have changed."""
|
|
400
|
+
changed = False
|
|
401
|
+
|
|
402
|
+
for filepath in self._get_watch_files():
|
|
403
|
+
try:
|
|
404
|
+
mtime = filepath.stat().st_mtime
|
|
405
|
+
if filepath in self._file_mtimes:
|
|
406
|
+
if mtime > self._file_mtimes[filepath]:
|
|
407
|
+
typer.echo(f" 📝 Changed: {filepath.relative_to(Path.cwd())}")
|
|
408
|
+
changed = True
|
|
409
|
+
self._file_mtimes[filepath] = mtime
|
|
410
|
+
except OSError:
|
|
411
|
+
pass
|
|
412
|
+
|
|
413
|
+
return changed
|
|
414
|
+
|
|
415
|
+
def _init_file_mtimes(self):
|
|
416
|
+
"""Initialize file modification times."""
|
|
417
|
+
self._file_mtimes = {}
|
|
418
|
+
for filepath in self._get_watch_files():
|
|
419
|
+
try:
|
|
420
|
+
self._file_mtimes[filepath] = filepath.stat().st_mtime
|
|
421
|
+
except OSError:
|
|
422
|
+
pass
|
|
423
|
+
|
|
424
|
+
def start_agent(self):
|
|
425
|
+
"""Start the agent process."""
|
|
426
|
+
if self.process and self.process.poll() is None:
|
|
427
|
+
self.stop_agent()
|
|
428
|
+
|
|
429
|
+
typer.echo(f" 🚀 Starting agent: {self.entry_point.name}")
|
|
430
|
+
|
|
431
|
+
self.process = subprocess.Popen(
|
|
432
|
+
[sys.executable, str(self.entry_point)],
|
|
433
|
+
env=self._get_env(),
|
|
434
|
+
cwd=Path.cwd(),
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
def stop_agent(self):
|
|
438
|
+
"""Stop the agent process."""
|
|
439
|
+
if self.process:
|
|
440
|
+
typer.echo(" ⏹️ Stopping agent...")
|
|
441
|
+
self.process.terminate()
|
|
442
|
+
try:
|
|
443
|
+
self.process.wait(timeout=5)
|
|
444
|
+
except subprocess.TimeoutExpired:
|
|
445
|
+
self.process.kill()
|
|
446
|
+
self.process.wait()
|
|
447
|
+
self.process = None
|
|
448
|
+
|
|
449
|
+
def restart_agent(self):
|
|
450
|
+
"""Restart the agent process (hot reload)."""
|
|
451
|
+
typer.echo("")
|
|
452
|
+
typer.echo(" 🔄 Hot reload triggered!")
|
|
453
|
+
self.stop_agent()
|
|
454
|
+
time.sleep(0.5) # Brief pause for cleanup
|
|
455
|
+
self.start_agent()
|
|
456
|
+
|
|
457
|
+
def run(self):
|
|
458
|
+
"""Run the agent with optional hot reload."""
|
|
459
|
+
self.running = True
|
|
460
|
+
self._init_file_mtimes()
|
|
461
|
+
self.start_agent()
|
|
462
|
+
|
|
463
|
+
if not self.watch:
|
|
464
|
+
# Just wait for the process
|
|
465
|
+
try:
|
|
466
|
+
self.process.wait()
|
|
467
|
+
except KeyboardInterrupt:
|
|
468
|
+
self.stop_agent()
|
|
469
|
+
return
|
|
470
|
+
|
|
471
|
+
# Watch for changes
|
|
472
|
+
typer.echo(" 👀 Watching for file changes...")
|
|
473
|
+
typer.echo("")
|
|
474
|
+
|
|
475
|
+
try:
|
|
476
|
+
while self.running:
|
|
477
|
+
# Check if process crashed
|
|
478
|
+
if self.process and self.process.poll() is not None:
|
|
479
|
+
exit_code = self.process.returncode
|
|
480
|
+
if exit_code != 0:
|
|
481
|
+
typer.echo(f" ⚠️ Agent exited with code {exit_code}")
|
|
482
|
+
typer.echo(" Waiting for file changes to restart...")
|
|
483
|
+
|
|
484
|
+
# Check for file changes
|
|
485
|
+
if self._check_for_changes():
|
|
486
|
+
self.restart_agent()
|
|
487
|
+
|
|
488
|
+
time.sleep(1) # Poll interval
|
|
489
|
+
|
|
490
|
+
except KeyboardInterrupt:
|
|
491
|
+
pass
|
|
492
|
+
finally:
|
|
493
|
+
self.stop_agent()
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def dev_stack(
|
|
497
|
+
detach: bool = typer.Option(
|
|
498
|
+
False,
|
|
499
|
+
"--detach", "-d",
|
|
500
|
+
help="Run infrastructure in background only (don't start agent).",
|
|
501
|
+
),
|
|
502
|
+
no_watch: bool = typer.Option(
|
|
503
|
+
False,
|
|
504
|
+
"--no-watch",
|
|
505
|
+
help="Disable hot reload (don't watch for file changes).",
|
|
506
|
+
),
|
|
507
|
+
stop: bool = typer.Option(
|
|
508
|
+
False,
|
|
509
|
+
"--stop",
|
|
510
|
+
help="Stop the running development stack.",
|
|
511
|
+
),
|
|
512
|
+
status: bool = typer.Option(
|
|
513
|
+
False,
|
|
514
|
+
"--status",
|
|
515
|
+
help="Show status of the development stack.",
|
|
516
|
+
),
|
|
517
|
+
logs: bool = typer.Option(
|
|
518
|
+
False,
|
|
519
|
+
"--logs",
|
|
520
|
+
help="Show logs from the infrastructure containers.",
|
|
521
|
+
),
|
|
522
|
+
infra_only: bool = typer.Option(
|
|
523
|
+
False,
|
|
524
|
+
"--infra-only",
|
|
525
|
+
help="Only start infrastructure, don't run the agent.",
|
|
526
|
+
),
|
|
527
|
+
registry_port: int = typer.Option(
|
|
528
|
+
8081,
|
|
529
|
+
"--registry-port",
|
|
530
|
+
help="Port for the Registry service.",
|
|
531
|
+
),
|
|
532
|
+
nats_port: int = typer.Option(
|
|
533
|
+
4222,
|
|
534
|
+
"--nats-port",
|
|
535
|
+
help="Port for NATS client connections.",
|
|
536
|
+
),
|
|
537
|
+
event_service_port: int = typer.Option(
|
|
538
|
+
8082,
|
|
539
|
+
"--event-service-port",
|
|
540
|
+
help="Port for the Event Service.",
|
|
541
|
+
),
|
|
542
|
+
build: bool = typer.Option(
|
|
543
|
+
False,
|
|
544
|
+
"--build",
|
|
545
|
+
help="Build service images from local soorma-core source before starting.",
|
|
546
|
+
),
|
|
547
|
+
):
|
|
548
|
+
"""
|
|
549
|
+
Start the local Soorma development environment.
|
|
550
|
+
|
|
551
|
+
This command implements the "Infra in Docker, Code on Host" pattern:
|
|
552
|
+
|
|
553
|
+
\b
|
|
554
|
+
• Infrastructure (Registry, NATS) runs in Docker containers
|
|
555
|
+
• Your agent code runs natively on your machine
|
|
556
|
+
• File changes trigger automatic hot reload
|
|
557
|
+
• No docker build cycle - instant iteration!
|
|
558
|
+
|
|
559
|
+
\b
|
|
560
|
+
Usage:
|
|
561
|
+
soorma dev # Start infra + run agent with hot reload
|
|
562
|
+
soorma dev --build # Build images first, then start
|
|
563
|
+
soorma dev --detach # Start infra only (background)
|
|
564
|
+
soorma dev --stop # Stop everything
|
|
565
|
+
"""
|
|
566
|
+
# Check Docker availability
|
|
567
|
+
compose_cmd = check_docker()
|
|
568
|
+
|
|
569
|
+
# Get .soorma directory and compose file
|
|
570
|
+
soorma_dir = get_soorma_dir()
|
|
571
|
+
compose_file = soorma_dir / "docker-compose.yml"
|
|
572
|
+
env_file = soorma_dir / ".env"
|
|
573
|
+
|
|
574
|
+
# Write docker-compose.yml
|
|
575
|
+
compose_file.write_text(DOCKER_COMPOSE_TEMPLATE)
|
|
576
|
+
|
|
577
|
+
# Check for service images (unless just stopping/status/logs)
|
|
578
|
+
service_images = {}
|
|
579
|
+
if not stop and not status and not logs:
|
|
580
|
+
# If --build flag, build all services first
|
|
581
|
+
if build:
|
|
582
|
+
typer.echo("🔨 Building service images...")
|
|
583
|
+
soorma_core_root = find_soorma_core_root()
|
|
584
|
+
if not soorma_core_root:
|
|
585
|
+
typer.echo("❌ Could not find soorma-core repository.", err=True)
|
|
586
|
+
typer.echo("")
|
|
587
|
+
typer.echo("Set SOORMA_CORE_PATH to the repo location:", err=True)
|
|
588
|
+
typer.echo(" export SOORMA_CORE_PATH=/path/to/soorma-core", err=True)
|
|
589
|
+
typer.echo(" soorma dev --build", err=True)
|
|
590
|
+
raise typer.Exit(1)
|
|
591
|
+
|
|
592
|
+
typer.echo(f" Found soorma-core at: {soorma_core_root}")
|
|
593
|
+
build_results = build_all_services(soorma_core_root)
|
|
594
|
+
|
|
595
|
+
failed = [k for k, v in build_results.items() if not v]
|
|
596
|
+
if failed:
|
|
597
|
+
typer.echo(f"❌ Failed to build: {', '.join(failed)}", err=True)
|
|
598
|
+
raise typer.Exit(1)
|
|
599
|
+
|
|
600
|
+
typer.echo(" ✓ All images built successfully!")
|
|
601
|
+
typer.echo("")
|
|
602
|
+
|
|
603
|
+
# Check for required service images
|
|
604
|
+
missing_services = []
|
|
605
|
+
for service_key, service_def in SERVICE_DEFINITIONS.items():
|
|
606
|
+
image = check_service_image(service_key)
|
|
607
|
+
if image:
|
|
608
|
+
service_images[service_key] = image
|
|
609
|
+
else:
|
|
610
|
+
missing_services.append((service_key, service_def))
|
|
611
|
+
|
|
612
|
+
if missing_services:
|
|
613
|
+
typer.echo("❌ Required service images not found:", err=True)
|
|
614
|
+
for key, svc in missing_services:
|
|
615
|
+
typer.echo(f" • {svc['name']} ({svc['local_image']})", err=True)
|
|
616
|
+
typer.echo("")
|
|
617
|
+
typer.echo("Options:", err=True)
|
|
618
|
+
typer.echo("")
|
|
619
|
+
typer.echo(" 1. Auto-build from source (if you have soorma-core cloned):", err=True)
|
|
620
|
+
typer.echo(" soorma dev --build", err=True)
|
|
621
|
+
typer.echo("")
|
|
622
|
+
typer.echo(" 2. Set SOORMA_CORE_PATH and build:", err=True)
|
|
623
|
+
typer.echo(" export SOORMA_CORE_PATH=/path/to/soorma-core", err=True)
|
|
624
|
+
typer.echo(" soorma dev --build", err=True)
|
|
625
|
+
typer.echo("")
|
|
626
|
+
typer.echo(" 3. Manual build from soorma-core root:", err=True)
|
|
627
|
+
typer.echo(" cd /path/to/soorma-core", err=True)
|
|
628
|
+
for key, svc in missing_services:
|
|
629
|
+
typer.echo(f" docker build -f {svc['dockerfile']} -t {svc['local_image']} .", err=True)
|
|
630
|
+
typer.echo("")
|
|
631
|
+
typer.echo("Note: Public images will be available at ghcr.io/soorma-ai/*", err=True)
|
|
632
|
+
typer.echo("once the platform is released.", err=True)
|
|
633
|
+
raise typer.Exit(1)
|
|
634
|
+
|
|
635
|
+
# Get service images for env file
|
|
636
|
+
registry_image = service_images.get("registry", "registry-service:latest")
|
|
637
|
+
event_service_image = service_images.get("event-service", "event-service:latest")
|
|
638
|
+
|
|
639
|
+
# Write .env file with custom ports and service images
|
|
640
|
+
env_content = f"""# Soorma Local Development Environment
|
|
641
|
+
NATS_PORT={nats_port}
|
|
642
|
+
NATS_HTTP_PORT=8222
|
|
643
|
+
REGISTRY_PORT={registry_port}
|
|
644
|
+
REGISTRY_IMAGE={registry_image or 'registry-service:latest'}
|
|
645
|
+
EVENT_SERVICE_PORT={event_service_port}
|
|
646
|
+
EVENT_SERVICE_IMAGE={event_service_image or 'event-service:latest'}
|
|
647
|
+
"""
|
|
648
|
+
env_file.write_text(env_content)
|
|
649
|
+
|
|
650
|
+
# Build base compose command
|
|
651
|
+
base_cmd = get_compose_cmd(compose_cmd, compose_file)
|
|
652
|
+
|
|
653
|
+
# Handle --stop
|
|
654
|
+
if stop:
|
|
655
|
+
typer.echo("🛑 Stopping Soorma development stack...")
|
|
656
|
+
result = subprocess.run(base_cmd + ["down"], cwd=soorma_dir)
|
|
657
|
+
if result.returncode == 0:
|
|
658
|
+
typer.echo("✓ Stack stopped.")
|
|
659
|
+
raise typer.Exit(result.returncode)
|
|
660
|
+
|
|
661
|
+
# Handle --status
|
|
662
|
+
if status:
|
|
663
|
+
typer.echo("📊 Soorma development stack status:")
|
|
664
|
+
typer.echo("")
|
|
665
|
+
subprocess.run(base_cmd + ["ps"], cwd=soorma_dir)
|
|
666
|
+
raise typer.Exit(0)
|
|
667
|
+
|
|
668
|
+
# Handle --logs
|
|
669
|
+
if logs:
|
|
670
|
+
typer.echo("📋 Infrastructure logs (Ctrl+C to exit):")
|
|
671
|
+
subprocess.run(base_cmd + ["logs", "-f"], cwd=soorma_dir)
|
|
672
|
+
raise typer.Exit(0)
|
|
673
|
+
|
|
674
|
+
# Find agent entry point (unless infra-only or detach)
|
|
675
|
+
entry_point = None
|
|
676
|
+
if not infra_only and not detach:
|
|
677
|
+
entry_point = find_agent_entry_point()
|
|
678
|
+
if not entry_point:
|
|
679
|
+
typer.echo("⚠️ No agent entry point found.", err=True)
|
|
680
|
+
typer.echo(" Looking for: agent.py, main.py, or app.py", err=True)
|
|
681
|
+
typer.echo(" Use --infra-only to start infrastructure without an agent.", err=True)
|
|
682
|
+
typer.echo("")
|
|
683
|
+
typer.echo(" Tip: Run 'soorma init my-agent' to create a new project.", err=True)
|
|
684
|
+
raise typer.Exit(1)
|
|
685
|
+
|
|
686
|
+
# Print banner
|
|
687
|
+
typer.echo("")
|
|
688
|
+
typer.echo("╭─────────────────────────────────────────────────────────╮")
|
|
689
|
+
typer.echo("│ 🚀 Soorma Development Environment │")
|
|
690
|
+
typer.echo("╰─────────────────────────────────────────────────────────╯")
|
|
691
|
+
typer.echo("")
|
|
692
|
+
|
|
693
|
+
# Start infrastructure
|
|
694
|
+
typer.echo("📦 Starting infrastructure (Docker)...")
|
|
695
|
+
typer.echo(f" Registry: http://localhost:{registry_port}")
|
|
696
|
+
typer.echo(f" Event Service: http://localhost:{event_service_port}")
|
|
697
|
+
typer.echo(f" NATS: nats://localhost:{nats_port}")
|
|
698
|
+
typer.echo("")
|
|
699
|
+
|
|
700
|
+
# Pull images (quiet mode)
|
|
701
|
+
subprocess.run(
|
|
702
|
+
base_cmd + ["pull", "-q"],
|
|
703
|
+
cwd=soorma_dir,
|
|
704
|
+
capture_output=True,
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
# Start containers in detached mode
|
|
708
|
+
up_result = subprocess.run(
|
|
709
|
+
base_cmd + ["up", "-d"],
|
|
710
|
+
cwd=soorma_dir,
|
|
711
|
+
capture_output=True,
|
|
712
|
+
text=True,
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
if up_result.returncode != 0:
|
|
716
|
+
typer.echo("❌ Failed to start infrastructure:", err=True)
|
|
717
|
+
typer.echo(up_result.stderr, err=True)
|
|
718
|
+
raise typer.Exit(1)
|
|
719
|
+
|
|
720
|
+
# Wait for infrastructure to be healthy
|
|
721
|
+
typer.echo(" ⏳ Waiting for services to be ready...")
|
|
722
|
+
if not wait_for_infrastructure(registry_port, timeout=60):
|
|
723
|
+
typer.echo("❌ Infrastructure failed to start. Check logs:", err=True)
|
|
724
|
+
typer.echo(f" soorma dev --logs", err=True)
|
|
725
|
+
raise typer.Exit(1)
|
|
726
|
+
|
|
727
|
+
typer.echo(" ✓ Infrastructure ready!")
|
|
728
|
+
typer.echo("")
|
|
729
|
+
|
|
730
|
+
# If detach or infra-only, we're done
|
|
731
|
+
if detach or infra_only:
|
|
732
|
+
typer.echo("✓ Infrastructure running in background.")
|
|
733
|
+
typer.echo("")
|
|
734
|
+
typer.echo("Useful commands:")
|
|
735
|
+
typer.echo(" soorma dev --status # Check status")
|
|
736
|
+
typer.echo(" soorma dev --logs # View logs")
|
|
737
|
+
typer.echo(" soorma dev --stop # Stop stack")
|
|
738
|
+
typer.echo("")
|
|
739
|
+
typer.echo("To run your agent:")
|
|
740
|
+
typer.echo(f" export SOORMA_REGISTRY_URL=http://localhost:{registry_port}")
|
|
741
|
+
typer.echo(f" export SOORMA_EVENT_SERVICE_URL=http://localhost:{event_service_port}")
|
|
742
|
+
typer.echo(f" export SOORMA_NATS_URL=nats://localhost:{nats_port}")
|
|
743
|
+
typer.echo(" python agent.py")
|
|
744
|
+
raise typer.Exit(0)
|
|
745
|
+
|
|
746
|
+
# Run the agent with hot reload
|
|
747
|
+
typer.echo("🤖 Starting agent (native Python process)...")
|
|
748
|
+
typer.echo(f" Entry point: {entry_point.relative_to(Path.cwd())}")
|
|
749
|
+
if not no_watch:
|
|
750
|
+
typer.echo(" Hot reload: enabled")
|
|
751
|
+
typer.echo("")
|
|
752
|
+
typer.echo("─" * 50)
|
|
753
|
+
typer.echo("Press Ctrl+C to stop")
|
|
754
|
+
typer.echo("─" * 50)
|
|
755
|
+
typer.echo("")
|
|
756
|
+
|
|
757
|
+
# Create and run the agent
|
|
758
|
+
runner = AgentRunner(
|
|
759
|
+
entry_point=entry_point,
|
|
760
|
+
registry_url=f"http://localhost:{registry_port}",
|
|
761
|
+
event_service_url=f"http://localhost:{event_service_port}",
|
|
762
|
+
nats_url=f"nats://localhost:{nats_port}",
|
|
763
|
+
watch=not no_watch,
|
|
764
|
+
)
|
|
765
|
+
|
|
766
|
+
try:
|
|
767
|
+
runner.run()
|
|
768
|
+
except KeyboardInterrupt:
|
|
769
|
+
pass
|
|
770
|
+
finally:
|
|
771
|
+
typer.echo("")
|
|
772
|
+
typer.echo("🛑 Stopping development environment...")
|
|
773
|
+
|
|
774
|
+
# Stop infrastructure
|
|
775
|
+
subprocess.run(
|
|
776
|
+
base_cmd + ["down"],
|
|
777
|
+
cwd=soorma_dir,
|
|
778
|
+
capture_output=True,
|
|
779
|
+
)
|
|
780
|
+
typer.echo("✓ Done.")
|