cueapi-worker 0.1.0__tar.gz
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.
- cueapi_worker-0.1.0/PKG-INFO +24 -0
- cueapi_worker-0.1.0/cueapi_worker/__init__.py +3 -0
- cueapi_worker-0.1.0/cueapi_worker/cli.py +153 -0
- cueapi_worker-0.1.0/cueapi_worker/client.py +146 -0
- cueapi_worker-0.1.0/cueapi_worker/config.py +138 -0
- cueapi_worker-0.1.0/cueapi_worker/daemon.py +194 -0
- cueapi_worker-0.1.0/cueapi_worker/executor.py +156 -0
- cueapi_worker-0.1.0/cueapi_worker/heartbeat.py +69 -0
- cueapi_worker-0.1.0/cueapi_worker/service.py +168 -0
- cueapi_worker-0.1.0/cueapi_worker.egg-info/PKG-INFO +24 -0
- cueapi_worker-0.1.0/cueapi_worker.egg-info/SOURCES.txt +20 -0
- cueapi_worker-0.1.0/cueapi_worker.egg-info/dependency_links.txt +1 -0
- cueapi_worker-0.1.0/cueapi_worker.egg-info/entry_points.txt +2 -0
- cueapi_worker-0.1.0/cueapi_worker.egg-info/requires.txt +3 -0
- cueapi_worker-0.1.0/cueapi_worker.egg-info/top_level.txt +1 -0
- cueapi_worker-0.1.0/pyproject.toml +42 -0
- cueapi_worker-0.1.0/setup.cfg +4 -0
- cueapi_worker-0.1.0/setup.py +4 -0
- cueapi_worker-0.1.0/tests/test_cli.py +32 -0
- cueapi_worker-0.1.0/tests/test_config.py +111 -0
- cueapi_worker-0.1.0/tests/test_daemon.py +157 -0
- cueapi_worker-0.1.0/tests/test_executor.py +105 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cueapi-worker
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Local pull-based worker daemon for CueAPI — executes scheduled tasks without a public URL
|
|
5
|
+
Author-email: "Vector Apps Inc." <hello@cueapi.ai>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://cueapi.ai
|
|
8
|
+
Project-URL: Documentation, https://docs.cueapi.ai
|
|
9
|
+
Project-URL: Repository, https://github.com/govindkavaturi-art/cueapi-worker
|
|
10
|
+
Keywords: cueapi,worker,daemon,scheduling,ai-agents
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
Requires-Dist: click>=8.0
|
|
23
|
+
Requires-Dist: httpx>=0.24
|
|
24
|
+
Requires-Dist: pyyaml>=6.0
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""CLI entry point for cueapi-worker."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
import platform
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from cueapi_worker import __version__
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.group()
|
|
14
|
+
@click.version_option(version=__version__, prog_name="cueapi-worker")
|
|
15
|
+
def main():
|
|
16
|
+
"""CueAPI Worker — local pull-based daemon for executing scheduled tasks."""
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@main.command()
|
|
21
|
+
@click.option(
|
|
22
|
+
"--config",
|
|
23
|
+
"-c",
|
|
24
|
+
required=True,
|
|
25
|
+
type=click.Path(exists=True),
|
|
26
|
+
help="Path to worker config YAML file.",
|
|
27
|
+
)
|
|
28
|
+
@click.option("--verbose", "-v", is_flag=True, help="Enable verbose (DEBUG) logging.")
|
|
29
|
+
def start(config: str, verbose: bool):
|
|
30
|
+
"""Start the worker daemon.
|
|
31
|
+
|
|
32
|
+
Polls CueAPI for claimable executions, matches them to handlers
|
|
33
|
+
defined in the config file, and executes them locally.
|
|
34
|
+
"""
|
|
35
|
+
# Configure logging
|
|
36
|
+
level = logging.DEBUG if verbose else logging.INFO
|
|
37
|
+
logging.basicConfig(
|
|
38
|
+
level=level,
|
|
39
|
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
40
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
from cueapi_worker.config import load_config
|
|
44
|
+
from cueapi_worker.daemon import WorkerDaemon
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
cfg = load_config(config)
|
|
48
|
+
except (FileNotFoundError, ValueError) as e:
|
|
49
|
+
click.echo(f"Error: {e}", err=True)
|
|
50
|
+
sys.exit(1)
|
|
51
|
+
|
|
52
|
+
click.echo(f"CueAPI Worker v{__version__}")
|
|
53
|
+
click.echo(f" Worker ID: {cfg.worker_id}")
|
|
54
|
+
click.echo(f" API Base: {cfg.api_base}")
|
|
55
|
+
click.echo(f" Handlers: {', '.join(cfg.handler_names())}")
|
|
56
|
+
click.echo(f" Poll interval: {cfg.poll_interval}s")
|
|
57
|
+
click.echo(f" Max concurrent: {cfg.max_concurrent}")
|
|
58
|
+
click.echo()
|
|
59
|
+
|
|
60
|
+
daemon = WorkerDaemon(cfg)
|
|
61
|
+
daemon.run()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@main.command()
|
|
65
|
+
@click.option(
|
|
66
|
+
"--config",
|
|
67
|
+
"-c",
|
|
68
|
+
required=True,
|
|
69
|
+
type=click.Path(exists=True),
|
|
70
|
+
help="Path to worker config YAML file.",
|
|
71
|
+
)
|
|
72
|
+
def status(config: str):
|
|
73
|
+
"""Check worker status by sending a heartbeat.
|
|
74
|
+
|
|
75
|
+
Verifies connectivity to the CueAPI server and reports
|
|
76
|
+
whether the worker can authenticate and communicate.
|
|
77
|
+
"""
|
|
78
|
+
from cueapi_worker.client import WorkerAPIClient
|
|
79
|
+
from cueapi_worker.config import load_config
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
cfg = load_config(config)
|
|
83
|
+
except (FileNotFoundError, ValueError) as e:
|
|
84
|
+
click.echo(f"Error: {e}", err=True)
|
|
85
|
+
sys.exit(1)
|
|
86
|
+
|
|
87
|
+
client = WorkerAPIClient(api_key=cfg.api_key, api_base=cfg.api_base)
|
|
88
|
+
ok = client.heartbeat(cfg.worker_id, cfg.handler_names())
|
|
89
|
+
|
|
90
|
+
if ok:
|
|
91
|
+
click.echo(f"Worker '{cfg.worker_id}' is connected and authenticated.")
|
|
92
|
+
click.echo(f" Handlers: {', '.join(cfg.handler_names())}")
|
|
93
|
+
else:
|
|
94
|
+
click.echo("Failed to connect to CueAPI. Check your API key and network.", err=True)
|
|
95
|
+
sys.exit(1)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@main.command("install-service")
|
|
99
|
+
@click.option(
|
|
100
|
+
"--config",
|
|
101
|
+
"-c",
|
|
102
|
+
required=True,
|
|
103
|
+
type=click.Path(exists=True),
|
|
104
|
+
help="Path to worker config YAML file.",
|
|
105
|
+
)
|
|
106
|
+
def install_service(config: str):
|
|
107
|
+
"""Install worker as a system service (launchd on macOS, systemd on Linux)."""
|
|
108
|
+
from cueapi_worker.service import install_launchd, install_systemd
|
|
109
|
+
|
|
110
|
+
system = platform.system()
|
|
111
|
+
try:
|
|
112
|
+
if system == "Darwin":
|
|
113
|
+
path = install_launchd(config)
|
|
114
|
+
click.echo(f"Installed launchd service: {path}")
|
|
115
|
+
click.echo("The worker will start automatically on login.")
|
|
116
|
+
elif system == "Linux":
|
|
117
|
+
path = install_systemd(config)
|
|
118
|
+
click.echo(f"Installed systemd service: {path}")
|
|
119
|
+
click.echo("The worker is now running and will start on boot.")
|
|
120
|
+
else:
|
|
121
|
+
click.echo(f"Unsupported platform: {system}. Use 'cueapi-worker start' directly.", err=True)
|
|
122
|
+
sys.exit(1)
|
|
123
|
+
except Exception as e:
|
|
124
|
+
click.echo(f"Error installing service: {e}", err=True)
|
|
125
|
+
sys.exit(1)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@main.command("uninstall-service")
|
|
129
|
+
def uninstall_service():
|
|
130
|
+
"""Uninstall the worker system service."""
|
|
131
|
+
from cueapi_worker.service import uninstall_launchd, uninstall_systemd
|
|
132
|
+
|
|
133
|
+
system = platform.system()
|
|
134
|
+
try:
|
|
135
|
+
if system == "Darwin":
|
|
136
|
+
ok = uninstall_launchd()
|
|
137
|
+
elif system == "Linux":
|
|
138
|
+
ok = uninstall_systemd()
|
|
139
|
+
else:
|
|
140
|
+
click.echo(f"Unsupported platform: {system}", err=True)
|
|
141
|
+
sys.exit(1)
|
|
142
|
+
|
|
143
|
+
if ok:
|
|
144
|
+
click.echo("Service uninstalled successfully.")
|
|
145
|
+
else:
|
|
146
|
+
click.echo("Service not found. Nothing to uninstall.")
|
|
147
|
+
except Exception as e:
|
|
148
|
+
click.echo(f"Error uninstalling service: {e}", err=True)
|
|
149
|
+
sys.exit(1)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
if __name__ == "__main__":
|
|
153
|
+
main()
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""HTTP client for CueAPI worker endpoints."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class WorkerAPIClient:
|
|
13
|
+
"""Thin wrapper around httpx for CueAPI worker API calls."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, api_key: str, api_base: str, timeout: float = 30.0):
|
|
16
|
+
self._api_key = api_key
|
|
17
|
+
self._api_base = api_base.rstrip("/")
|
|
18
|
+
self._timeout = timeout
|
|
19
|
+
|
|
20
|
+
def _headers(self) -> Dict[str, str]:
|
|
21
|
+
return {
|
|
22
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
23
|
+
"Content-Type": "application/json",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
def get_claimable(self, task: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
27
|
+
"""GET /v1/executions/claimable — list pending worker executions.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
task: Optional task name filter.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
List of claimable execution dicts.
|
|
34
|
+
"""
|
|
35
|
+
params = {}
|
|
36
|
+
if task:
|
|
37
|
+
params["task"] = task
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
resp = httpx.get(
|
|
41
|
+
f"{self._api_base}/v1/executions/claimable",
|
|
42
|
+
headers=self._headers(),
|
|
43
|
+
params=params,
|
|
44
|
+
timeout=self._timeout,
|
|
45
|
+
)
|
|
46
|
+
resp.raise_for_status()
|
|
47
|
+
return resp.json().get("executions", [])
|
|
48
|
+
except httpx.HTTPStatusError as e:
|
|
49
|
+
logger.error("Failed to get claimable: %s %s", e.response.status_code, e.response.text)
|
|
50
|
+
return []
|
|
51
|
+
except httpx.RequestError as e:
|
|
52
|
+
logger.error("Request error getting claimable: %s", e)
|
|
53
|
+
return []
|
|
54
|
+
|
|
55
|
+
def claim(self, execution_id: str, worker_id: str) -> Optional[Dict[str, Any]]:
|
|
56
|
+
"""POST /v1/executions/{id}/claim — claim an execution.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Claim response dict if successful, None if claim failed (409 or error).
|
|
60
|
+
"""
|
|
61
|
+
try:
|
|
62
|
+
resp = httpx.post(
|
|
63
|
+
f"{self._api_base}/v1/executions/{execution_id}/claim",
|
|
64
|
+
headers=self._headers(),
|
|
65
|
+
json={"worker_id": worker_id},
|
|
66
|
+
timeout=self._timeout,
|
|
67
|
+
)
|
|
68
|
+
if resp.status_code == 409:
|
|
69
|
+
logger.debug("Execution %s already claimed", execution_id)
|
|
70
|
+
return None
|
|
71
|
+
resp.raise_for_status()
|
|
72
|
+
return resp.json()
|
|
73
|
+
except httpx.HTTPStatusError as e:
|
|
74
|
+
logger.error("Failed to claim %s: %s %s", execution_id, e.response.status_code, e.response.text)
|
|
75
|
+
return None
|
|
76
|
+
except httpx.RequestError as e:
|
|
77
|
+
logger.error("Request error claiming %s: %s", execution_id, e)
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
def report_outcome(
|
|
81
|
+
self,
|
|
82
|
+
execution_id: str,
|
|
83
|
+
success: bool,
|
|
84
|
+
result: Optional[str] = None,
|
|
85
|
+
error: Optional[str] = None,
|
|
86
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
87
|
+
) -> bool:
|
|
88
|
+
"""POST /v1/executions/{id}/outcome — report execution result.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
True if outcome was recorded, False otherwise.
|
|
92
|
+
"""
|
|
93
|
+
body: Dict[str, Any] = {"success": success}
|
|
94
|
+
if result is not None:
|
|
95
|
+
body["result"] = result
|
|
96
|
+
if error is not None:
|
|
97
|
+
body["error"] = error
|
|
98
|
+
if metadata is not None:
|
|
99
|
+
body["metadata"] = metadata
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
resp = httpx.post(
|
|
103
|
+
f"{self._api_base}/v1/executions/{execution_id}/outcome",
|
|
104
|
+
headers=self._headers(),
|
|
105
|
+
json=body,
|
|
106
|
+
timeout=self._timeout,
|
|
107
|
+
)
|
|
108
|
+
resp.raise_for_status()
|
|
109
|
+
return True
|
|
110
|
+
except httpx.HTTPStatusError as e:
|
|
111
|
+
logger.error(
|
|
112
|
+
"Failed to report outcome for %s: %s %s",
|
|
113
|
+
execution_id,
|
|
114
|
+
e.response.status_code,
|
|
115
|
+
e.response.text,
|
|
116
|
+
)
|
|
117
|
+
return False
|
|
118
|
+
except httpx.RequestError as e:
|
|
119
|
+
logger.error("Request error reporting outcome for %s: %s", execution_id, e)
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
def heartbeat(self, worker_id: str, handlers: Optional[List[str]] = None) -> bool:
|
|
123
|
+
"""POST /v1/worker/heartbeat — register/update worker.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
True if heartbeat acknowledged, False otherwise.
|
|
127
|
+
"""
|
|
128
|
+
body: Dict[str, Any] = {"worker_id": worker_id}
|
|
129
|
+
if handlers is not None:
|
|
130
|
+
body["handlers"] = handlers
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
resp = httpx.post(
|
|
134
|
+
f"{self._api_base}/v1/worker/heartbeat",
|
|
135
|
+
headers=self._headers(),
|
|
136
|
+
json=body,
|
|
137
|
+
timeout=self._timeout,
|
|
138
|
+
)
|
|
139
|
+
resp.raise_for_status()
|
|
140
|
+
return True
|
|
141
|
+
except httpx.HTTPStatusError as e:
|
|
142
|
+
logger.error("Heartbeat failed: %s %s", e.response.status_code, e.response.text)
|
|
143
|
+
return False
|
|
144
|
+
except httpx.RequestError as e:
|
|
145
|
+
logger.error("Heartbeat request error: %s", e)
|
|
146
|
+
return False
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Configuration loading for cueapi-worker."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class HandlerConfig:
|
|
16
|
+
"""Configuration for a single task handler."""
|
|
17
|
+
|
|
18
|
+
cmd: str
|
|
19
|
+
cwd: Optional[str] = None
|
|
20
|
+
timeout: int = 300
|
|
21
|
+
env: Optional[Dict[str, str]] = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class WorkerConfig:
|
|
26
|
+
"""Top-level worker configuration."""
|
|
27
|
+
|
|
28
|
+
api_key: str
|
|
29
|
+
api_base: str = "https://api.cueapi.ai"
|
|
30
|
+
worker_id: str = ""
|
|
31
|
+
poll_interval: int = 5
|
|
32
|
+
heartbeat_interval: int = 30
|
|
33
|
+
max_concurrent: int = 4
|
|
34
|
+
handlers: Dict[str, HandlerConfig] = field(default_factory=dict)
|
|
35
|
+
|
|
36
|
+
def handler_names(self) -> List[str]:
|
|
37
|
+
"""Return list of handler task names."""
|
|
38
|
+
return list(self.handlers.keys())
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _resolve_api_key(raw: Optional[str]) -> str:
|
|
42
|
+
"""Resolve API key: config value > CUEAPI_API_KEY env > credentials file."""
|
|
43
|
+
# 1. Explicit value from config
|
|
44
|
+
if raw:
|
|
45
|
+
return raw
|
|
46
|
+
|
|
47
|
+
# 2. Environment variable
|
|
48
|
+
env_key = os.environ.get("CUEAPI_API_KEY")
|
|
49
|
+
if env_key:
|
|
50
|
+
return env_key
|
|
51
|
+
|
|
52
|
+
# 3. Credentials file (same as CLI)
|
|
53
|
+
creds_path = Path.home() / ".config" / "cueapi" / "credentials.json"
|
|
54
|
+
if creds_path.exists():
|
|
55
|
+
try:
|
|
56
|
+
data = json.loads(creds_path.read_text())
|
|
57
|
+
default = data.get("default", {})
|
|
58
|
+
key = default.get("api_key")
|
|
59
|
+
if key:
|
|
60
|
+
return key
|
|
61
|
+
except (json.JSONDecodeError, KeyError):
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
raise ValueError(
|
|
65
|
+
"No API key found. Set 'api_key' in config, CUEAPI_API_KEY env var, "
|
|
66
|
+
"or run 'cueapi login' to save credentials."
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _default_worker_id() -> str:
|
|
71
|
+
"""Generate default worker_id from hostname."""
|
|
72
|
+
return platform.node() or "worker-unknown"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def load_config(path: str) -> WorkerConfig:
|
|
76
|
+
"""Load and validate worker config from a YAML file.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
path: Path to the YAML config file.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
Validated WorkerConfig instance.
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
FileNotFoundError: If config file doesn't exist.
|
|
86
|
+
ValueError: If config is invalid.
|
|
87
|
+
"""
|
|
88
|
+
config_path = Path(path)
|
|
89
|
+
if not config_path.exists():
|
|
90
|
+
raise FileNotFoundError(f"Config file not found: {path}")
|
|
91
|
+
|
|
92
|
+
with open(config_path) as f:
|
|
93
|
+
raw = yaml.safe_load(f)
|
|
94
|
+
|
|
95
|
+
if not isinstance(raw, dict):
|
|
96
|
+
raise ValueError(f"Invalid config file: expected a YAML mapping, got {type(raw).__name__}")
|
|
97
|
+
|
|
98
|
+
# Resolve API key
|
|
99
|
+
api_key = _resolve_api_key(raw.get("api_key"))
|
|
100
|
+
|
|
101
|
+
# Parse handlers
|
|
102
|
+
handlers: Dict[str, HandlerConfig] = {}
|
|
103
|
+
raw_handlers = raw.get("handlers", {})
|
|
104
|
+
if not isinstance(raw_handlers, dict):
|
|
105
|
+
raise ValueError("'handlers' must be a mapping of task_name -> handler config")
|
|
106
|
+
|
|
107
|
+
for task_name, handler_raw in raw_handlers.items():
|
|
108
|
+
if isinstance(handler_raw, str):
|
|
109
|
+
# Short form: just a command string
|
|
110
|
+
handlers[task_name] = HandlerConfig(cmd=handler_raw)
|
|
111
|
+
elif isinstance(handler_raw, dict):
|
|
112
|
+
cmd = handler_raw.get("cmd")
|
|
113
|
+
if not cmd:
|
|
114
|
+
raise ValueError(f"Handler '{task_name}' must have a 'cmd' field")
|
|
115
|
+
handlers[task_name] = HandlerConfig(
|
|
116
|
+
cmd=cmd,
|
|
117
|
+
cwd=handler_raw.get("cwd"),
|
|
118
|
+
timeout=handler_raw.get("timeout", 300),
|
|
119
|
+
env=handler_raw.get("env"),
|
|
120
|
+
)
|
|
121
|
+
else:
|
|
122
|
+
raise ValueError(f"Handler '{task_name}' must be a string or mapping")
|
|
123
|
+
|
|
124
|
+
if not handlers:
|
|
125
|
+
raise ValueError("At least one handler must be defined")
|
|
126
|
+
|
|
127
|
+
# Build config
|
|
128
|
+
worker_id = raw.get("worker_id") or _default_worker_id()
|
|
129
|
+
|
|
130
|
+
return WorkerConfig(
|
|
131
|
+
api_key=api_key,
|
|
132
|
+
api_base=raw.get("api_base", "https://api.cueapi.ai"),
|
|
133
|
+
worker_id=worker_id,
|
|
134
|
+
poll_interval=raw.get("poll_interval", 5),
|
|
135
|
+
heartbeat_interval=raw.get("heartbeat_interval", 30),
|
|
136
|
+
max_concurrent=raw.get("max_concurrent", 4),
|
|
137
|
+
handlers=handlers,
|
|
138
|
+
)
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Worker daemon — main poll loop that claims and executes tasks."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
import signal
|
|
6
|
+
import time
|
|
7
|
+
from concurrent.futures import ThreadPoolExecutor, Future
|
|
8
|
+
from typing import Dict, Set
|
|
9
|
+
|
|
10
|
+
from cueapi_worker.client import WorkerAPIClient
|
|
11
|
+
from cueapi_worker.config import WorkerConfig
|
|
12
|
+
from cueapi_worker.executor import execute_handler
|
|
13
|
+
from cueapi_worker.heartbeat import HeartbeatThread
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class WorkerDaemon:
|
|
19
|
+
"""Main worker daemon.
|
|
20
|
+
|
|
21
|
+
Poll loop: get_claimable -> match handler -> claim -> execute in
|
|
22
|
+
ThreadPoolExecutor -> report outcome. Respects max_concurrent.
|
|
23
|
+
Handles SIGINT/SIGTERM gracefully.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, config: WorkerConfig):
|
|
27
|
+
self._config = config
|
|
28
|
+
self._client = WorkerAPIClient(
|
|
29
|
+
api_key=config.api_key,
|
|
30
|
+
api_base=config.api_base,
|
|
31
|
+
)
|
|
32
|
+
self._heartbeat = HeartbeatThread(
|
|
33
|
+
client=self._client,
|
|
34
|
+
worker_id=config.worker_id,
|
|
35
|
+
handlers=config.handler_names(),
|
|
36
|
+
interval=config.heartbeat_interval,
|
|
37
|
+
)
|
|
38
|
+
self._executor = ThreadPoolExecutor(
|
|
39
|
+
max_workers=config.max_concurrent,
|
|
40
|
+
thread_name_prefix="cueapi-handler",
|
|
41
|
+
)
|
|
42
|
+
self._running = False
|
|
43
|
+
self._active_futures: Set[Future] = set()
|
|
44
|
+
|
|
45
|
+
def run(self) -> None:
|
|
46
|
+
"""Start the daemon and run until interrupted."""
|
|
47
|
+
self._running = True
|
|
48
|
+
|
|
49
|
+
# Install signal handlers
|
|
50
|
+
signal.signal(signal.SIGINT, self._handle_signal)
|
|
51
|
+
signal.signal(signal.SIGTERM, self._handle_signal)
|
|
52
|
+
|
|
53
|
+
logger.info(
|
|
54
|
+
"Worker daemon starting: worker_id=%s, handlers=%s, poll_interval=%ds, max_concurrent=%d",
|
|
55
|
+
self._config.worker_id,
|
|
56
|
+
self._config.handler_names(),
|
|
57
|
+
self._config.poll_interval,
|
|
58
|
+
self._config.max_concurrent,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# Start heartbeat thread
|
|
62
|
+
self._heartbeat.start()
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
while self._running:
|
|
66
|
+
self._poll_cycle()
|
|
67
|
+
if self._running:
|
|
68
|
+
time.sleep(self._config.poll_interval)
|
|
69
|
+
finally:
|
|
70
|
+
self._shutdown()
|
|
71
|
+
|
|
72
|
+
def _poll_cycle(self) -> None:
|
|
73
|
+
"""Run one poll cycle: fetch claimable, match, claim, execute."""
|
|
74
|
+
# Clean up completed futures
|
|
75
|
+
done = {f for f in self._active_futures if f.done()}
|
|
76
|
+
self._active_futures -= done
|
|
77
|
+
|
|
78
|
+
# Check available capacity
|
|
79
|
+
available = self._config.max_concurrent - len(self._active_futures)
|
|
80
|
+
if available <= 0:
|
|
81
|
+
logger.debug("At max concurrent (%d), skipping poll", self._config.max_concurrent)
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
# Fetch claimable executions
|
|
85
|
+
executions = self._client.get_claimable()
|
|
86
|
+
if not executions:
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
claimed = 0
|
|
90
|
+
for exec_data in executions:
|
|
91
|
+
if claimed >= available:
|
|
92
|
+
break
|
|
93
|
+
|
|
94
|
+
task = exec_data.get("task")
|
|
95
|
+
execution_id = exec_data.get("execution_id")
|
|
96
|
+
|
|
97
|
+
# Match handler
|
|
98
|
+
handler_config = self._config.handlers.get(task) if task else None
|
|
99
|
+
if handler_config is None:
|
|
100
|
+
logger.debug(
|
|
101
|
+
"No handler for task=%s, skipping execution=%s",
|
|
102
|
+
task,
|
|
103
|
+
execution_id,
|
|
104
|
+
)
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
# Claim
|
|
108
|
+
claim_result = self._client.claim(execution_id, self._config.worker_id)
|
|
109
|
+
if claim_result is None:
|
|
110
|
+
logger.debug("Failed to claim execution=%s", execution_id)
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
claimed += 1
|
|
114
|
+
logger.info(
|
|
115
|
+
"Claimed execution=%s task=%s cue=%s",
|
|
116
|
+
execution_id,
|
|
117
|
+
task,
|
|
118
|
+
exec_data.get("cue_name"),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# Submit to thread pool
|
|
122
|
+
future = self._executor.submit(
|
|
123
|
+
self._execute_and_report,
|
|
124
|
+
handler_config,
|
|
125
|
+
exec_data,
|
|
126
|
+
)
|
|
127
|
+
self._active_futures.add(future)
|
|
128
|
+
|
|
129
|
+
if claimed > 0:
|
|
130
|
+
logger.info("Poll cycle: claimed %d executions", claimed)
|
|
131
|
+
|
|
132
|
+
def _execute_and_report(self, handler_config, exec_data: Dict) -> None:
|
|
133
|
+
"""Execute a handler and report the outcome. Runs in thread pool."""
|
|
134
|
+
execution_id = exec_data.get("execution_id", "")
|
|
135
|
+
cue_id = exec_data.get("cue_id", "")
|
|
136
|
+
cue_name = exec_data.get("cue_name", "")
|
|
137
|
+
payload = exec_data.get("payload", {})
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
success, output = execute_handler(
|
|
141
|
+
handler=handler_config,
|
|
142
|
+
payload=payload,
|
|
143
|
+
execution_id=execution_id,
|
|
144
|
+
cue_id=cue_id,
|
|
145
|
+
cue_name=cue_name,
|
|
146
|
+
worker_id=self._config.worker_id,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# Report outcome
|
|
150
|
+
if success:
|
|
151
|
+
self._client.report_outcome(
|
|
152
|
+
execution_id=execution_id,
|
|
153
|
+
success=True,
|
|
154
|
+
result=output or "completed",
|
|
155
|
+
)
|
|
156
|
+
else:
|
|
157
|
+
self._client.report_outcome(
|
|
158
|
+
execution_id=execution_id,
|
|
159
|
+
success=False,
|
|
160
|
+
error=output or "handler failed",
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
except Exception as e:
|
|
164
|
+
logger.error("Unhandled error executing %s: %s", execution_id, e)
|
|
165
|
+
self._client.report_outcome(
|
|
166
|
+
execution_id=execution_id,
|
|
167
|
+
success=False,
|
|
168
|
+
error=f"Worker error: {e}",
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def _handle_signal(self, signum: int, frame) -> None:
|
|
172
|
+
"""Handle SIGINT/SIGTERM gracefully."""
|
|
173
|
+
sig_name = signal.Signals(signum).name
|
|
174
|
+
logger.info("Received %s, shutting down gracefully...", sig_name)
|
|
175
|
+
self._running = False
|
|
176
|
+
|
|
177
|
+
def _shutdown(self) -> None:
|
|
178
|
+
"""Clean shutdown: stop heartbeat, wait for active tasks."""
|
|
179
|
+
logger.info("Shutting down worker daemon...")
|
|
180
|
+
|
|
181
|
+
# Stop heartbeat
|
|
182
|
+
self._heartbeat.stop()
|
|
183
|
+
|
|
184
|
+
# Wait for active tasks to complete
|
|
185
|
+
if self._active_futures:
|
|
186
|
+
logger.info("Waiting for %d active tasks to complete...", len(self._active_futures))
|
|
187
|
+
for future in self._active_futures:
|
|
188
|
+
try:
|
|
189
|
+
future.result(timeout=30)
|
|
190
|
+
except Exception as e:
|
|
191
|
+
logger.error("Task error during shutdown: %s", e)
|
|
192
|
+
|
|
193
|
+
self._executor.shutdown(wait=False)
|
|
194
|
+
logger.info("Worker daemon stopped")
|