diff --git a/examples/service/README.md b/examples/service/README.md new file mode 100644 index 0000000..1fe04d8 --- /dev/null +++ b/examples/service/README.md @@ -0,0 +1,273 @@ +# ROSE Service Examples + +This directory contains examples for running ROSE workflows through the **ROSE Service** — a daemon-based workflow manager that accepts workflow submissions, tracks execution state, and provides real-time status updates. + +The service uses file-based IPC: the manager polls a local directory for request files, and clients (CLI or Python) write JSON requests into that directory. A shared `registry.json` file reflects the live state of all workflows. + +**Files in this directory:** + +| File | Description | +|------|-------------| +| `service_test.yaml` | Minimal test workflow using `/bin/echo` (no dependencies required) | +| `service_real.yaml` | Real workflow using `ParallelActiveLearner` with Python scripts | +| `debug_workflow.yaml` | Fast test workflow for plugin testing (2 iterations) | +| `run_service.py` | Integration example: launches, submits, monitors, and shuts down programmatically | +| `verify_service.py` | Demonstrates workflow cancellation flow | +| `example_rose_plugin.py` | REST API example using RADICAL-Edge plugin | +| `example_minimal.py` | Minimal example (30 lines) for quick reference | + +--- + +## Workflow YAML Format + +Both examples use a YAML file to define the workflow. The service loads this file, instantiates the appropriate learner, and registers the component tasks. + +```yaml +learner: + type: SequentialActiveLearner # or ParallelActiveLearner + +components: + simulation: + type: script # or "function" for a Python callable + path: /bin/echo + config: + args: ["Simulation Step"] + training: + type: script + path: /bin/echo + config: + args: ["Training Step"] + active_learn: + type: script + path: /bin/echo + config: + args: ["AL Step"] + +config: + max_iterations: 3 + work_dir: /tmp/rose_test +``` + +--- + +## Option 1 — CLI (Two Terminals) + +The CLI is the simplest way to interact with ROSE service. You need two terminal sessions: one to run the service daemon and one to submit and monitor workflows. + +> The `--job-id` flag identifies the service instance. If you are inside a SLURM job, it defaults to `$SLURM_JOB_ID`. For local usage, it defaults to `local_job_0`. Both terminals must use the same job ID. + +### Terminal 1 — Start the Service + +```bash +rose launch +``` + +The service starts and blocks, printing log output as workflows are received and executed. Keep this terminal open for the lifetime of the session. + +To use a custom job ID (e.g. for running multiple isolated services): + +```bash +rose launch --job-id job.000001 +``` + +### Terminal 2 — Submit and Monitor a Workflow + +**Submit a workflow:** + +```bash +rose submit --job-id job.000001 examples/service/service_test.yaml +``` + +Output: + +``` +Submitted workflow request. +Request ID: 3f2a1b4c-... +Workflow ID: wf.3f2a1b4c +``` + +**Check workflow status:** + +```bash +rose status wf.3f2a1b4c +``` + +Output (example): + +```json +{ + "wf_id": "wf.3f2a1b4c", + "state": "running", + "workflow_file": "/path/to/service_test.yaml", + "start_time": 1700000000.0, + "end_time": null, + "stats": { + "iteration": 2, + "metric_value": null + }, + "error": null +} +``` + +**List all workflows:** + +```bash +rose status +``` + +**Cancel a running workflow:** + +```bash +rose cancel wf.3f2a1b4c +``` + +**Shut down the service when done:** + +```bash +rose shutdown +``` + +> The service in Terminal 1 will exit gracefully after receiving the shutdown request. + +If you launched with a custom `--job-id`, pass the same flag to all client commands: + +```bash +rose submit --job-id my_session examples/service/service_test.yaml +rose status --job-id my_session +rose shutdown --job-id my_session +``` + +--- + +## Option 2 — Python Client + +Use `ServiceClient` from `rose.service.client` to drive the service programmatically — useful for integration scripts, notebooks, or automated pipelines. + +See [`run_service.py`](run_service.py) for a complete working example. It covers the full lifecycle: + +1. Start `ServiceManager` as a background `asyncio` task +2. Initialize `ServiceClient` with the same job ID +3. Call `client.submit_workflow(path)` to submit a YAML workflow +4. Derive the workflow ID with `ServiceClient.get_wf_id(req_id)` +5. Poll `client.get_workflow_status(wf_id)` until the state reaches `COMPLETED`, `FAILED`, or `CANCELED` +6. Call `client.shutdown()` to stop the service + +Run it with: + +```bash +python examples/service/run_service.py +``` + +See [`verify_service.py`](verify_service.py) for an example of cancellation via `client.cancel_workflow(wf_id)`. + +**Key `ServiceClient` methods:** + +| Method | Description | +|--------|-------------| +| `submit_workflow(path)` | Submit a YAML workflow file; returns a `req_id` | +| `ServiceClient.get_wf_id(req_id)` | Derive the workflow ID from a request ID | +| `get_workflow_status(wf_id)` | Return the current state dict for a workflow | +| `list_workflows()` | Return all workflows from the registry | +| `cancel_workflow(wf_id)` | Request cancellation of a running workflow | +| `shutdown()` | Send a graceful shutdown request to the service | + +--- + +## Option 3 — REST API via RADICAL-Edge Plugin + +The ROSE plugin for RADICAL-Edge provides a REST API for workflow management. This is the recommended approach for remote access and integration with other services. + +**Architecture:** +``` +Client (Python/curl/browser) + ↓ HTTP/REST +RADICAL-Edge Bridge + ↓ WebSocket +Edge Service (with ROSE plugin) + ↓ +WorkflowEngine / Learners (embedded) +``` + +The plugin embeds workflow execution directly — no separate `rose launch` daemon required. + +### Prerequisites + +1. RADICAL-Edge bridge running +2. RADICAL-Edge service running with ROSE plugin loaded + +### REST Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `POST` | `/rose/register_session` | Register a new session | +| `POST` | `/rose/submit/{sid}` | Submit a workflow | +| `GET` | `/rose/status/{sid}/{wf_id}` | Get workflow status | +| `GET` | `/rose/workflows/{sid}` | List all workflows | +| `POST` | `/rose/cancel/{sid}/{wf_id}` | Cancel a workflow | +| `POST` | `/rose/unregister_session/{sid}` | Close session | + +### Python Client Example + +See [`example_rose_plugin.py`](example_rose_plugin.py) for a complete working example. + +```python +from radical.edge import BridgeClient +import rose.service.api.rest # Register plugin + +# Connect to bridge +bc = BridgeClient(url='https://localhost:8000') +edges = bc.list_edges() +ec = bc.get_edge_client(edges[0]) + +# Get ROSE plugin client +rose = ec.get_plugin('rose') + +# Submit workflow +result = rose.submit_workflow('/path/to/workflow.yaml') +wf_id = result['wf_id'] + +# Monitor status +status = rose.get_workflow_status(wf_id) +print(f"State: {status['state']}") + +# List all workflows +workflows = rose.list_workflows() + +# Cancel if needed +rose.cancel_workflow(wf_id) + +# Cleanup +rose.close() +bc.close() +``` + +### Notifications + +The plugin sends real-time notifications via SSE when workflow state changes: + +```python +def on_state_change(topic, data): + print(f"Workflow {data['wf_id']}: {data['state']}") + +rose.on_workflow_state(on_state_change) +``` + +### Running the Example + +```bash +# Set bridge URL +export RADICAL_BRIDGE_URL=https://localhost:8000 + +# Run example +python example_rose_plugin.py --workflow debug_workflow.yaml +``` + +--- + +## Additional Files + +| File | Description | +|------|-------------| +| `example_rose_plugin.py` | REST API example using RADICAL-Edge plugin | +| `example_minimal.py` | Minimal example (30 lines) for quick reference | +| `debug_workflow.yaml` | Fast test workflow (2 iterations, ~6 seconds) | diff --git a/examples/service/debug_workflow.yaml b/examples/service/debug_workflow.yaml new file mode 100644 index 0000000..151a905 --- /dev/null +++ b/examples/service/debug_workflow.yaml @@ -0,0 +1,39 @@ +# Debug Workflow for ROSE Plugin Testing +# +# This workflow uses simple echo commands to quickly test +# the ROSE service and plugin without heavy computation. +# +# Usage: +# python example_rose_client.py --workflow debug_workflow.yaml + +learner: + type: SequentialActiveLearner + +components: + simulation: + type: script + path: /bin/bash + config: + args: + - "-c" + - "echo '[SIM] Iteration $ROSE_ITERATION' && sleep 1" + + training: + type: script + path: /bin/bash + config: + args: + - "-c" + - "echo '[TRAIN] Training model...' && sleep 1" + + active_learn: + type: script + path: /bin/bash + config: + args: + - "-c" + - "echo '[AL] Active learning step' && sleep 1" + +config: + max_iterations: 2 + work_dir: /tmp/rose_debug diff --git a/examples/service/example_rose_plugin.py b/examples/service/example_rose_plugin.py new file mode 100755 index 0000000..aa52db5 --- /dev/null +++ b/examples/service/example_rose_plugin.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +""" +Example: ROSE Workflow Execution via RADICAL-Edge Plugin + +This example demonstrates how to submit and monitor Active Learning +workflows using the ROSE plugin for RADICAL-Edge. + +Architecture: + Client (this script) + | HTTP/REST + v + RADICAL-Edge Bridge + | WebSocket + v + Edge Service (ROSE plugin with embedded workflow execution) + +Prerequisites: + 1. RADICAL-Edge bridge running + 2. RADICAL-Edge service running with ROSE plugin loaded + +Usage: + export RADICAL_BRIDGE_URL=https://localhost:8443 + python example_rose_plugin.py --workflow debug_workflow.yaml +""" + +import argparse +import logging +import os +import sys +import time +from pathlib import Path + +# Configure logging +logging.basicConfig( + level=logging.DEBUG, + format="%(asctime)s | %(levelname)-8s | %(name)-20s | %(message)s", + datefmt="%H:%M:%S", +) + +# Reduce noise from HTTP libraries +for name in ["httpx", "httpcore", "urllib3", "hpack"]: + logging.getLogger(name).setLevel(logging.WARNING) + +log = logging.getLogger("rose.example") + + +def on_state_change(topic: str, data: dict): + """Callback for workflow state change notifications.""" + wf_id = data.get("wf_id", "?") + state = data.get("state", "?") + stats = data.get("stats", {}) + + if stats: + iteration = stats.get("iteration", "-") + metric = stats.get("metric_value", "-") + log.info(f"[{wf_id}] {state} (iteration={iteration}, metric={metric})") + elif wf_id and state: + log.info(f"[{wf_id}] {state}") + else: + log.info(f"[NOTIFY] {topic}: {data}") + + +def main(): + parser = argparse.ArgumentParser( + description="Submit ROSE workflow via RADICAL-Edge plugin", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + parser.add_argument( + "--workflow", + "-w", + default="debug_workflow.yaml", + help="Path to workflow YAML file (default: debug_workflow.yaml)", + ) + parser.add_argument( + "--bridge-url", + "-b", + default=os.environ.get("RADICAL_BRIDGE_URL", "https://localhost:8443"), + help="Bridge URL (default: $RADICAL_BRIDGE_URL or https://localhost:8443)", + ) + parser.add_argument( + "--timeout", "-t", type=int, default=300, help="Timeout in seconds (default: 300)" + ) + args = parser.parse_args() + + # Resolve workflow path + workflow = Path(args.workflow) + if not workflow.exists(): + workflow = Path(__file__).parent / args.workflow + if not workflow.exists(): + log.error(f"Workflow not found: {args.workflow}") + sys.exit(1) + + workflow = workflow.resolve() + + log.info(f"Bridge: {args.bridge_url}") + log.info(f"Workflow: {workflow}") + log.info("-" * 60) + + # Import dependencies + from radical.edge import BridgeClient + + import rose.service.api.rest # Register ROSE plugin client class + + # Connect to bridge + try: + bc = BridgeClient(url=args.bridge_url) + edges = bc.list_edges() + except Exception as e: + log.error(f"Cannot connect to bridge: {e}") + sys.exit(1) + + if not edges: + log.error("No edges connected to bridge") + sys.exit(1) + + edge_id = edges[0] + log.info(f"Using edge: {edge_id}") + + # Get ROSE plugin client + try: + ec = bc.get_edge_client(edge_id) + rose = ec.get_plugin("rose") + except Exception as e: + log.error(f"Cannot get ROSE plugin: {e}") + log.error("Make sure ROSE plugin is loaded on edge service.") + sys.exit(1) + + log.info(f"Session: {rose.sid}") + rose.on_workflow_state(on_state_change) + + try: + # Submit workflow + log.info(f"Submitting {workflow}...") + result = rose.submit_workflow(str(workflow.absolute())) + wf_id = result["wf_id"] + log.info(f"Submitted: {wf_id}") + + # Monitor status + log.info("Monitoring (Ctrl+C to cancel)...") + start_time = time.time() + terminal = {"COMPLETED", "FAILED", "CANCELED"} + last_state = None + + while time.time() - start_time < args.timeout: + time.sleep(2) + + try: + status = rose.get_workflow_status(wf_id) + state = status.get("state", "UNKNOWN") + if state != last_state: + log.info(f"State: {state}") + last_state = state + if state in terminal: + if state == "FAILED": + log.error(f"Error: {status.get('error')}") + break + except Exception as e: + log.warning(f"Status error: {e}") + + state = status.get("state", "UNKNOWN") + + if state in terminal: + log.info("-" * 50) + if state == "COMPLETED": + log.info(f"Workflow {wf_id} completed successfully") + elif state == "FAILED": + log.error(f"Workflow {wf_id} failed: {status.get('error')}") + else: + log.warning(f"Workflow {wf_id} was canceled") + break + else: + log.warning(f"Timeout after {args.timeout}s") + + # Show final workflow list + log.info("-" * 60) + for wid, info in rose.list_workflows().items(): + log.info(f" {wid}: {info.get('state')}") + + except KeyboardInterrupt: + log.warning("Interrupted by user") + + finally: + rose.off_workflow_state(on_state_change) + rose.close() + bc.close() + log.info("Done") + + +if __name__ == "__main__": + main() diff --git a/examples/service/run_service.py b/examples/service/run_service.py new file mode 100644 index 0000000..3911756 --- /dev/null +++ b/examples/service/run_service.py @@ -0,0 +1,120 @@ +import asyncio +import json +import logging +import os +import shutil +from pathlib import Path + +from rose.service.client import ServiceClient +from rose.service.manager import ServiceManager + +# Configure logging to see what's happening +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger("run_service") + +# Job ID for this service instance +JOB_ID = "rose_service_run" +SERVICE_ROOT = Path.home() / ".rose" / "services" / JOB_ID + + +def cleanup(): + if SERVICE_ROOT.exists(): + logger.info(f"Cleaning up previous service root at {SERVICE_ROOT}") + shutil.rmtree(SERVICE_ROOT) + + +async def run_workflow(): + print(f"--- Starting ROSE Service for Job {JOB_ID} ---") + cleanup() + + # 1. Start Service Manager in a background task + manager = ServiceManager(JOB_ID) + service_task = asyncio.create_task(manager.run()) + + # Wait for service initialization + await asyncio.sleep(1) + + # 2. Initialize Client + client = ServiceClient(JOB_ID) + + # 3. Submit the realistic workflow + wf_path = "service_real.yaml" + if not os.path.exists(wf_path): + print(f"Error: Workflow file not found at {wf_path}") + await manager.shutdown() + return + + print(f"Submitting workflow: {wf_path}") + req_id = client.submit_workflow(wf_path) + print(f"Submitted. Request ID: {req_id}") + + # 4. Wait for workflow to be picked up and assigned a wf_id + wf_id = None + print("Waiting for service to assign Workflow ID...") + for _ in range(20): + await asyncio.sleep(1) + registry = client.list_workflows() + if registry: + wf_id = list(registry.keys())[0] + print(f"Assigned Workflow ID: {wf_id}") + break + + if not wf_id: + print("Error: Workflow was not picked up by the service.") + await manager.shutdown() + return + + # 5. Monitor progress until completion + print(f"Monitoring workflow {wf_id}...") + last_state = None + while True: + status = client.get_workflow_status(wf_id) + if not status: + print("Error: Workflow status lost.") + break + + current_state = status.get("state") + if current_state != last_state: + print(f"Status Change: {current_state}") + last_state = current_state + + if current_state in ["COMPLETED", "FAILED", "CANCELED"]: + print(f"Workflow reached terminal state: {current_state}") + if current_state == "FAILED": + print(f"Error Details: {status.get('error')}") + break + + # Optional: Print iteration progress if available in stats + stats = status.get("stats", {}) + if "iteration" in stats: + print( + f" Iteration: {stats['iteration']} | Metric: {stats.get('metric_value', 'N/A')}", + end="\r", + ) + + await asyncio.sleep(2) + + # 6. Final Report + status = client.get_workflow_status(wf_id) + print("\n--- Final Workflow Status ---") + print(json.dumps(status, indent=2)) + + # 7. Graceful Shutdown + print("Shutting down service...") + await manager.shutdown() + try: + # Give some time for internal tasks to finish before canceling the service loop + await asyncio.wait_for(service_task, timeout=5) + except (asyncio.TimeoutError, asyncio.CancelledError): + pass + + print("--- Service Run Finished ---") + + +if __name__ == "__main__": + try: + asyncio.run(run_workflow()) + except KeyboardInterrupt: + print("\nInterrupted by user.") diff --git a/examples/service/service_real.yaml b/examples/service/service_real.yaml new file mode 100644 index 0000000..c4544d4 --- /dev/null +++ b/examples/service/service_real.yaml @@ -0,0 +1,24 @@ +learner: + path: rose.al.active_learner.ParallelActiveLearner + +components: + simulation: + type: script + path: /home/aymen/ve/raas/bin/python3 + config: + args: ["/home/aymen/RADICAL/ROSE-AS-A-SERVICE/ROSE/examples/active_learn/basic/sim.py"] + training: + type: script + path: /home/aymen/ve/raas/bin/python3 + config: + args: ["/home/aymen/RADICAL/ROSE-AS-A-SERVICE/ROSE/examples/active_learn/basic/train.py"] + active_learn: + type: script + path: /home/aymen/ve/raas/bin/python3 + config: + args: ["/home/aymen/RADICAL/ROSE-AS-A-SERVICE/ROSE/examples/active_learn/basic/active.py"] + +config: + parallel_learners: 4 + max_iterations: 10 + work_dir: /tmp/rose_test diff --git a/examples/service/service_test.yaml b/examples/service/service_test.yaml new file mode 100644 index 0000000..41451c0 --- /dev/null +++ b/examples/service/service_test.yaml @@ -0,0 +1,23 @@ +learner: + type: SequentialActiveLearner + +components: + simulation: + type: script + path: /bin/echo + config: + args: ["Simulation Step"] + training: + type: script + path: /bin/echo + config: + args: ["Training Step"] + active_learn: + type: script + path: /bin/echo + config: + args: ["AL Step"] + +config: + max_iterations: 3 + work_dir: /tmp/rose_test diff --git a/examples/service/verify_service.py b/examples/service/verify_service.py new file mode 100644 index 0000000..3f3c79b --- /dev/null +++ b/examples/service/verify_service.py @@ -0,0 +1,81 @@ +import asyncio +import shutil +from pathlib import Path + +from rose.service.client import ServiceClient +from rose.service.manager import ServiceManager + +# Mock Job ID +JOB_ID = "test_job_123" +SERVICE_ROOT = Path.home() / ".rose" / "services" / JOB_ID + + +def cleanup(): + if SERVICE_ROOT.exists(): + shutil.rmtree(SERVICE_ROOT) + + +async def run_verification(): + print(f"--- Starting Verification for Job {JOB_ID} ---") + cleanup() + + # 1. Start Service in Background Task + manager = ServiceManager(JOB_ID) + service_task = asyncio.create_task(manager.run()) + + # Allow service to init + await asyncio.sleep(2) + + # 2. Initialize Client + client = ServiceClient(JOB_ID) + + # 3. Submit Workflow + wf_path = "service_real.yaml" + print(f"Submitting {wf_path}...") + req_id = client.submit_workflow(wf_path) + print(f"Submitted. Request ID: {req_id}") + + # 4. Poll for Status until Running + wf_id = None + for _ in range(10): + await asyncio.sleep(1) + registry = client.list_workflows() + if registry: + wf_id = list(registry.keys())[0] + state = registry[wf_id]["state"] + print(f"Workflow {wf_id} State: {state}") + if state in ["RUNNING", "COMPLETED"]: + break + + if not wf_id: + print("Failed to get workflow ID") + await manager.shutdown() + return + + # 5. Cancel Workflow (if still running) + print(f"Canceling {wf_id}...") + client.cancel_workflow(wf_id) + + # 6. Check for Canceled State + for _ in range(5): + await asyncio.sleep(1) + status = client.get_workflow_status(wf_id) + print(f"Workflow {wf_id} State: {status['state']}") + if status["state"] == "CANCELED": + print("SUCCESS: Workflow Canceled") + break + if status["state"] == "COMPLETED": + print("Workflow finished before cancel (acceptable for short test)") + break + + # Shutdown + await manager.shutdown() + try: + await service_task + except asyncio.CancelledError: + pass + print("--- Verification Finished ---") + + +if __name__ == "__main__": + asyncio.run(run_verification()) diff --git a/pyproject.toml b/pyproject.toml index e8fcca0..1014797 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ requires-python = ">=3.10" dependencies = [ "numpy", + "PyYAML", "radical.asyncflow", "rhapsody-py[radical_pilot]", 'rhapsody-py[dragon]; python_version >= "3.10" and python_version <= "3.12"', @@ -29,6 +30,12 @@ Homepage = "https://github.com/radical-cybertools/ROSE" Issues = "https://github.com/radical-cybertools/ROSE/issues" Documentation = "https://radical-cybertools.github.io/ROSE/" +[project.scripts] +rose = "rose.service.api.cli:main" + +[project.entry-points."radical.edge.plugins"] +rose = "rose.service.api.rest:PluginRose" + [project.optional-dependencies] lint = ["ruff"] @@ -36,12 +43,19 @@ mlflow = ["mlflow>=2.0"] clearml = ["clearml>=1.14"] tracking = ["mlflow>=2.0", "clearml>=1.14"] +service = [ + "fastapi>=0.100", + "radical.edge @ git+https://github.com/radical-cybertools/radical.edge", +] + # All test deps dev = [ "pytest", "pytest-asyncio", "pytest-cov", "pre-commit", + "fastapi>=0.100", + "radical.edge @ git+https://github.com/radical-cybertools/radical.edge", ] doc = [ @@ -59,6 +73,9 @@ doc = [ [tool.setuptools.packages.find] include = ["rose*"] +[tool.setuptools.package-data] +rose = ["service/api/*.js"] + [tool.ruff] line-length = 100 target-version = "py310" diff --git a/rose/service/.claude/settings.local.json b/rose/service/.claude/settings.local.json new file mode 100644 index 0000000..4742ca3 --- /dev/null +++ b/rose/service/.claude/settings.local.json @@ -0,0 +1,17 @@ +{ + "permissions": { + "allow": [ + "Bash(wc:*)", + "Bash(python -c:*)", + "Bash(source:*)", + "Bash(ruff check:*)", + "Bash(python3:*)", + "Bash(pip show:*)", + "Bash(pkill:*)", + "Bash(pgrep:*)", + "Bash(find:*)", + "Bash(xargs:*)", + "Bash(grep:*)" + ] + } +} diff --git a/rose/service/__init__.py b/rose/service/__init__.py new file mode 100644 index 0000000..e3fddf1 --- /dev/null +++ b/rose/service/__init__.py @@ -0,0 +1,3 @@ +from .models import Workflow, WorkflowState + +__all__ = ["WorkflowState", "Workflow"] diff --git a/rose/service/api/__init__.py b/rose/service/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rose/service/api/cli.py b/rose/service/api/cli.py new file mode 100644 index 0000000..24c21f1 --- /dev/null +++ b/rose/service/api/cli.py @@ -0,0 +1,141 @@ +import argparse +import asyncio +import json +import logging +import os +import sys + +from rose.service.client import ServiceClient +from rose.service.manager import ServiceManager + + +def get_job_id(): + """Get SLURM_JOB_ID from env or argument.""" + # Priority: env var -> but for client, user might validly pass it as arg. + # For 'launch', we usually rely on env var if running inside job. + return os.environ.get("SLURM_JOB_ID", "local_job_0") + + +def cmd_launch(args): + """Start the Service Manager.""" + # Configure logging for the service + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + datefmt="%H:%M:%S", + ) + + job_id = args.job_id or get_job_id() + print(f"Launching ROSE Service for Job ID: {job_id}") + + manager = ServiceManager(job_id) + # The loop in manager.run() handles signals if radical.asyncflow does, + # and the finally block ensures manager.shutdown() is called. + asyncio.run(manager.run()) + + +def cmd_submit(args): + """Submit a workflow.""" + job_id = args.job_id or get_job_id() + client = ServiceClient(job_id) + + try: + req_id = client.submit_workflow(args.workflow_file) + wf_id = ServiceClient.get_wf_id(req_id) + print("Submitted workflow request.") + print(f"Request ID: {req_id}") + print(f"Workflow ID: {wf_id}") + except Exception as e: + print(f"Error submitting workflow: {e}") + sys.exit(1) + + +def cmd_cancel(args): + """Cancel a workflow.""" + job_id = args.job_id or get_job_id() + client = ServiceClient(job_id) + + try: + req_id = client.cancel_workflow(args.wf_id) + print(f"Sent cancellation request for {args.wf_id}. Request ID: {req_id}") + except Exception as e: + print(f"Error cancelling workflow: {e}") + sys.exit(1) + + +def cmd_status(args): + """Get status.""" + job_id = args.job_id or get_job_id() + client = ServiceClient(job_id) + + try: + if args.wf_id: + status = client.get_workflow_status(args.wf_id) + if status: + print(json.dumps(status, indent=2)) + else: + print(f"Workflow {args.wf_id} not found.") + else: + # List all + registry = client.list_workflows() + print(f"Workflows in Job {job_id}:") + for wfid, wfdata in registry.items(): + print(f" - {wfid}: {wfdata.get('state')}") + except Exception as e: + print(f"Error getting status: {e}") + sys.exit(1) + + +def cmd_shutdown(args): + """Shutdown the service.""" + job_id = args.job_id or get_job_id() + client = ServiceClient(job_id) + try: + client.shutdown() + print(f"Shutdown request sent to service (Job ID: {job_id})") + except Exception as e: + print(f"Error sending shutdown request: {e}") + sys.exit(1) + + +def main(): + parser = argparse.ArgumentParser(description="ROSE Service CLI") + subparsers = parser.add_subparsers(dest="command", required=True) + + # Common arg for job id + parent_parser = argparse.ArgumentParser(add_help=False) + parent_parser.add_argument("--job-id", help="SLURM Job ID (default: $SLURM_JOB_ID)") + + # Launch + p_launch = subparsers.add_parser( + "launch", parents=[parent_parser], help="Start the Service Manager daemon" + ) + p_launch.set_defaults(func=cmd_launch) + + # Submit + p_submit = subparsers.add_parser("submit", parents=[parent_parser], help="Submit a workflow") + p_submit.add_argument("workflow_file", help="Path to workflow YAML file") + p_submit.set_defaults(func=cmd_submit) + + # Cancel + p_cancel = subparsers.add_parser("cancel", parents=[parent_parser], help="Cancel a workflow") + p_cancel.add_argument("wf_id", help="Workflow ID to cancel") + p_cancel.set_defaults(func=cmd_cancel) + + # Status + p_status = subparsers.add_parser("status", parents=[parent_parser], help="Get workflow status") + p_status.add_argument("wf_id", nargs="?", help="Optional Workflow ID") + p_status.set_defaults(func=cmd_status) + + # Shutdown + p_shutdown = subparsers.add_parser( + "shutdown", parents=[parent_parser], help="Shutdown the service" + ) + p_shutdown.set_defaults(func=cmd_shutdown) + + args = parser.parse_args() + args.func(args) + + +if __name__ == "__main__": + main() diff --git a/rose/service/api/rest.py b/rose/service/api/rest.py new file mode 100644 index 0000000..ed5001d --- /dev/null +++ b/rose/service/api/rest.py @@ -0,0 +1,632 @@ +""" +ROSE Plugin for RADICAL-Edge +============================= + +This module provides a RADICAL-Edge plugin for ROSE (Remote Online Smart +Experiment) workflow management. It enables submission, monitoring, and +cancellation of Active Learning workflows through REST endpoints. + +Architecture +------------ +The plugin embeds workflow execution directly within the Edge service, +eliminating the need for a separate ServiceManager daemon. Each RoseSession +maintains its own WorkflowEngine and executes learner loops as async tasks. + +:: + + Client (Python/curl/browser) + ↓ HTTP/REST + RADICAL-Edge Bridge + ↓ WebSocket + Edge Service (with ROSE plugin) + ↓ + WorkflowEngine / Learners (embedded) + +Components +---------- +- **PluginRose**: The plugin class registered with RADICAL-Edge. Defines REST + routes and UI configuration for portal integration. + +- **RoseSession**: Server-side session managing workflow execution. Each + session lazily initializes a WorkflowEngine and tracks all submitted + workflows. + +- **RoseClient**: Application-side client providing synchronous methods for + workflow operations. + +REST Endpoints +-------------- +- ``POST /rose/register_session`` - Create a new session +- ``POST /rose/submit/{sid}`` - Submit a workflow YAML +- ``GET /rose/status/{sid}/{wf_id}`` - Get workflow status +- ``GET /rose/workflows/{sid}`` - List all workflows +- ``POST /rose/cancel/{sid}/{wf_id}`` - Cancel a workflow +- ``POST /rose/unregister_session/{sid}`` - Close session + +Notifications +------------- +The plugin sends real-time notifications via SSE when workflow state changes. +Clients can subscribe using ``RoseClient.on_workflow_state(callback)``. + +See Also +-------- +- ``rose.service.manager.WorkflowLoader`` - YAML parsing and learner creation +- ``rose.al.active_learner`` - SequentialActiveLearner, ParallelActiveLearner +- ``radical.edge.plugin_base.Plugin`` - Base plugin class +""" + +__author__ = "RADICAL Development Team" +__email__ = "radical@radical-project.org" +__copyright__ = "Copyright 2024, RADICAL@Rutgers" +__license__ = "MIT" + + +import asyncio +import logging +import signal +import time +import uuid +from pathlib import Path + +from fastapi import FastAPI, HTTPException, Request +from radical.asyncflow import LocalExecutionBackend, WorkflowEngine +from radical.edge.client import PluginClient +from radical.edge.plugin_base import Plugin +from radical.edge.plugin_session_base import PluginSession +from radical.edge.ui_schema import ( + UIConfig, + UIField, + UIForm, + UIFormSubmit, + UIMonitor, + UINotifications, +) +from starlette.responses import JSONResponse + +from rose.service.manager import WorkflowLoader +from rose.service.models import Workflow, WorkflowState + +log = logging.getLogger("radical.edge") + + +# ------------------------------------------------------------------------------ +# +class RoseSession(PluginSession): + """ROSE session (service-side). + + Directly manages workflow execution using AsyncFlow, eliminating the need for a separate + ServiceManager process. + """ + + # -------------------------------------------------------------------------- + # + def __init__(self, sid: str): + """Initialize a RoseSession. + + Args: + sid (str): Unique session identifier assigned by the plugin. + """ + super().__init__(sid) + + self._workflows: dict[str, Workflow] = {} + self._learner_tasks: dict[str, asyncio.Task] = {} + self._engine: WorkflowEngine | None = None + self._engine_lock = asyncio.Lock() + self._initialized = False + + # -------------------------------------------------------------------------- + # + async def _ensure_engine(self): + """Lazily initialize the workflow engine.""" + if self._engine is not None: + return + + async with self._engine_lock: + if self._engine is not None: + return + + log.info(f"[{self.sid}] Initializing workflow engine") + backend = LocalExecutionBackend() + self._engine = await WorkflowEngine.create(backend) + self._initialized = True + log.info(f"[{self.sid}] Workflow engine ready") + + # -------------------------------------------------------------------------- + # + async def submit_workflow(self, workflow_file: str) -> dict: + """Submit a workflow YAML file for execution. + + Args: + workflow_file (str): Absolute or relative path to the workflow YAML. + + Returns: + dict: ``{wf_id}`` — the workflow ID. + """ + self._check_active() + await self._ensure_engine() + + workflow_file = str(Path(workflow_file).expanduser()) + + # Generate workflow ID + wf_id = f"wf.{uuid.uuid4().hex[:8]}" + + # Create workflow record + wf = Workflow(wf_id=wf_id, state=WorkflowState.SUBMITTED, workflow_file=workflow_file) + self._workflows[wf_id] = wf + + # Notify submission + if self._plugin: + self._plugin._dispatch_notify( + "workflow_state", + {"wf_id": wf_id, "state": "SUBMITTED", "workflow_file": workflow_file}, + ) + + # Start workflow execution in background + task = asyncio.create_task(self._run_workflow(wf)) + self._learner_tasks[wf_id] = task + + log.info(f"[{self.sid}] Submitted workflow {wf_id}: {workflow_file}") + return {"wf_id": wf_id, "state": WorkflowState.SUBMITTED.value} + + # -------------------------------------------------------------------------- + # + async def _run_workflow(self, wf: Workflow): + """Execute a workflow (runs as background task).""" + wf_id = wf.wf_id + + try: + # Initialize + wf.state = WorkflowState.INITIALIZING + self._notify_state(wf) + + # Load workflow definition + wf_def = WorkflowLoader.load_yaml(wf.workflow_file) + learner, initial_config = WorkflowLoader.create_learner(wf_id, wf_def, self._engine) + wf.learner_instance = learner + + # Wrap learner task registration to emit per-task completion events + task_count = 0 + _orig_register = learner._register_task + + def _tracked_register(task_obj, deps=None): + nonlocal task_count + task_count += 1 + tid = task_count + future = _orig_register(task_obj, deps) + + def _on_done(fut): + if fut.cancelled(): + return + exc = fut.exception() + is_ok = exc is None + raw = str(fut.result() or "") if is_ok else str(exc) + excerpt = next((line.strip() for line in raw.splitlines() if line.strip()), "")[ + :120 + ] + if self._plugin: + self._plugin._dispatch_notify( + "task_event", + {"wf_id": wf_id, "task_id": tid, "ok": is_ok, "excerpt": excerpt}, + ) + + future.add_done_callback(_on_done) + return future + + learner._register_task = _tracked_register + + # Run + wf.state = WorkflowState.RUNNING + wf.start_time = time.time() + self._notify_state(wf) + + log.info(f"[{self.sid}] Running workflow {wf_id}") + + def on_iteration(state): + wf.stats = state.to_dict() if hasattr(state, "to_dict") else {"result": str(state)} + log.info( + f"[{self.sid}] {wf_id} learner {getattr(state, 'learner_id', '?')}," + f" iteration {getattr(state, 'iteration', '?')}" + f" (metric={getattr(state, 'metric_value', '?')})" + ) + self._notify_state(wf) + + await WorkflowLoader.run_learner(learner, wf_def, initial_config, on_iteration) + + # Completed + wf.state = WorkflowState.COMPLETED + wf.end_time = time.time() + log.info(f"[{self.sid}] Workflow {wf_id} completed") + + except asyncio.CancelledError: + wf.state = WorkflowState.CANCELED + wf.end_time = time.time() + log.info(f"[{self.sid}] Workflow {wf_id} canceled") + raise + + except Exception as e: + wf.state = WorkflowState.FAILED + wf.error = str(e) + wf.end_time = time.time() + log.exception(f"[{self.sid}] Workflow {wf_id} failed") + + finally: + self._notify_state(wf) + self._learner_tasks.pop(wf_id, None) + + # -------------------------------------------------------------------------- + # + def _notify_state(self, wf: Workflow): + """Send workflow state notification.""" + if self._plugin: + self._plugin._dispatch_notify( + "workflow_state", + {"wf_id": wf.wf_id, "state": wf.state.value, "stats": wf.stats, "error": wf.error}, + ) + + # -------------------------------------------------------------------------- + # + async def get_workflow_status(self, wf_id: str) -> dict: + """Return the current status of a workflow. + + Args: + wf_id (str): The workflow ID. + + Returns: + dict: Workflow state dictionary. + + Raises: + HTTPException(404): If the workflow ID is not found. + """ + self._check_active() + + wf = self._workflows.get(wf_id) + if not wf: + raise HTTPException(status_code=404, detail=f"workflow '{wf_id}' not found") + + return wf.to_dict() + + # -------------------------------------------------------------------------- + # + async def list_workflows(self) -> dict: + """List all workflows in this session. + + Returns: + dict: Mapping ``wf_id → state dict``. + """ + self._check_active() + + return {wf_id: wf.to_dict() for wf_id, wf in self._workflows.items()} + + # -------------------------------------------------------------------------- + # + async def cancel_workflow(self, wf_id: str) -> dict: + """Cancel a running workflow. + + Args: + wf_id (str): The workflow ID to cancel. + + Returns: + dict: ``{wf_id}`` confirming the cancellation. + """ + self._check_active() + + wf = self._workflows.get(wf_id) + if not wf: + raise HTTPException(status_code=404, detail=f"workflow '{wf_id}' not found") + + if wf.state not in ( + WorkflowState.RUNNING, + WorkflowState.INITIALIZING, + WorkflowState.SUBMITTED, + ): + raise HTTPException(status_code=400, detail=f"workflow '{wf_id}' not running") + + # Stop the learner + if wf.learner_instance: + wf.learner_instance.stop() + + # Cancel the task + task = self._learner_tasks.get(wf_id) + if task and not task.done(): + task.cancel() + + log.info(f"[{self.sid}] Canceling workflow {wf_id}") + return {"wf_id": wf_id} + + # -------------------------------------------------------------------------- + # + async def close(self) -> dict: + """Close this session, stopping all workflows and cleaning up.""" + log.info(f"[{self.sid}] Closing session") + + # Stop all learners + for wf in self._workflows.values(): + if wf.learner_instance and wf.state == WorkflowState.RUNNING: + wf.learner_instance.stop() + + # Snapshot before cancelling — tasks remove themselves on completion + tasks = list(self._learner_tasks.values()) + for task in tasks: + if not task.done(): + task.cancel() + + if tasks: + try: + await asyncio.wait_for( + asyncio.gather(*tasks, return_exceptions=True), + timeout=5.0, + ) + except asyncio.TimeoutError: + log.warning(f"[{self.sid}] Timed out waiting for learner tasks to cancel") + self._learner_tasks.clear() + + # Shutdown engine + if self._engine: + # Snapshot existing tasks so we can drain asyncflow's leftovers afterwards + current = asyncio.current_task() + pre_shutdown_tasks = set(asyncio.all_tasks()) - {current} + + try: + await asyncio.wait_for(self._engine.shutdown(), timeout=5.0) + except asyncio.TimeoutError: + log.warning(f"[{self.sid}] Engine shutdown timed out, forcing exit") + self._engine = None + + # asyncflow registers SIGINT/SIGTERM/SIGHUP via loop.add_signal_handler(), + # replacing uvicorn's own handlers and never restoring them. Remove them + # so that the next Ctrl-C / SIGTERM is handled by uvicorn as normal. + loop = asyncio.get_event_loop() + for sig in (signal.SIGHUP, signal.SIGTERM, signal.SIGINT): + try: + loop.remove_signal_handler(sig) + except Exception: + pass + + # asyncflow's cancel_all_tasks() cancels futures but never awaits them, + # leaving cancelled tasks in the event-loop queue. Drain them now so the + # loop is clean and uvicorn can exit without spurious delays. + leftover = [ + t + for t in asyncio.all_tasks() + if t is not current and t not in pre_shutdown_tasks and not t.done() + ] + if leftover: + log.debug(f"[{self.sid}] Draining {len(leftover)} leftover engine tasks") + for t in leftover: + t.cancel() + await asyncio.gather(*leftover, return_exceptions=True) + + return await super().close() + + +# ------------------------------------------------------------------------------ +# +class RoseClient(PluginClient): + """Application-side client for the ROSE plugin. + + Provides a thin sync wrapper over the HTTP endpoints exposed by + ``PluginRose``. + """ + + # -------------------------------------------------------------------------- + # + def on_workflow_state(self, callback): + """Register a callback for workflow state change notifications. + + Args: + callback: A callable(topic, data) to invoke on state changes. + """ + self.register_notification_callback(callback) + + # -------------------------------------------------------------------------- + # + def off_workflow_state(self, callback): + """Unregister a workflow state change callback. + + Args: + callback: The callback to unregister. + """ + self.unregister_notification_callback(callback) + + # -------------------------------------------------------------------------- + # + def submit_workflow(self, workflow_file: str) -> dict: + """Submit a workflow YAML file. + + Args: + workflow_file (str): Path to the workflow YAML file. + + Returns: + dict: ``{wf_id}``. + """ + if not self.sid: + raise RuntimeError("No active session") + + resp = self._http.post( + self._url(f"submit/{self.sid}"), json={"workflow_file": workflow_file} + ) + self._raise(resp) + + return resp.json() + + # -------------------------------------------------------------------------- + # + def get_workflow_status(self, wf_id: str) -> dict: + """Get the current status of a workflow. + + Args: + wf_id (str): Workflow ID. + + Returns: + dict: Workflow state dictionary. + """ + if not self.sid: + raise RuntimeError("No active session") + + resp = self._http.get(self._url(f"status/{self.sid}/{wf_id}")) + self._raise(resp) + + return resp.json() + + # -------------------------------------------------------------------------- + # + def list_workflows(self) -> dict: + """List all workflows in the session. + + Returns: + dict: Registry mapping ``wf_id → state dict``. + """ + if not self.sid: + raise RuntimeError("No active session") + + resp = self._http.get(self._url(f"workflows/{self.sid}")) + self._raise(resp) + + return resp.json() + + # -------------------------------------------------------------------------- + # + def cancel_workflow(self, wf_id: str) -> dict: + """Cancel a running workflow. + + Args: + wf_id (str): Workflow ID to cancel. + + Returns: + dict: ``{wf_id}``. + """ + if not self.sid: + raise RuntimeError("No active session") + + resp = self._http.post(self._url(f"cancel/{self.sid}/{wf_id}")) + self._raise(resp) + + return resp.json() + + +# ------------------------------------------------------------------------------ +# +class PluginRose(Plugin): + """ROSE plugin for RADICAL-Edge. + + Exposes workflow management via REST endpoints, with embedded execution + (no separate ServiceManager process required). + + Routes: + - POST /rose/register_session + - POST /rose/unregister_session/{sid} + - POST /rose/submit/{sid} + - GET /rose/status/{sid}/{wf_id} + - GET /rose/workflows/{sid} + - POST /rose/cancel/{sid}/{wf_id} + """ + + plugin_name = "rose" + session_class = RoseSession + client_class = RoseClient + version = "0.2.0" + session_ttl = 0 # No timeout - workflows can run for hours/days + ui_module = str(Path(__file__).parent / "rose.js") + + ui_config = UIConfig( + icon="🌹", + title="ROSE Active Learning", + description="Submit and monitor Active Learning workflows", + refresh_button=True, + forms=[ + UIForm( + id="submit", + title="Submit Workflow", + layout="single", + fields=[ + UIField( + name="workflow_file", + type="text", + label="Workflow File", + placeholder="/path/to/workflow.yaml", + required=True, + ) + ], + submit=UIFormSubmit(label="Submit", style="success", endpoint="submit/{sid}"), + ) + ], + monitors=[ + UIMonitor( + id="workflows", + title="Workflows", + type="task_list", + css_class="workflow-list", + empty_text="No workflows submitted yet", + auto_load="workflows/{sid}", + ) + ], + notifications=UINotifications( + topic="workflow_state", id_field="wf_id", state_field="state" + ), + ) + + # -------------------------------------------------------------------------- + # + def __init__(self, app: FastAPI, instance_name: str = "rose"): + """Initialize the ROSE plugin, registering all routes.""" + super().__init__(app, instance_name) + + self.add_route_post("submit/{sid}", self.submit_workflow) + self.add_route_get("status/{sid}/{wf_id}", self.get_workflow_status) + self.add_route_get("workflows/{sid}", self.list_workflows) + self.add_route_post("cancel/{sid}/{wf_id}", self.cancel_workflow) + + self._log_routes() + app.add_event_handler("shutdown", self._on_shutdown) + + # -------------------------------------------------------------------------- + # + async def _on_shutdown(self) -> None: + """Close all active sessions when the FastAPI app shuts down.""" + log.info(f"[rose] Shutting down {len(self._sessions)} active session(s)...") + for session in list(self._sessions.values()): + try: + await asyncio.wait_for(session.close(), timeout=10.0) + except (asyncio.TimeoutError, Exception) as exc: + log.warning(f"[rose] Session {session.sid} close error during shutdown: {exc}") + log.info("[rose] All sessions closed") + + # -------------------------------------------------------------------------- + # + async def submit_workflow(self, request: Request) -> JSONResponse: + """Submit a workflow YAML file.""" + sid = request.path_params["sid"] + data = await request.json() + + return await self._forward( + sid, RoseSession.submit_workflow, workflow_file=data.get("workflow_file") + ) + + # -------------------------------------------------------------------------- + # + async def get_workflow_status(self, request: Request) -> JSONResponse: + """Return the status of a specific workflow.""" + sid = request.path_params["sid"] + wf_id = request.path_params["wf_id"] + + return await self._forward(sid, RoseSession.get_workflow_status, wf_id=wf_id) + + # -------------------------------------------------------------------------- + # + async def list_workflows(self, request: Request) -> JSONResponse: + """List all workflows in the session.""" + sid = request.path_params["sid"] + + return await self._forward(sid, RoseSession.list_workflows) + + # -------------------------------------------------------------------------- + # + async def cancel_workflow(self, request: Request) -> JSONResponse: + """Cancel a running workflow.""" + sid = request.path_params["sid"] + wf_id = request.path_params["wf_id"] + + return await self._forward(sid, RoseSession.cancel_workflow, wf_id=wf_id) + + +# ------------------------------------------------------------------------------ diff --git a/rose/service/api/rose.js b/rose/service/api/rose.js new file mode 100644 index 0000000..ae570a7 --- /dev/null +++ b/rose/service/api/rose.js @@ -0,0 +1,77 @@ +/** + * ROSE Plugin Module for Radical Edge Explorer + * + * Handles workflow submission results and live state updates via SSE. + */ + +export const name = 'rose'; + +export const notificationConfig = { + topic: 'workflow_state', + idField: 'wf_id', +}; + +export function onNotification(data, page, api) { + const wfId = data.data?.wf_id || ''; + if (!wfId) return; + + const entryId = `rose-task-${api.edgeName}-${wfId}`; + const entry = document.getElementById(entryId); + if (!entry) return; + + if (data.topic === 'task_event') { + const logEl = entry.querySelector('.rose-task-log'); + if (!logEl) return; + const d = data.data; + const color = d.ok ? 'var(--green, #4caf50)' : 'var(--red, #f44336)'; + const icon = d.ok ? '✓' : '✗'; + const excerpt = api.escHtml(d.excerpt || ''); + logEl.insertAdjacentHTML('beforeend', + `
${api.escHtml(error)}`;
+ }
+
+ // Write state info into a stable child so task event lines below survive.
+ // On first write, clear the "Waiting…" placeholder.
+ let si = logEl.querySelector('.rose-state-info');
+ if (!si) {
+ logEl.innerHTML = '';
+ si = document.createElement('div');
+ si.className = 'rose-state-info';
+ logEl.appendChild(si);
+ }
+ si.innerHTML = logHtml;
+}
diff --git a/rose/service/client.py b/rose/service/client.py
new file mode 100644
index 0000000..a168824
--- /dev/null
+++ b/rose/service/client.py
@@ -0,0 +1,107 @@
+import json
+import logging
+import time
+import uuid
+from pathlib import Path
+from typing import Any
+
+logger = logging.getLogger(__name__)
+
+
+class ServiceClient:
+ """Client for interacting with the ROSE Service via File-based IPC.
+
+ Attributes:
+ job_id (str): The SLURM job ID where the service is running.
+ service_root (Path): Root directory for service IPC (~/.rose/services/