elidia-agent-cli 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.
- elidia/__init__.py +0 -0
- elidia/__main__.py +4 -0
- elidia/agent/__init__.py +3 -0
- elidia/agent/loop.py +449 -0
- elidia/agent/personas.py +130 -0
- elidia/agent/portal.py +115 -0
- elidia/api/__init__.py +0 -0
- elidia/api/client.py +221 -0
- elidia/api/streaming.py +55 -0
- elidia/auth/__init__.py +0 -0
- elidia/auth/keychain.py +86 -0
- elidia/cache/__init__.py +1 -0
- elidia/cache/lru.py +158 -0
- elidia/cli/__init__.py +0 -0
- elidia/cli/commands.py +122 -0
- elidia/cli/main.py +293 -0
- elidia/cli/pager.py +104 -0
- elidia/cli/progress.py +173 -0
- elidia/cli/renderer.py +32 -0
- elidia/cli/repl.py +1234 -0
- elidia/cli/themes.py +214 -0
- elidia/config/__init__.py +0 -0
- elidia/config/defaults.py +5 -0
- elidia/config/rules.py +48 -0
- elidia/config/settings.py +217 -0
- elidia/creative/__init__.py +1 -0
- elidia/creative/audio.py +213 -0
- elidia/creative/display.py +260 -0
- elidia/creative/image.py +142 -0
- elidia/creative/local.py +163 -0
- elidia/creative/video.py +181 -0
- elidia/daemon/__init__.py +0 -0
- elidia/daemon/manager.py +349 -0
- elidia/db/__init__.py +0 -0
- elidia/db/database.py +87 -0
- elidia/mcp/__init__.py +0 -0
- elidia/mcp/client.py +198 -0
- elidia/mcp/config.py +93 -0
- elidia/mcp/registry.py +101 -0
- elidia/mcp/types.py +43 -0
- elidia/memory/__init__.py +3 -0
- elidia/memory/auto.py +164 -0
- elidia/memory/compaction.py +101 -0
- elidia/memory/embeddings.py +47 -0
- elidia/memory/outcomes.py +131 -0
- elidia/memory/patterns.py +79 -0
- elidia/memory/store.py +338 -0
- elidia/models/__init__.py +0 -0
- elidia/models/adaptive.py +53 -0
- elidia/models/router.py +115 -0
- elidia/modes/__init__.py +1 -0
- elidia/modes/autonomous.py +318 -0
- elidia/modes/budget.py +182 -0
- elidia/modes/classifier.py +169 -0
- elidia/modes/consensus.py +210 -0
- elidia/modes/deep_think.py +190 -0
- elidia/modes/swarm.py +229 -0
- elidia/modes/thinking.py +153 -0
- elidia/permissions/__init__.py +0 -0
- elidia/permissions/audit.py +138 -0
- elidia/permissions/manager.py +199 -0
- elidia/permissions/trust.py +63 -0
- elidia/rag/__init__.py +4 -0
- elidia/rag/chunker.py +216 -0
- elidia/rag/engine.py +345 -0
- elidia/rag/ingest.py +154 -0
- elidia/rag/portal_bridge.py +84 -0
- elidia/rag/watcher.py +98 -0
- elidia/research/__init__.py +1 -0
- elidia/research/export.py +237 -0
- elidia/research/orchestrator.py +310 -0
- elidia/research/sources.py +223 -0
- elidia/session/__init__.py +0 -0
- elidia/session/history.py +119 -0
- elidia/session/manager.py +82 -0
- elidia/tools/__init__.py +28 -0
- elidia/tools/base.py +70 -0
- elidia/tools/fetch.py +100 -0
- elidia/tools/filesystem.py +204 -0
- elidia/tools/git.py +156 -0
- elidia/tools/search.py +99 -0
- elidia/tools/terminal.py +77 -0
- elidia/widgets/__init__.py +1 -0
- elidia/widgets/protocol.py +248 -0
- elidia/widgets/renderer.py +225 -0
- elidia/workflow/__init__.py +1 -0
- elidia/workflow/engine.py +377 -0
- elidia_agent_cli-0.1.0.dist-info/METADATA +344 -0
- elidia_agent_cli-0.1.0.dist-info/RECORD +93 -0
- elidia_agent_cli-0.1.0.dist-info/WHEEL +5 -0
- elidia_agent_cli-0.1.0.dist-info/entry_points.txt +2 -0
- elidia_agent_cli-0.1.0.dist-info/licenses/LICENSE +57 -0
- elidia_agent_cli-0.1.0.dist-info/top_level.txt +1 -0
elidia/creative/local.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Local creative generation — runs models on the user's machine.
|
|
2
|
+
|
|
3
|
+
Uses diffusers for image generation (SD-turbo, SDXL-lightning) when
|
|
4
|
+
available. Falls back gracefully when dependencies are not installed.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
DEFAULT_OUTPUT_DIR = Path.home() / ".elidia" / "generated" / "local"
|
|
17
|
+
|
|
18
|
+
LOCAL_IMAGE_MODELS = {
|
|
19
|
+
"sd-turbo": {
|
|
20
|
+
"repo": "stabilityai/sd-turbo",
|
|
21
|
+
"steps": 4,
|
|
22
|
+
"guidance": 0.0,
|
|
23
|
+
"description": "Fast 4-step image generation (512x512)",
|
|
24
|
+
},
|
|
25
|
+
"sdxl-lightning-4step": {
|
|
26
|
+
"repo": "ByteDance/SDXL-Lightning",
|
|
27
|
+
"steps": 4,
|
|
28
|
+
"guidance": 0.0,
|
|
29
|
+
"description": "Fast SDXL variant (1024x1024)",
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class LocalImageResult:
|
|
36
|
+
file_path: str
|
|
37
|
+
model: str
|
|
38
|
+
prompt: str
|
|
39
|
+
width: int
|
|
40
|
+
height: int
|
|
41
|
+
steps: int
|
|
42
|
+
elapsed_ms: int = 0
|
|
43
|
+
size_bytes: int = 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def check_local_available() -> dict[str, bool]:
|
|
47
|
+
logger.debug("Entered into check_local_available")
|
|
48
|
+
status: dict[str, bool] = {}
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
import torch # noqa: F401
|
|
52
|
+
status["torch"] = True
|
|
53
|
+
except ImportError:
|
|
54
|
+
status["torch"] = False
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
import diffusers # noqa: F401
|
|
58
|
+
status["diffusers"] = True
|
|
59
|
+
except ImportError:
|
|
60
|
+
status["diffusers"] = False
|
|
61
|
+
|
|
62
|
+
status["gpu"] = False
|
|
63
|
+
if status["torch"]:
|
|
64
|
+
try:
|
|
65
|
+
import torch
|
|
66
|
+
status["gpu"] = torch.cuda.is_available() or (hasattr(torch.backends, "mps") and torch.backends.mps.is_available())
|
|
67
|
+
except Exception:
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
status["ready"] = status["torch"] and status["diffusers"]
|
|
71
|
+
return status
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def generate_local_image(
|
|
75
|
+
prompt: str,
|
|
76
|
+
model: str = "sd-turbo",
|
|
77
|
+
width: int = 512,
|
|
78
|
+
height: int = 512,
|
|
79
|
+
output_dir: Path | None = None,
|
|
80
|
+
num_steps: int | None = None,
|
|
81
|
+
) -> LocalImageResult:
|
|
82
|
+
logger.debug(f"Entered into generate_local_image: model={model}, prompt={prompt[:60]!r}")
|
|
83
|
+
start = time.monotonic()
|
|
84
|
+
|
|
85
|
+
avail = check_local_available()
|
|
86
|
+
if not avail["ready"]:
|
|
87
|
+
missing = []
|
|
88
|
+
if not avail["torch"]:
|
|
89
|
+
missing.append("torch")
|
|
90
|
+
if not avail["diffusers"]:
|
|
91
|
+
missing.append("diffusers")
|
|
92
|
+
raise RuntimeError(
|
|
93
|
+
f"Local generation requires: {', '.join(missing)}. "
|
|
94
|
+
f"Install with: pip install {' '.join(missing)}"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
model_info = LOCAL_IMAGE_MODELS.get(model)
|
|
98
|
+
if not model_info:
|
|
99
|
+
raise RuntimeError(f"Unknown local model: {model}. Available: {list(LOCAL_IMAGE_MODELS.keys())}")
|
|
100
|
+
|
|
101
|
+
steps = num_steps or model_info["steps"]
|
|
102
|
+
guidance = model_info["guidance"]
|
|
103
|
+
|
|
104
|
+
save_dir = output_dir or DEFAULT_OUTPUT_DIR
|
|
105
|
+
save_dir.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
|
|
107
|
+
import torch
|
|
108
|
+
from diffusers import AutoPipelineForText2Image
|
|
109
|
+
|
|
110
|
+
device = "cuda" if torch.cuda.is_available() else "mps" if (hasattr(torch.backends, "mps") and torch.backends.mps.is_available()) else "cpu"
|
|
111
|
+
dtype = torch.float16 if device != "cpu" else torch.float32
|
|
112
|
+
|
|
113
|
+
logger.info(f"Loading {model_info['repo']} on {device} ({dtype})")
|
|
114
|
+
pipe = AutoPipelineForText2Image.from_pretrained(
|
|
115
|
+
model_info["repo"],
|
|
116
|
+
torch_dtype=dtype,
|
|
117
|
+
variant="fp16" if dtype == torch.float16 else None,
|
|
118
|
+
)
|
|
119
|
+
pipe = pipe.to(device)
|
|
120
|
+
|
|
121
|
+
image = pipe(
|
|
122
|
+
prompt=prompt,
|
|
123
|
+
num_inference_steps=steps,
|
|
124
|
+
guidance_scale=guidance,
|
|
125
|
+
width=width,
|
|
126
|
+
height=height,
|
|
127
|
+
).images[0]
|
|
128
|
+
|
|
129
|
+
timestamp = int(time.time())
|
|
130
|
+
safe_prompt = "".join(c if c.isalnum() or c in " -_" else "" for c in prompt[:30]).strip().replace(" ", "_")
|
|
131
|
+
filename = f"{timestamp}_local_{safe_prompt}.png"
|
|
132
|
+
file_path = save_dir / filename
|
|
133
|
+
image.save(str(file_path))
|
|
134
|
+
|
|
135
|
+
size_bytes = file_path.stat().st_size
|
|
136
|
+
elapsed = int((time.monotonic() - start) * 1000)
|
|
137
|
+
logger.info(f"Local image generated: {file_path} ({size_bytes} bytes, {elapsed}ms)")
|
|
138
|
+
|
|
139
|
+
return LocalImageResult(
|
|
140
|
+
file_path=str(file_path),
|
|
141
|
+
model=model,
|
|
142
|
+
prompt=prompt,
|
|
143
|
+
width=width,
|
|
144
|
+
height=height,
|
|
145
|
+
steps=steps,
|
|
146
|
+
elapsed_ms=elapsed,
|
|
147
|
+
size_bytes=size_bytes,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def list_local_models() -> list[dict[str, Any]]:
|
|
152
|
+
logger.debug("Entered into list_local_models")
|
|
153
|
+
avail = check_local_available()
|
|
154
|
+
return [
|
|
155
|
+
{
|
|
156
|
+
"model": name,
|
|
157
|
+
"repo": info["repo"],
|
|
158
|
+
"description": info["description"],
|
|
159
|
+
"steps": info["steps"],
|
|
160
|
+
"available": avail["ready"],
|
|
161
|
+
}
|
|
162
|
+
for name, info in LOCAL_IMAGE_MODELS.items()
|
|
163
|
+
]
|
elidia/creative/video.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Video generation via AiUtils Developer API.
|
|
2
|
+
|
|
3
|
+
Supports text-to-video and image-to-video through models like Kling and
|
|
4
|
+
Minimax. Video generation is asynchronous — submit a job, poll for completion.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import base64
|
|
10
|
+
import logging
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from elidia.api.client import AiUtilsClient
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
VIDEO_MODELS = {
|
|
23
|
+
"kling-v2": {"vendor": "kling", "max_duration": 10, "supports_image": True},
|
|
24
|
+
"kling-v1.6": {"vendor": "kling", "max_duration": 5, "supports_image": True},
|
|
25
|
+
"minimax-video-01": {"vendor": "minimax", "max_duration": 6, "supports_image": False},
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
DEFAULT_MODEL = "kling-v2"
|
|
29
|
+
DEFAULT_OUTPUT_DIR = Path.home() / ".elidia" / "generated" / "videos"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class VideoResult:
|
|
34
|
+
file_path: str
|
|
35
|
+
model: str
|
|
36
|
+
prompt: str
|
|
37
|
+
duration: int
|
|
38
|
+
format: str = "mp4"
|
|
39
|
+
size_bytes: int = 0
|
|
40
|
+
elapsed_ms: int = 0
|
|
41
|
+
job_id: str = ""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def generate_video(
|
|
45
|
+
client: AiUtilsClient,
|
|
46
|
+
prompt: str,
|
|
47
|
+
model: str = DEFAULT_MODEL,
|
|
48
|
+
duration: int = 5,
|
|
49
|
+
image_path: str | None = None,
|
|
50
|
+
output_dir: Path | None = None,
|
|
51
|
+
poll_interval: float = 5.0,
|
|
52
|
+
max_wait: float = 300.0,
|
|
53
|
+
) -> VideoResult:
|
|
54
|
+
logger.debug(f"Entered into generate_video: model={model}, prompt={prompt[:60]!r}")
|
|
55
|
+
start = time.monotonic()
|
|
56
|
+
|
|
57
|
+
save_dir = output_dir or DEFAULT_OUTPUT_DIR
|
|
58
|
+
save_dir.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
|
|
60
|
+
model_info = VIDEO_MODELS.get(model, VIDEO_MODELS[DEFAULT_MODEL])
|
|
61
|
+
duration = min(duration, model_info["max_duration"])
|
|
62
|
+
|
|
63
|
+
payload: dict[str, Any] = {
|
|
64
|
+
"model": model,
|
|
65
|
+
"prompt": prompt,
|
|
66
|
+
"duration": duration,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if image_path and model_info["supports_image"]:
|
|
70
|
+
img_bytes = Path(image_path).read_bytes()
|
|
71
|
+
payload["image"] = base64.b64encode(img_bytes).decode("ascii")
|
|
72
|
+
|
|
73
|
+
headers = {
|
|
74
|
+
"Authorization": f"Bearer {client._api_key}",
|
|
75
|
+
"Content-Type": "application/json",
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async with httpx.AsyncClient(
|
|
79
|
+
base_url=client._base_url,
|
|
80
|
+
headers=headers,
|
|
81
|
+
timeout=httpx.Timeout(30.0, connect=10.0),
|
|
82
|
+
) as http:
|
|
83
|
+
response = await http.post("/videos/generations", json=payload)
|
|
84
|
+
|
|
85
|
+
if response.status_code == 401:
|
|
86
|
+
raise RuntimeError("Invalid API key for video generation")
|
|
87
|
+
if response.status_code == 402:
|
|
88
|
+
raise RuntimeError("Insufficient balance for video generation")
|
|
89
|
+
response.raise_for_status()
|
|
90
|
+
|
|
91
|
+
data = response.json()
|
|
92
|
+
job_id = data.get("id", data.get("job_id", ""))
|
|
93
|
+
|
|
94
|
+
if not job_id:
|
|
95
|
+
video_url = data.get("url", "")
|
|
96
|
+
if video_url:
|
|
97
|
+
return await _download_and_save(
|
|
98
|
+
video_url, prompt, model, duration, save_dir, start, job_id
|
|
99
|
+
)
|
|
100
|
+
raise RuntimeError("No job ID or video URL returned")
|
|
101
|
+
|
|
102
|
+
logger.info(f"Video job submitted: {job_id}")
|
|
103
|
+
|
|
104
|
+
waited = 0.0
|
|
105
|
+
while waited < max_wait:
|
|
106
|
+
await asyncio.sleep(poll_interval)
|
|
107
|
+
waited += poll_interval
|
|
108
|
+
|
|
109
|
+
status_resp = await http.get(f"/videos/generations/{job_id}")
|
|
110
|
+
if status_resp.status_code >= 400:
|
|
111
|
+
logger.warning(f"Poll error {status_resp.status_code} for job {job_id}")
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
status_data = status_resp.json()
|
|
115
|
+
state = status_data.get("status", "").lower()
|
|
116
|
+
|
|
117
|
+
if state in ("completed", "succeeded", "done"):
|
|
118
|
+
video_url = status_data.get("url", status_data.get("video_url", ""))
|
|
119
|
+
if not video_url:
|
|
120
|
+
results = status_data.get("data", status_data.get("results", []))
|
|
121
|
+
if results and isinstance(results, list):
|
|
122
|
+
video_url = results[0].get("url", "")
|
|
123
|
+
|
|
124
|
+
if video_url:
|
|
125
|
+
return await _download_and_save(
|
|
126
|
+
video_url, prompt, model, duration, save_dir, start, job_id
|
|
127
|
+
)
|
|
128
|
+
raise RuntimeError(f"Job {job_id} completed but no video URL found")
|
|
129
|
+
|
|
130
|
+
elif state in ("failed", "error"):
|
|
131
|
+
error = status_data.get("error", status_data.get("message", "unknown error"))
|
|
132
|
+
raise RuntimeError(f"Video generation failed: {error}")
|
|
133
|
+
|
|
134
|
+
logger.debug(f"Job {job_id} status: {state}, waited {waited:.0f}s")
|
|
135
|
+
|
|
136
|
+
raise RuntimeError(f"Video generation timed out after {max_wait}s (job: {job_id})")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
async def _download_and_save(
|
|
140
|
+
url: str,
|
|
141
|
+
prompt: str,
|
|
142
|
+
model: str,
|
|
143
|
+
duration: int,
|
|
144
|
+
save_dir: Path,
|
|
145
|
+
start: float,
|
|
146
|
+
job_id: str,
|
|
147
|
+
) -> VideoResult:
|
|
148
|
+
logger.debug(f"Entered into _download_and_save: url={url[:80]}")
|
|
149
|
+
async with httpx.AsyncClient(timeout=60.0) as dl:
|
|
150
|
+
vid_resp = await dl.get(url)
|
|
151
|
+
vid_resp.raise_for_status()
|
|
152
|
+
vid_bytes = vid_resp.content
|
|
153
|
+
|
|
154
|
+
timestamp = int(time.time())
|
|
155
|
+
safe_prompt = "".join(c if c.isalnum() or c in " -_" else "" for c in prompt[:30]).strip().replace(" ", "_")
|
|
156
|
+
filename = f"{timestamp}_{safe_prompt}.mp4"
|
|
157
|
+
file_path = save_dir / filename
|
|
158
|
+
file_path.write_bytes(vid_bytes)
|
|
159
|
+
|
|
160
|
+
elapsed = int((time.monotonic() - start) * 1000)
|
|
161
|
+
logger.info(f"Video saved: {file_path} ({len(vid_bytes)} bytes, {elapsed}ms)")
|
|
162
|
+
|
|
163
|
+
return VideoResult(
|
|
164
|
+
file_path=str(file_path),
|
|
165
|
+
model=model,
|
|
166
|
+
prompt=prompt,
|
|
167
|
+
duration=duration,
|
|
168
|
+
format="mp4",
|
|
169
|
+
size_bytes=len(vid_bytes),
|
|
170
|
+
elapsed_ms=elapsed,
|
|
171
|
+
job_id=job_id,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def list_video_models() -> list[dict[str, Any]]:
|
|
176
|
+
logger.debug("Entered into list_video_models")
|
|
177
|
+
return [
|
|
178
|
+
{"model": name, "vendor": info["vendor"], "max_duration": info["max_duration"],
|
|
179
|
+
"supports_image": info["supports_image"]}
|
|
180
|
+
for name, info in VIDEO_MODELS.items()
|
|
181
|
+
]
|
|
File without changes
|
elidia/daemon/manager.py
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""Daemon mode — background task manager with watchers, schedules, and webhooks.
|
|
2
|
+
|
|
3
|
+
Manages long-running background tasks:
|
|
4
|
+
- File watchers: monitor directories for changes
|
|
5
|
+
- Scheduled tasks: run at intervals or cron-like expressions
|
|
6
|
+
- Webhook listener: HTTP endpoint that triggers workflows
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import time
|
|
14
|
+
from collections.abc import Callable, Coroutine
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class DaemonTask:
|
|
24
|
+
id: str
|
|
25
|
+
name: str
|
|
26
|
+
type: str # "watcher", "schedule", "webhook"
|
|
27
|
+
config: dict[str, Any] = field(default_factory=dict)
|
|
28
|
+
status: str = "stopped" # "running", "stopped", "error"
|
|
29
|
+
last_run: float = 0.0
|
|
30
|
+
run_count: int = 0
|
|
31
|
+
error: str = ""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class DaemonEvent:
|
|
36
|
+
kind: str # "task_start", "task_stop", "task_trigger", "task_error", "status"
|
|
37
|
+
data: Any = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class DaemonManager:
|
|
41
|
+
"""Manages background daemon tasks."""
|
|
42
|
+
|
|
43
|
+
def __init__(self) -> None:
|
|
44
|
+
logger.debug("Entered into DaemonManager.__init__")
|
|
45
|
+
self._tasks: dict[str, DaemonTask] = {}
|
|
46
|
+
self._handles: dict[str, asyncio.Task[None]] = {}
|
|
47
|
+
self._callbacks: dict[str, Callable[..., Coroutine[Any, Any, None]]] = {}
|
|
48
|
+
self._running = False
|
|
49
|
+
self._webhook_server: asyncio.Server | None = None
|
|
50
|
+
|
|
51
|
+
async def start(self) -> None:
|
|
52
|
+
logger.debug("Entered into DaemonManager.start")
|
|
53
|
+
self._running = True
|
|
54
|
+
for task_id, task in self._tasks.items():
|
|
55
|
+
if task.status == "stopped":
|
|
56
|
+
await self._start_task(task)
|
|
57
|
+
|
|
58
|
+
async def stop(self) -> None:
|
|
59
|
+
logger.debug("Entered into DaemonManager.stop")
|
|
60
|
+
self._running = False
|
|
61
|
+
for task_id in list(self._handles.keys()):
|
|
62
|
+
await self._stop_task(task_id)
|
|
63
|
+
if self._webhook_server:
|
|
64
|
+
self._webhook_server.close()
|
|
65
|
+
await self._webhook_server.wait_closed()
|
|
66
|
+
self._webhook_server = None
|
|
67
|
+
|
|
68
|
+
def add_watcher(
|
|
69
|
+
self,
|
|
70
|
+
name: str,
|
|
71
|
+
path: str,
|
|
72
|
+
patterns: list[str] | None = None,
|
|
73
|
+
interval: float = 2.0,
|
|
74
|
+
callback: Callable[..., Coroutine[Any, Any, None]] | None = None,
|
|
75
|
+
) -> str:
|
|
76
|
+
logger.debug(f"Entered into add_watcher: name={name}, path={path}")
|
|
77
|
+
task_id = f"watch_{name}_{int(time.time())}"
|
|
78
|
+
task = DaemonTask(
|
|
79
|
+
id=task_id,
|
|
80
|
+
name=name,
|
|
81
|
+
type="watcher",
|
|
82
|
+
config={
|
|
83
|
+
"path": path,
|
|
84
|
+
"patterns": patterns or ["*"],
|
|
85
|
+
"interval": interval,
|
|
86
|
+
},
|
|
87
|
+
)
|
|
88
|
+
self._tasks[task_id] = task
|
|
89
|
+
if callback:
|
|
90
|
+
self._callbacks[task_id] = callback
|
|
91
|
+
return task_id
|
|
92
|
+
|
|
93
|
+
def add_schedule(
|
|
94
|
+
self,
|
|
95
|
+
name: str,
|
|
96
|
+
interval_seconds: float,
|
|
97
|
+
command: str = "",
|
|
98
|
+
callback: Callable[..., Coroutine[Any, Any, None]] | None = None,
|
|
99
|
+
) -> str:
|
|
100
|
+
logger.debug(f"Entered into add_schedule: name={name}, interval={interval_seconds}")
|
|
101
|
+
task_id = f"sched_{name}_{int(time.time())}"
|
|
102
|
+
task = DaemonTask(
|
|
103
|
+
id=task_id,
|
|
104
|
+
name=name,
|
|
105
|
+
type="schedule",
|
|
106
|
+
config={
|
|
107
|
+
"interval": interval_seconds,
|
|
108
|
+
"command": command,
|
|
109
|
+
},
|
|
110
|
+
)
|
|
111
|
+
self._tasks[task_id] = task
|
|
112
|
+
if callback:
|
|
113
|
+
self._callbacks[task_id] = callback
|
|
114
|
+
return task_id
|
|
115
|
+
|
|
116
|
+
def add_webhook(
|
|
117
|
+
self,
|
|
118
|
+
name: str,
|
|
119
|
+
path: str = "/webhook",
|
|
120
|
+
port: int = 8765,
|
|
121
|
+
callback: Callable[..., Coroutine[Any, Any, None]] | None = None,
|
|
122
|
+
) -> str:
|
|
123
|
+
logger.debug(f"Entered into add_webhook: name={name}, path={path}, port={port}")
|
|
124
|
+
task_id = f"hook_{name}_{int(time.time())}"
|
|
125
|
+
task = DaemonTask(
|
|
126
|
+
id=task_id,
|
|
127
|
+
name=name,
|
|
128
|
+
type="webhook",
|
|
129
|
+
config={
|
|
130
|
+
"path": path,
|
|
131
|
+
"port": port,
|
|
132
|
+
},
|
|
133
|
+
)
|
|
134
|
+
self._tasks[task_id] = task
|
|
135
|
+
if callback:
|
|
136
|
+
self._callbacks[task_id] = callback
|
|
137
|
+
return task_id
|
|
138
|
+
|
|
139
|
+
async def remove_task(self, task_id: str) -> bool:
|
|
140
|
+
logger.debug(f"Entered into remove_task: id={task_id}")
|
|
141
|
+
if task_id in self._handles:
|
|
142
|
+
await self._stop_task(task_id)
|
|
143
|
+
if task_id in self._tasks:
|
|
144
|
+
del self._tasks[task_id]
|
|
145
|
+
self._callbacks.pop(task_id, None)
|
|
146
|
+
return True
|
|
147
|
+
return False
|
|
148
|
+
|
|
149
|
+
def list_tasks(self) -> list[DaemonTask]:
|
|
150
|
+
logger.debug("Entered into list_tasks")
|
|
151
|
+
return list(self._tasks.values())
|
|
152
|
+
|
|
153
|
+
def get_status(self) -> dict[str, Any]:
|
|
154
|
+
logger.debug("Entered into get_status")
|
|
155
|
+
return {
|
|
156
|
+
"running": self._running,
|
|
157
|
+
"task_count": len(self._tasks),
|
|
158
|
+
"active": sum(1 for t in self._tasks.values() if t.status == "running"),
|
|
159
|
+
"tasks": [
|
|
160
|
+
{
|
|
161
|
+
"id": t.id,
|
|
162
|
+
"name": t.name,
|
|
163
|
+
"type": t.type,
|
|
164
|
+
"status": t.status,
|
|
165
|
+
"run_count": t.run_count,
|
|
166
|
+
"last_run": t.last_run,
|
|
167
|
+
"error": t.error,
|
|
168
|
+
}
|
|
169
|
+
for t in self._tasks.values()
|
|
170
|
+
],
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async def _start_task(self, task: DaemonTask) -> None:
|
|
174
|
+
logger.debug(f"Entered into _start_task: id={task.id}, type={task.type}")
|
|
175
|
+
if task.id in self._handles:
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
if task.type == "watcher":
|
|
179
|
+
self._handles[task.id] = asyncio.create_task(self._run_watcher(task))
|
|
180
|
+
elif task.type == "schedule":
|
|
181
|
+
self._handles[task.id] = asyncio.create_task(self._run_schedule(task))
|
|
182
|
+
elif task.type == "webhook":
|
|
183
|
+
self._handles[task.id] = asyncio.create_task(self._run_webhook(task))
|
|
184
|
+
|
|
185
|
+
task.status = "running"
|
|
186
|
+
|
|
187
|
+
async def _stop_task(self, task_id: str) -> None:
|
|
188
|
+
logger.debug(f"Entered into _stop_task: id={task_id}")
|
|
189
|
+
handle = self._handles.pop(task_id, None)
|
|
190
|
+
if handle and not handle.done():
|
|
191
|
+
handle.cancel()
|
|
192
|
+
try:
|
|
193
|
+
await handle
|
|
194
|
+
except asyncio.CancelledError:
|
|
195
|
+
pass
|
|
196
|
+
if task_id in self._tasks:
|
|
197
|
+
self._tasks[task_id].status = "stopped"
|
|
198
|
+
|
|
199
|
+
async def _run_watcher(self, task: DaemonTask) -> None:
|
|
200
|
+
logger.debug(f"Entered into _run_watcher: id={task.id}")
|
|
201
|
+
watch_path = Path(task.config["path"])
|
|
202
|
+
interval = task.config.get("interval", 2.0)
|
|
203
|
+
patterns = task.config.get("patterns", ["*"])
|
|
204
|
+
|
|
205
|
+
file_states: dict[str, float] = {}
|
|
206
|
+
if watch_path.is_dir():
|
|
207
|
+
for pattern in patterns:
|
|
208
|
+
for f in watch_path.rglob(pattern):
|
|
209
|
+
if f.is_file():
|
|
210
|
+
file_states[str(f)] = f.stat().st_mtime
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
while self._running:
|
|
214
|
+
await asyncio.sleep(interval)
|
|
215
|
+
if not watch_path.exists():
|
|
216
|
+
continue
|
|
217
|
+
|
|
218
|
+
current: dict[str, float] = {}
|
|
219
|
+
for pattern in patterns:
|
|
220
|
+
for f in watch_path.rglob(pattern):
|
|
221
|
+
if f.is_file():
|
|
222
|
+
current[str(f)] = f.stat().st_mtime
|
|
223
|
+
|
|
224
|
+
changed = [p for p, mt in current.items() if file_states.get(p) != mt]
|
|
225
|
+
deleted = [p for p in file_states if p not in current]
|
|
226
|
+
|
|
227
|
+
if changed or deleted:
|
|
228
|
+
task.run_count += 1
|
|
229
|
+
task.last_run = time.time()
|
|
230
|
+
callback = self._callbacks.get(task.id)
|
|
231
|
+
if callback:
|
|
232
|
+
try:
|
|
233
|
+
await callback(changed=changed, deleted=deleted, task=task)
|
|
234
|
+
except Exception as e:
|
|
235
|
+
task.error = str(e)
|
|
236
|
+
logger.warning(f"Watcher callback error: {e}")
|
|
237
|
+
|
|
238
|
+
file_states = current
|
|
239
|
+
|
|
240
|
+
except asyncio.CancelledError:
|
|
241
|
+
pass
|
|
242
|
+
|
|
243
|
+
async def _run_schedule(self, task: DaemonTask) -> None:
|
|
244
|
+
logger.debug(f"Entered into _run_schedule: id={task.id}")
|
|
245
|
+
interval = task.config.get("interval", 60.0)
|
|
246
|
+
command = task.config.get("command", "")
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
while self._running:
|
|
250
|
+
await asyncio.sleep(interval)
|
|
251
|
+
task.run_count += 1
|
|
252
|
+
task.last_run = time.time()
|
|
253
|
+
|
|
254
|
+
callback = self._callbacks.get(task.id)
|
|
255
|
+
if callback:
|
|
256
|
+
try:
|
|
257
|
+
await callback(task=task)
|
|
258
|
+
except Exception as e:
|
|
259
|
+
task.error = str(e)
|
|
260
|
+
logger.warning(f"Schedule callback error: {e}")
|
|
261
|
+
elif command:
|
|
262
|
+
try:
|
|
263
|
+
proc = await asyncio.create_subprocess_shell(
|
|
264
|
+
command,
|
|
265
|
+
stdout=asyncio.subprocess.PIPE,
|
|
266
|
+
stderr=asyncio.subprocess.PIPE,
|
|
267
|
+
)
|
|
268
|
+
await asyncio.wait_for(proc.communicate(), timeout=30)
|
|
269
|
+
except Exception as e:
|
|
270
|
+
task.error = str(e)
|
|
271
|
+
logger.warning(f"Schedule command error: {e}")
|
|
272
|
+
|
|
273
|
+
except asyncio.CancelledError:
|
|
274
|
+
pass
|
|
275
|
+
|
|
276
|
+
async def _run_webhook(self, task: DaemonTask) -> None:
|
|
277
|
+
logger.debug(f"Entered into _run_webhook: id={task.id}")
|
|
278
|
+
port = task.config.get("port", 8765)
|
|
279
|
+
expected_path = task.config.get("path", "/webhook")
|
|
280
|
+
|
|
281
|
+
async def handle_request(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
282
|
+
try:
|
|
283
|
+
request_line = await asyncio.wait_for(reader.readline(), timeout=10)
|
|
284
|
+
line = request_line.decode("utf-8", errors="replace").strip()
|
|
285
|
+
parts = line.split(" ")
|
|
286
|
+
method = parts[0] if parts else "GET"
|
|
287
|
+
path = parts[1] if len(parts) > 1 else "/"
|
|
288
|
+
|
|
289
|
+
headers: dict[str, str] = {}
|
|
290
|
+
content_length = 0
|
|
291
|
+
while True:
|
|
292
|
+
header_line = await asyncio.wait_for(reader.readline(), timeout=5)
|
|
293
|
+
header = header_line.decode("utf-8", errors="replace").strip()
|
|
294
|
+
if not header:
|
|
295
|
+
break
|
|
296
|
+
if ":" in header:
|
|
297
|
+
key, val = header.split(":", 1)
|
|
298
|
+
headers[key.strip().lower()] = val.strip()
|
|
299
|
+
if key.strip().lower() == "content-length":
|
|
300
|
+
content_length = int(val.strip())
|
|
301
|
+
|
|
302
|
+
body = b""
|
|
303
|
+
if content_length > 0:
|
|
304
|
+
body = await asyncio.wait_for(reader.read(min(content_length, 65536)), timeout=10)
|
|
305
|
+
|
|
306
|
+
if path == expected_path and method == "POST":
|
|
307
|
+
task.run_count += 1
|
|
308
|
+
task.last_run = time.time()
|
|
309
|
+
|
|
310
|
+
payload: dict[str, Any] = {}
|
|
311
|
+
if body:
|
|
312
|
+
try:
|
|
313
|
+
payload = json.loads(body)
|
|
314
|
+
except json.JSONDecodeError:
|
|
315
|
+
payload = {"raw": body.decode("utf-8", errors="replace")}
|
|
316
|
+
|
|
317
|
+
callback = self._callbacks.get(task.id)
|
|
318
|
+
if callback:
|
|
319
|
+
try:
|
|
320
|
+
await callback(payload=payload, task=task)
|
|
321
|
+
except Exception as e:
|
|
322
|
+
task.error = str(e)
|
|
323
|
+
|
|
324
|
+
response = 'HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n{"status": "ok"}'
|
|
325
|
+
else:
|
|
326
|
+
response = 'HTTP/1.1 404 Not Found\r\nContent-Type: application/json\r\n\r\n{"error": "not found"}'
|
|
327
|
+
|
|
328
|
+
writer.write(response.encode())
|
|
329
|
+
await writer.drain()
|
|
330
|
+
except Exception as e:
|
|
331
|
+
logger.debug(f"Webhook request error: {e}")
|
|
332
|
+
finally:
|
|
333
|
+
writer.close()
|
|
334
|
+
try:
|
|
335
|
+
await writer.wait_closed()
|
|
336
|
+
except Exception:
|
|
337
|
+
pass
|
|
338
|
+
|
|
339
|
+
try:
|
|
340
|
+
self._webhook_server = await asyncio.start_server(handle_request, "127.0.0.1", port)
|
|
341
|
+
logger.info(f"Webhook listener started on 127.0.0.1:{port}{expected_path}")
|
|
342
|
+
async with self._webhook_server:
|
|
343
|
+
await self._webhook_server.serve_forever()
|
|
344
|
+
except asyncio.CancelledError:
|
|
345
|
+
pass
|
|
346
|
+
except Exception as e:
|
|
347
|
+
task.status = "error"
|
|
348
|
+
task.error = str(e)
|
|
349
|
+
logger.warning(f"Webhook server error: {e}")
|
elidia/db/__init__.py
ADDED
|
File without changes
|