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.
@@ -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,3 @@
1
+ """CueAPI Worker — local pull-based daemon for executing scheduled tasks."""
2
+
3
+ __version__ = "0.1.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")