diff --git a/docs/changelog.md b/docs/changelog.md index 4f9c15c5..78fa4ffe 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -4,6 +4,11 @@ - Update RCC to `v18.5.0`. - Using RCC directly to build the environment using `package.yaml` (instead of creating a `conda.yaml` first). - When dealing with a `package.yaml`, always consider the `dev-environment` when building the environment. +- Add code lenses to: + - `Run Dev Task` (for `dev-tasks` inside a `package.yaml`) + - `Run Action` (for `@action` decorated methods in `.py` files) + - `Debug Action` (for `@action` decorated methods in `.py` files) +- Fixed vulnerability when creating temp file to create terminal (it could create a `.bat` / `.sh` file in a directory which could be shared by multiple users). ## New in 2.7.1 (2024-10-23) diff --git a/sema4ai-python-ls-core/tests/sema4ai_ls_core_tests/test_workspace_memory_cache.py b/sema4ai-python-ls-core/tests/sema4ai_ls_core_tests/test_workspace_memory_cache.py index e84637c9..4f427771 100644 --- a/sema4ai-python-ls-core/tests/sema4ai_ls_core_tests/test_workspace_memory_cache.py +++ b/sema4ai-python-ls-core/tests/sema4ai_ls_core_tests/test_workspace_memory_cache.py @@ -12,13 +12,14 @@ def small_vs_sleep(): def test_workspace_and_uris(tmpdir): - from sema4ai_ls_core.workspace import Workspace - from sema4ai_ls_core.watchdog_wrapper import create_observer - from sema4ai_ls_core import uris - from sema4ai_ls_core.lsp import TextDocumentItem import os import sys + from sema4ai_ls_core import uris + from sema4ai_ls_core.lsp import TextDocumentItem + from sema4ai_ls_core.watchdog_wrapper import create_observer + from sema4ai_ls_core.workspace import Workspace + ws_root_path = str(tmpdir) root_uri = uris.from_fs_path(ws_root_path) ws = Workspace( @@ -58,17 +59,15 @@ def to_vscode_uri(uri): ) -def test_workspace_memory_cache(tmpdir, small_vs_sleep): - from sema4ai_ls_core.workspace import Workspace - from sema4ai_ls_core import uris - from sema4ai_ls_core.lsp import WorkspaceFolder +def test_workspace_memory_cache(tmpdir, small_vs_sleep) -> None: import os - from typing import List - from sema4ai_ls_core.protocols import IWorkspaceFolder import typing - from sema4ai_ls_core.workspace import _WorkspaceFolderWithVirtualFS - from sema4ai_ls_core import watchdog_wrapper + + from sema4ai_ls_core import uris, watchdog_wrapper from sema4ai_ls_core.basic import wait_for_condition + from sema4ai_ls_core.lsp import WorkspaceFolder + from sema4ai_ls_core.protocols import IWorkspaceFolder + from sema4ai_ls_core.workspace import Workspace, _WorkspaceFolderWithVirtualFS root_uri = uris.from_fs_path(str(tmpdir)) workspace_folders: list[IWorkspaceFolder] = [ diff --git a/sema4ai/codegen/codegen_package.py b/sema4ai/codegen/codegen_package.py index 7d17c904..ebbea057 100644 --- a/sema4ai/codegen/codegen_package.py +++ b/sema4ai/codegen/codegen_package.py @@ -45,6 +45,7 @@ def get_json_contents(): "publisher": "sema4ai", "engines": {"vscode": f"^1.65.0"}, "categories": ["Debuggers"], + "taskDefinitions": [{"type": "Sema4.ai: dev-task"}], "activationEvents": get_activation_events_for_json() + views.get_activation_events_for_json() + ["onLanguage:json", "onLanguage:yaml", "onLanguage:python"], diff --git a/sema4ai/codegen/commands.py b/sema4ai/codegen/commands.py index f7afec46..cd39777a 100644 --- a/sema4ai/codegen/commands.py +++ b/sema4ai/codegen/commands.py @@ -1000,6 +1000,12 @@ def __init__( server_handled=False, hide_from_command_palette=False, ), + Command( + "sema4ai.runActionPackageDevTask", + "Run dev-task (from Action Package)", + add_to_package_json=True, + server_handled=False, + ), ] diff --git a/sema4ai/codegen/settings.py b/sema4ai/codegen/settings.py index 67739b5b..d0dfb094 100644 --- a/sema4ai/codegen/settings.py +++ b/sema4ai/codegen/settings.py @@ -110,6 +110,18 @@ def __init__( "Specifies whether the 'Run Task' and 'Debug Task' code lenses should be shown.", setting_type="boolean", ), + Setting( + "sema4ai.codeLens.actionsLaunch", + True, + "Specifies whether the 'Run Action' and 'Debug Action' code lenses should be shown.", + setting_type="boolean", + ), + Setting( + "sema4ai.codeLens.devTask", + True, + "Specifies whether the 'Run Task' and 'Debug Task' code lenses should be shown in `dev-tasks` in `package.yaml`.", + setting_type="boolean", + ), ] diff --git a/sema4ai/package.json b/sema4ai/package.json index 7ed5ea08..eb085417 100644 --- a/sema4ai/package.json +++ b/sema4ai/package.json @@ -18,6 +18,11 @@ "categories": [ "Debuggers" ], + "taskDefinitions": [ + { + "type": "Sema4.ai: dev-task" + } + ], "activationEvents": [ "onCommand:sema4ai.getLanguageServerPython", "onCommand:sema4ai.getLanguageServerPythonInfo", @@ -162,6 +167,7 @@ "onCommand:sema4ai.updateAgentVersion.internal", "onCommand:sema4ai.collapseAllEntries", "onCommand:sema4ai.importActionPackage", + "onCommand:sema4ai.runActionPackageDevTask", "onDebugInitialConfigurations", "onDebugResolve:sema4ai", "onView:sema4ai-task-packages-tree", @@ -241,6 +247,16 @@ "type": "boolean", "default": true, "description": "Specifies whether the 'Run Task' and 'Debug Task' code lenses should be shown." + }, + "sema4ai.codeLens.actionsLaunch": { + "type": "boolean", + "default": true, + "description": "Specifies whether the 'Run Action' and 'Debug Action' code lenses should be shown." + }, + "sema4ai.codeLens.devTask": { + "type": "boolean", + "default": true, + "description": "Specifies whether the 'Run Task' and 'Debug Task' code lenses should be shown in `dev-tasks` in `package.yaml`." } } }, @@ -969,6 +985,11 @@ "command": "sema4ai.importActionPackage", "title": "Import Action Package", "category": "Sema4.ai" + }, + { + "command": "sema4ai.runActionPackageDevTask", + "title": "Run dev-task (from Action Package)", + "category": "Sema4.ai" } ], "menus": { diff --git a/sema4ai/src/sema4ai_code/commands.py b/sema4ai/src/sema4ai_code/commands.py index e2a4310a..2e2b72a3 100644 --- a/sema4ai/src/sema4ai_code/commands.py +++ b/sema4ai/src/sema4ai_code/commands.py @@ -144,6 +144,7 @@ SEMA4AI_UPDATE_AGENT_VERSION_INTERNAL = "sema4ai.updateAgentVersion.internal" # Update Agent Version (internal) SEMA4AI_COLLAPSE_ALL_ENTRIES = "sema4ai.collapseAllEntries" # Collapse All Entries SEMA4AI_IMPORT_ACTION_PACKAGE = "sema4ai.importActionPackage" # Import Action Package +SEMA4AI_RUN_ACTION_PACKAGE_DEV_TASK = "sema4ai.runActionPackageDevTask" # Run dev-task (from Action Package) ALL_SERVER_COMMANDS = [ SEMA4AI_GET_PLUGINS_DIR, diff --git a/sema4ai/src/sema4ai_code/robo/launch_code_lens.py b/sema4ai/src/sema4ai_code/robo/launch_code_lens.py index 577fef0e..0fe23f67 100644 --- a/sema4ai/src/sema4ai_code/robo/launch_code_lens.py +++ b/sema4ai/src/sema4ai_code/robo/launch_code_lens.py @@ -1,7 +1,8 @@ -import re +import typing from functools import partial -from typing import List, Optional +from pathlib import Path +from sema4ai_ls_core.core_log import get_logger from sema4ai_ls_core.jsonrpc.endpoint import require_monitor from sema4ai_ls_core.lsp import CodeLensTypedDict from sema4ai_ls_core.protocols import ( @@ -12,116 +13,286 @@ IWorkspace, ) +if typing.TYPE_CHECKING: + import ast -def compute_launch_robo_code_lens( + +log = get_logger(__name__) + + +def compute_code_lenses( workspace: IWorkspace | None, config_provider: IConfigProvider, doc_uri: str ) -> partial | None: from sema4ai_ls_core import uris - if not uris.to_fs_path(doc_uri).endswith(".py"): - return None ws = workspace if ws is None: return None + document: IDocument | None = ws.get_document(doc_uri, accept_from_file=True) + if document is None: + return None + config_provider = config_provider config: IConfig | None = None - compute_launch = True - if config_provider is not None: - config = config_provider.config - if config: - from sema4ai_code.settings import SEMA4AI_CODE_LENS_ROBO_LAUNCH - - compute_launch = config.get_setting( - SEMA4AI_CODE_LENS_ROBO_LAUNCH, bool, True + + fs_path = uris.to_fs_path(doc_uri) + if fs_path.endswith(".py"): + compute_robo_tasks_code_lenses = True + compute_action_packages_code_lenses = True + + if config_provider is not None: + config = config_provider.config + if config: + from sema4ai_code.settings import SEMA4AI_CODE_LENS_ROBO_LAUNCH + + compute_robo_tasks_code_lenses = config.get_setting( + SEMA4AI_CODE_LENS_ROBO_LAUNCH, bool, True + ) + + from sema4ai_code.settings import SEMA4AI_CODE_LENS_ACTIONS_LAUNCH + + compute_action_packages_code_lenses = config.get_setting( + SEMA4AI_CODE_LENS_ACTIONS_LAUNCH, bool, True + ) + + if ( + not compute_robo_tasks_code_lenses + and not compute_action_packages_code_lenses + ): + return None + # Provide a partial which will be computed in a thread with a monitor. + return require_monitor( + partial( + _collect_python_code_lenses_in_thread, + document, + compute_robo_tasks_code_lenses, + compute_action_packages_code_lenses, ) + ) - if not compute_launch: - return None + if fs_path.endswith("package.yaml"): + return require_monitor( + partial(_collect_package_yaml_code_lenses_in_thread, document) + ) + return None + + +def _is_robocorp_tasks_import(node: "ast.AST") -> bool: + import ast + + if isinstance(node, ast.ImportFrom): + # Check if the module is 'robocorp.tasks' and 'task' is in the names + if node.module == "robocorp.tasks": + for alias in node.names: + if alias.name == "task": + return True + return False + + +def _is_sema4ai_actions_import(node: "ast.AST") -> bool: + import ast + + if isinstance(node, ast.ImportFrom): + # Check if the module is 'sema4ai.actions' and 'action' is in the names + if node.module == "sema4ai.actions": + for alias in node.names: + if alias.name == "action": + return True + return False - document: IDocument | None = ws.get_document(doc_uri, accept_from_file=True) - if document is None: - return None - # Provide a partial which will be computed in a thread with a monitor. - return require_monitor(partial(_collect_tasks_in_thread, document)) +def _create_code_lens(start_line, title, command, arguments) -> CodeLensTypedDict: + return { + "range": { + "start": { + "line": start_line, + "character": 0, + }, + "end": { + "line": start_line, + "character": 0, + }, + }, + "command": { + "title": title, + "command": command, + "arguments": arguments, + }, + "data": None, + } -def _collect_tasks_in_thread( - document: IDocument, monitor: IMonitor +def _collect_package_yaml_code_lenses_in_thread( + document: IDocument, + monitor: IMonitor, ) -> list[CodeLensTypedDict] | None: + from pathlib import Path + + from sema4ai_code.commands import SEMA4AI_RUN_ACTION_PACKAGE_DEV_TASK + from sema4ai_code.robo.list_dev_tasks import list_dev_tasks_from_content + + result = list_dev_tasks_from_content(document.source, Path(document.path)) + + if not result.success: + log.info(result.message or "") + return None + code_lenses: list[CodeLensTypedDict] = [] + dev_tasks = result.result + if dev_tasks: + for task_name in dev_tasks.keys(): + if task_name.location: + code_lenses.append( + _create_code_lens( + task_name.location[0], + "Run Dev Task", + SEMA4AI_RUN_ACTION_PACKAGE_DEV_TASK, + [ + { + "packageYamlPath": document.path, + "taskName": task_name, + } + ], + ) + ) + return code_lenses + + +def _find_package_yaml_from_path(fs_path: Path) -> Path | None: + import itertools + + for path in itertools.chain(iter([fs_path]), fs_path.parents): + # Give higher priority to package.yaml (in case conda.yaml became + # package.yaml but robot.yaml is still lingering around). + package_yaml: Path = path / "package.yaml" + if package_yaml.exists(): + return package_yaml + return None + + +def _collect_python_code_lenses_in_thread( + document: IDocument, + compute_robo_tasks_code_lenses: bool, + compute_action_packages_code_lenses: bool, + monitor: IMonitor, +) -> list[CodeLensTypedDict] | None: + import ast + import os + + from sema4ai_code.commands import ( + SEMA4AI_ROBOTS_VIEW_ACTION_DEBUG, + SEMA4AI_ROBOTS_VIEW_ACTION_RUN, + ) + + tasks_code_lenses: list[CodeLensTypedDict] = [] + actions_code_lenses: list[CodeLensTypedDict] = [] contents = document.source - found_task_decorator_at_line = -1 - for i, line in enumerate(contents.splitlines()): + # Parse the document source into an AST + try: + tree = ast.parse(contents) + except Exception: + return None + + found_robocorp_tasks_import = False + found_sema4ai_actions_import = False + + package_yaml_path = _find_package_yaml_from_path(Path(document.path)) + if not package_yaml_path: + compute_action_packages_code_lenses = False + + # Iterate over the AST nodes + for node in ast.walk(tree): monitor.check_cancelled() - if found_task_decorator_at_line != -1: - if i < found_task_decorator_at_line + 3: - use_line = found_task_decorator_at_line - found_task_decorator_at_line = -1 - if line.startswith("def "): - re_match = re.match(r"\s*def\s+(\w*).*", line) - if re_match: - function_name = re_match.group(1) - if function_name: - code_lenses.append( - { - "range": { - "start": { - "line": use_line, - "character": 0, - }, - "end": { - "line": use_line, - "character": 0, - }, - }, - "command": { - "title": "Run Task", - "command": "sema4ai.runRobocorpsPythonTask", - "arguments": [ - [ - document.path, - "-t", - function_name, - ] - ], - }, - "data": None, - } - ) - code_lenses.append( - { - "range": { - "start": { - "line": use_line, - "character": 0, - }, - "end": { - "line": use_line, - "character": 0, - }, - }, - "command": { - "title": "Debug Task", - "command": "sema4ai.debugRobocorpsPythonTask", - "arguments": [ - [ - document.path, - "-t", - function_name, - ] - ], - }, - "data": None, - } - ) - - line = line.strip() - if line.startswith("@task"): - re_match = re.match(r"@\b(task)\b.*", line) - if re_match: - found_task_decorator_at_line = i - return code_lenses + # Detect if there's a `from robocorp.tasks import task` import. + if not found_robocorp_tasks_import and compute_robo_tasks_code_lenses: + found_robocorp_tasks_import = _is_robocorp_tasks_import(node) + + # Detect if there's a `from sema4ai.actions import action` import. + if not found_sema4ai_actions_import and compute_action_packages_code_lenses: + found_sema4ai_actions_import = _is_sema4ai_actions_import(node) + + if isinstance(node, ast.FunctionDef) and node.decorator_list: + for decorator in node.decorator_list: + if ( + compute_action_packages_code_lenses + and isinstance(decorator, ast.Name) + and decorator.id == "action" + ): + assert ( + package_yaml_path is not None + ), "Expected package_yaml_path to be defined at this point." + function_name = node.name + start_line = decorator.lineno - 1 # AST line numbers are 1-based + robot_entry = { + "actionName": function_name, + "robot": { + "directory": str(package_yaml_path.parent), + "filePath": str(package_yaml_path), + }, + "uri": document.uri, + } + actions_code_lenses.append( + _create_code_lens( + start_line, + "Run Action", + SEMA4AI_ROBOTS_VIEW_ACTION_RUN, + [robot_entry], + ) + ) + actions_code_lenses.append( + _create_code_lens( + start_line, + "Debug Action", + SEMA4AI_ROBOTS_VIEW_ACTION_DEBUG, + [robot_entry], + ) + ) + + if ( + compute_robo_tasks_code_lenses + and isinstance(decorator, ast.Name) + and decorator.id == "task" + ): + function_name = node.name + start_line = decorator.lineno - 1 # AST line numbers are 1-based + tasks_code_lenses.append( + _create_code_lens( + start_line, + "Run Task", + "sema4ai.runRobocorpsPythonTask", + [ + [ + document.path, + "-t", + function_name, + ] + ], + ) + ) + tasks_code_lenses.append( + _create_code_lens( + start_line, + "Debug Task", + "sema4ai.debugRobocorpsPythonTask", + [ + [ + document.path, + "-t", + function_name, + ] + ], + ) + ) + + all_lenses = [] + if found_robocorp_tasks_import: + # If there was no import, then @task decorators are from some other library! + all_lenses.extend(tasks_code_lenses) + + if found_sema4ai_actions_import: + # If there was no import, then @action decorators are from some other library! + all_lenses.extend(actions_code_lenses) + return all_lenses diff --git a/sema4ai/src/sema4ai_code/robo/launch_dev_task.py b/sema4ai/src/sema4ai_code/robo/launch_dev_task.py new file mode 100644 index 00000000..4b31906c --- /dev/null +++ b/sema4ai/src/sema4ai_code/robo/launch_dev_task.py @@ -0,0 +1,45 @@ +import sys + + +def main() -> int: + import json + import os + import shlex + import subprocess + + try: + run_dev_tasks_str = os.environ["SEMA4AI_RUN_DEV_TASKS"] + except Exception: + print( + "Error: tasks to launch not defined in SEMA4AI_RUN_DEV_TASKS environment variable.", + file=sys.stderr, + ) + return 1 + try: + run_dev_tasks = json.loads(run_dev_tasks_str) + except Exception: + print( + "Error: contents of SEMA4AI_RUN_DEV_TASKS environment variable not a valid json.", + file=sys.stderr, + ) + return 1 + + assert isinstance(run_dev_tasks, list) + + for dev_task in run_dev_tasks: + print(f"\nRunning command: {shlex.join(dev_task)}") + print("=" * 120) + popen = subprocess.Popen([dev_task[0]] + dev_task[1:], shell=False) + popen.wait() + + if popen.returncode != 0: + print( + f"\nFailed running dev-task: `{shlex.join(dev_task)}` (return code: {popen.returncode}).", + file=sys.stderr, + ) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/sema4ai/src/sema4ai_code/robo/list_dev_tasks.py b/sema4ai/src/sema4ai_code/robo/list_dev_tasks.py new file mode 100644 index 00000000..63dd4447 --- /dev/null +++ b/sema4ai/src/sema4ai_code/robo/list_dev_tasks.py @@ -0,0 +1,48 @@ +import typing +from pathlib import Path + +from sema4ai_ls_core.protocols import ActionResult + +if typing.TYPE_CHECKING: + from sema4ai_code.vendored_deps.yaml_with_location import str_with_location + + +def list_dev_tasks_from_content( + action_package_yaml_content: str, action_package_yaml_path: Path +) -> ActionResult[dict["str_with_location", "str_with_location"]]: + from sema4ai_code.vendored_deps.yaml_with_location import ( + LoaderWithLines, + str_with_location, + ) + + try: + loader = LoaderWithLines(action_package_yaml_content) + loader.name = f".../{action_package_yaml_path.parent.name}/{action_package_yaml_path.name}" + yaml_content = loader.get_single_data() + except Exception as e: + return ActionResult.make_failure( + f"Unable to parse: {action_package_yaml_path} as YAML. Error: {e}" + ) + + if not isinstance(yaml_content, dict): + return ActionResult.make_failure( + f"Expected a dictionary as the root of: {action_package_yaml_path}." + ) + + dev_tasks = yaml_content.get("dev-tasks", {}) + if not isinstance(dev_tasks, dict): + return ActionResult.make_failure( + f"Expected dev-tasks to be a dictionary in: {action_package_yaml_path}. Found: {dev_tasks}" + ) + + for task_name, task_contents in dev_tasks.items(): + if not isinstance(task_name, str_with_location): + return ActionResult.make_failure( + f"Expected dev-task key to be a string in: {action_package_yaml_path} (dev-task: {task_name}: {task_contents})." + ) + if not isinstance(task_contents, str_with_location): + return ActionResult.make_failure( + f"Expected dev-task value to be a string in: {action_package_yaml_path} (dev-task: {task_name}: {task_contents})." + ) + + return ActionResult.make_success(dev_tasks) diff --git a/sema4ai/src/sema4ai_code/robocorp_language_server.py b/sema4ai/src/sema4ai_code/robocorp_language_server.py index 152194bc..bef3ce0c 100644 --- a/sema4ai/src/sema4ai_code/robocorp_language_server.py +++ b/sema4ai/src/sema4ai_code/robocorp_language_server.py @@ -1042,6 +1042,168 @@ def _local_list_robots(self, params=None) -> ActionResultDictLocalRobotMetadata: def m_list_robots(self) -> ActionResultDictLocalRobotMetadata: return self._local_list_robots() + def m_list_dev_tasks(self, action_package_uri: str) -> partial: + return require_monitor( + partial(self._list_dev_tasks_in_thread, action_package_uri) + ) + + def _list_dev_tasks_in_thread( + self, action_package_uri: str, monitor: IMonitor + ) -> ActionResultDict: + """ + Provides a dict[str, str] with the dev tasks found in the Action Package. + """ + from sema4ai_code.robo.list_dev_tasks import list_dev_tasks_from_content + + action_package_yaml_path = Path(uris.to_fs_path(action_package_uri)) + try: + action_package_yaml_content = action_package_yaml_path.read_text() + except Exception as e: + return dict( + success=False, + message=f"Unable to read: {action_package_yaml_path}. Error: {e}", + result=None, + ) + + return list_dev_tasks_from_content( + action_package_yaml_content, action_package_yaml_path + ).as_dict() + + def m_compute_dev_task_spec_to_run( + self, package_yaml_path: str, task_name: str + ) -> partial: + return require_monitor( + partial( + self._compute_dev_task_spec_to_run_in_thread, + package_yaml_path, + task_name, + ) + ) + + def _compute_dev_task_spec_to_run_in_thread( + self, + package_yaml_path: str, + task_name: str, + monitor: IMonitor, + ) -> ActionResultDict: + import json + import shlex + + from sema4ai_ls_core.process import build_python_launch_env + + from sema4ai_code.robo import launch_dev_task + + result = self._list_dev_tasks_in_thread( + uris.from_fs_path(package_yaml_path), monitor + ) + if not result["success"]: + return result + + try: + task_contents = result["result"][task_name] + except KeyError: + return dict( + success=False, + message=f"dev-task: {task_name} not found in: {package_yaml_path}.", + result=None, + ) + + task_command = task_contents.strip() + if not task_command: + return dict( + success=False, + message="Expected task contents to be a non-empty string.", + result=None, + ) + + task_commands: list[list[str]] = [] + for line in task_command.splitlines(): + monitor.check_cancelled() + if line.strip(): + try: + c = shlex.split(line.strip()) + except Exception as e: + msg = f"Unable to shlex.split command: {line.strip()}. Error: {e}" + log.critical(msg) + return dict(success=False, message=msg, result=None) + + if not c: + msg = f"Unable to make sense of command: {line.strip()}." + log.critical(msg) + return dict(success=False, message=msg, result=None) + task_commands.append(c) + + if len(task_commands) == 1: + log.debug(f"Parsed command as: {task_commands[0]}") + else: + m = "\n ".join(str(x) for x in task_commands) + log.debug(f"Parsed (multiple) commands as:\n{m}") + + cwd = str(Path(package_yaml_path).parent) + interpreter_info_result = self._resolve_interpreter( + params=dict(target_robot=package_yaml_path) + ) + monitor.check_cancelled() + if not interpreter_info_result["success"]: + return interpreter_info_result + + interpreter_info = interpreter_info_result["result"] + + env = build_python_launch_env(interpreter_info["environ"]) + + log.debug("Environment variables:") + for env_key in ("PATH", "PYTHONPATH"): + env_val = env.get(env_key) + if env_val: + log.debug(f"{env_key}:") + for path_part in env_val.split(os.path.pathsep): + log.debug(f" {path_part}") + else: + log.debug(f"{env_key}: (none)") + try: + PATH = env["PATH"] + + commands_list = [] + + for command in task_commands: + # search for command[0] in PATH + + command_path = None + for path in PATH.split(os.path.pathsep): + if os.path.exists(os.path.join(path, command[0])): + command_path = os.path.join(path, command[0]) + break + if sys.platform == "win32": + if os.path.exists(os.path.join(path, command[0] + ".exe")): + command_path = os.path.join(path, command[0] + ".exe") + break + + if not command_path: + msg = f"Command: {command[0]} not found in PATH." + log.critical(msg) + return dict(success=False, message=msg, result=None) + log.debug(f"Target program: {command_path}") + + monitor.check_cancelled() + + commands_list.append(command) + + env["SEMA4AI_RUN_DEV_TASKS"] = json.dumps(commands_list) + python = env["PYTHON_EXE"] + task_spec = { + "env": env, + "cwd": cwd, + "program": python, + "args": [launch_dev_task.__file__], + } + + return dict(success=True, message=None, result=task_spec) + + except Exception as e: + msg = f"It was not possible to run the task: {task_name}.\nThe error below happened when running the command:\n{task_command}\n{e}" + log.critical(msg) + return dict(success=False, message=msg, result=None) + def _validate_directory(self, directory) -> str | None: if not os.path.exists(directory): return f"Expected: {directory} to exist." @@ -1322,13 +1484,11 @@ def _send_metric(self, params: dict) -> ActionResultDict: return {"success": True, "message": None, "result": None} def m_text_document__code_lens(self, **kwargs) -> partial | None: - from sema4ai_code.robo.launch_code_lens import compute_launch_robo_code_lens + from sema4ai_code.robo.launch_code_lens import compute_code_lenses doc_uri = kwargs["textDocument"]["uri"] - return compute_launch_robo_code_lens( - self._workspace, self._config_provider, doc_uri - ) + return compute_code_lenses(self._workspace, self._config_provider, doc_uri) def m_text_document__hover(self, **kwargs) -> Any: """ diff --git a/sema4ai/src/sema4ai_code/settings.py b/sema4ai/src/sema4ai_code/settings.py index f7244a80..5efc2ed2 100644 --- a/sema4ai/src/sema4ai_code/settings.py +++ b/sema4ai/src/sema4ai_code/settings.py @@ -14,6 +14,8 @@ SEMA4AI_PROCEED_WITH_LONG_PATHS_DISABLED = "sema4ai.proceedWithLongPathsDisabled" SEMA4AI_VAULT_TOKEN_TIMEOUT_IN_MINUTES = "sema4ai.vaultTokenTimeoutInMinutes" SEMA4AI_CODE_LENS_ROBO_LAUNCH = "sema4ai.codeLens.roboLaunch" +SEMA4AI_CODE_LENS_ACTIONS_LAUNCH = "sema4ai.codeLens.actionsLaunch" +SEMA4AI_CODE_LENS_DEV_TASK = "sema4ai.codeLens.devTask" ALL_SEMA4AI_OPTIONS = frozenset( ( @@ -30,6 +32,8 @@ SEMA4AI_PROCEED_WITH_LONG_PATHS_DISABLED, SEMA4AI_VAULT_TOKEN_TIMEOUT_IN_MINUTES, SEMA4AI_CODE_LENS_ROBO_LAUNCH, + SEMA4AI_CODE_LENS_ACTIONS_LAUNCH, + SEMA4AI_CODE_LENS_DEV_TASK, ) ) diff --git a/sema4ai/src/sema4ai_code/vendored_deps/package_deps/analyzer.py b/sema4ai/src/sema4ai_code/vendored_deps/package_deps/analyzer.py index 9b5662e6..eeb0952e 100644 --- a/sema4ai/src/sema4ai_code/vendored_deps/package_deps/analyzer.py +++ b/sema4ai/src/sema4ai_code/vendored_deps/package_deps/analyzer.py @@ -5,8 +5,8 @@ import pathlib import typing -from typing import List, Optional, Union from collections.abc import Iterator +from typing import Optional, Union from ..ls_protocols import _DiagnosticSeverity, _DiagnosticsTypedDict from ._deps_protocols import ICondaCloud, IPyPiCloud diff --git a/sema4ai/tests/sema4ai_code_tests/_resources/action_packages/action_package1/package.yaml b/sema4ai/tests/sema4ai_code_tests/_resources/action_packages/action_package1/package.yaml index 3a0f4f7e..97d4a83c 100644 --- a/sema4ai/tests/sema4ai_code_tests/_resources/action_packages/action_package1/package.yaml +++ b/sema4ai/tests/sema4ai_code_tests/_resources/action_packages/action_package1/package.yaml @@ -14,3 +14,6 @@ dependencies: - pip=23.2.1 pypi: - sema4ai-actions=0.9.1 + +dev-tasks: + dev_task: python -c 'print("Hello, world!")' diff --git a/sema4ai/tests/sema4ai_code_tests/robo/test_tasks_code_lenses.py b/sema4ai/tests/sema4ai_code_tests/robo/test_tasks_code_lenses.py index d26f007f..e00706bb 100644 --- a/sema4ai/tests/sema4ai_code_tests/robo/test_tasks_code_lenses.py +++ b/sema4ai/tests/sema4ai_code_tests/robo/test_tasks_code_lenses.py @@ -1,7 +1,9 @@ import os +import pytest from sema4ai_code_tests.protocols import IRobocorpLanguageServerClient from sema4ai_ls_core import uris +from sema4ai_ls_core.protocols import IWorkspace def test_tasks_code_lenses( @@ -30,8 +32,122 @@ def cache_on_task(): ret = language_server.request_code_lens(uri) result = ret["result"] - assert len(result) == 2 - for r in result: - assert r["command"]["arguments"][0][0].lower() == path.lower() - r["command"]["arguments"][0][0] = "filepath.py" + result = _fix_code_lenses(result, ws_root_path) data_regression.check(result) + + +@pytest.fixture +def workspace(ws_root_path) -> IWorkspace: + from sema4ai_ls_core import watchdog_wrapper + from sema4ai_ls_core.lsp import WorkspaceFolder + from sema4ai_ls_core.protocols import IWorkspaceFolder + from sema4ai_ls_core.workspace import Workspace + + root_uri = uris.from_fs_path(str(ws_root_path)) + workspace_folders: list[IWorkspaceFolder] = [ + WorkspaceFolder(root_uri, os.path.basename(str(ws_root_path))) + ] + extensions = (".py", ".txt", ".yaml") + fs_observer = watchdog_wrapper.create_observer("dummy", extensions=extensions) + + ws = Workspace( + root_uri, fs_observer, workspace_folders, track_file_extensions=extensions + ) + return ws + + +def _fix_code_lenses(code_lenses: list | None, root): + """ + Adjusts the file paths in the code lenses to be relative to the specified root directory. + + :param code_lenses: List of code lens dictionaries. + :param root: The root directory to which paths should be made relative. + :return: The modified list of code lenses with updated paths. + """ + if code_lenses is None: + code_lenses = [] + for lens in code_lenses: + if "command" in lens and "arguments" in lens["command"]: + for arg_list in lens["command"]["arguments"]: + if isinstance(arg_list, list): + for i, arg in enumerate(arg_list): + if isinstance(arg, str) and os.path.isabs(arg): + # Make the path relative to the root + arg_list[i] = os.path.relpath( + os.path.normcase(arg), start=os.path.normcase(root) + ) + elif isinstance(arg_list, dict): + if "uri" in arg_list: + arg_list["uri"] = os.path.basename(arg_list["uri"]) + + if "robot" in arg_list: + if "filePath" in arg_list["robot"]: + arg_list["robot"]["filePath"] = os.path.basename( + arg_list["robot"]["filePath"] + ) + + if "directory" in arg_list["robot"]: + arg_list["robot"]["directory"] = os.path.basename( + arg_list["robot"]["directory"] + ) + return code_lenses + + +@pytest.mark.parametrize( + "scenario", + [ + "simple", + "no_import", + "actions", + ], +) +def test_call_compute_code_lenses_directly( + ws_root_path, workspace, config_provider, data_regression, scenario +) -> None: + """ + This is to test that compute_code_lenses can be called directly. + """ + from pathlib import Path + + from sema4ai_ls_core.jsonrpc.monitor import Monitor + + from sema4ai_code.robo.launch_code_lens import compute_code_lenses + + root = Path(ws_root_path) + root.mkdir(parents=True, exist_ok=True) + path = root / "my.py" + uri = uris.from_fs_path(str(path)) + + if scenario == "simple": + txt = """ +from robocorp.tasks import task + +@task +def my_entry_point(): + pass + """ + elif scenario == "actions": + txt = """ +from sema4ai.actions import action +@action +def my_entry_point(): + pass + """ + + # We must have a package.yaml file in the root directory for actions to be searched for. + (root / "package.yaml").write_text("") + + elif scenario == "no_import": + txt = """ +@task +def my_entry_point(): + pass + """ + path.write_text(txt) + + func = compute_code_lenses(workspace, config_provider, uri) + assert func is not None + ret = func(monitor=Monitor()) + + ret = _fix_code_lenses(ret, root) + data_regression.check(ret, basename=f"run_task_{scenario}") diff --git a/sema4ai/tests/sema4ai_code_tests/robo/test_tasks_code_lenses/run_task_actions.yml b/sema4ai/tests/sema4ai_code_tests/robo/test_tasks_code_lenses/run_task_actions.yml new file mode 100644 index 00000000..97851bd1 --- /dev/null +++ b/sema4ai/tests/sema4ai_code_tests/robo/test_tasks_code_lenses/run_task_actions.yml @@ -0,0 +1,34 @@ +- command: + arguments: + - actionName: my_entry_point + robot: + directory: root + filePath: package.yaml + uri: my.py + command: sema4ai.robotsViewActionRun + title: Run Action + data: null + range: + end: + character: 0 + line: 2 + start: + character: 0 + line: 2 +- command: + arguments: + - actionName: my_entry_point + robot: + directory: root + filePath: package.yaml + uri: my.py + command: sema4ai.robotsViewActionDebug + title: Debug Action + data: null + range: + end: + character: 0 + line: 2 + start: + character: 0 + line: 2 diff --git a/sema4ai/tests/sema4ai_code_tests/robo/test_tasks_code_lenses/run_task_no_import.yml b/sema4ai/tests/sema4ai_code_tests/robo/test_tasks_code_lenses/run_task_no_import.yml new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/sema4ai/tests/sema4ai_code_tests/robo/test_tasks_code_lenses/run_task_no_import.yml @@ -0,0 +1 @@ +[] diff --git a/sema4ai/tests/sema4ai_code_tests/robo/test_tasks_code_lenses/run_task_simple.yml b/sema4ai/tests/sema4ai_code_tests/robo/test_tasks_code_lenses/run_task_simple.yml new file mode 100644 index 00000000..0b79f971 --- /dev/null +++ b/sema4ai/tests/sema4ai_code_tests/robo/test_tasks_code_lenses/run_task_simple.yml @@ -0,0 +1,30 @@ +- command: + arguments: + - - my.py + - -t + - my_entry_point + command: sema4ai.runRobocorpsPythonTask + title: Run Task + data: null + range: + end: + character: 0 + line: 3 + start: + character: 0 + line: 3 +- command: + arguments: + - - my.py + - -t + - my_entry_point + command: sema4ai.debugRobocorpsPythonTask + title: Debug Task + data: null + range: + end: + character: 0 + line: 3 + start: + character: 0 + line: 3 diff --git a/sema4ai/tests/sema4ai_code_tests/robo/test_tasks_code_lenses/test_tasks_code_lenses.yml b/sema4ai/tests/sema4ai_code_tests/robo/test_tasks_code_lenses/test_tasks_code_lenses.yml index 64707c06..0b79f971 100644 --- a/sema4ai/tests/sema4ai_code_tests/robo/test_tasks_code_lenses/test_tasks_code_lenses.yml +++ b/sema4ai/tests/sema4ai_code_tests/robo/test_tasks_code_lenses/test_tasks_code_lenses.yml @@ -1,6 +1,6 @@ - command: arguments: - - - filepath.py + - - my.py - -t - my_entry_point command: sema4ai.runRobocorpsPythonTask @@ -15,7 +15,7 @@ line: 3 - command: arguments: - - - filepath.py + - - my.py - -t - my_entry_point command: sema4ai.debugRobocorpsPythonTask diff --git a/sema4ai/tests/sema4ai_code_tests/test_vscode_integration.py b/sema4ai/tests/sema4ai_code_tests/test_vscode_integration.py index bf283b9c..1ca8fdb8 100644 --- a/sema4ai/tests/sema4ai_code_tests/test_vscode_integration.py +++ b/sema4ai/tests/sema4ai_code_tests/test_vscode_integration.py @@ -2924,3 +2924,56 @@ def test_import_action_packages(language_server_initialized, tmpdir, datadir) -> ), f'No success response from "importZipAsActionPackage". Response: {result}' created_dir = msg_result["result"] assert os.listdir(Path(created_dir)) + + +def test_run_dev_task( + language_server_initialized, cases: CasesFixture, data_regression +) -> None: + action_package_path = Path(cases.get_path("action_packages"), "action_package1") + package_yaml = Path(action_package_path, "package.yaml") + assert package_yaml.exists() + + language_server = language_server_initialized + + result = language_server.request( + { + "jsonrpc": "2.0", + "id": language_server.next_id(), + "method": "listDevTasks", + "params": {"action_package_uri": str(uris.from_fs_path(str(package_yaml)))}, + } + ) + + if "success" not in result and "result" in result: + result = result["result"] + + assert result["success"], result["message"] + dev_tasks = result["result"] + assert dev_tasks == {"dev_task": "python -c 'print(\"Hello, world!\")'"} + + result = language_server.request( + { + "jsonrpc": "2.0", + "id": language_server.next_id(), + "method": "computeDevTaskSpecToRun", + "params": { + "package_yaml_path": str(package_yaml), + "task_name": "dev_task", + }, + } + ) + + action_result = result["result"] + assert action_result["success"], action_result["message"] + info = action_result["result"] + + env = info.pop("env", None) + assert env is not None, f"env not in {info}" + assert "CONDA_PREFIX" in env + + args = info.pop("args") + assert len(args) == 1 + assert args[0].endswith("launch_dev_task.py") + + cwd = info.pop("cwd") + assert cwd.endswith("action_package1") diff --git a/sema4ai/vscode-client/src/extension.ts b/sema4ai/vscode-client/src/extension.ts index 16f0c77f..674f3f47 100644 --- a/sema4ai/vscode-client/src/extension.ts +++ b/sema4ai/vscode-client/src/extension.ts @@ -159,6 +159,7 @@ import { SEMA4AI_REFRESH_AGENT_SPEC, SEMA4AI_COLLAPSE_ALL_ENTRIES, SEMA4AI_IMPORT_ACTION_PACKAGE, + SEMA4AI_RUN_ACTION_PACKAGE_DEV_TASK, } from "./robocorpCommands"; import { installWorkspaceWatcher } from "./pythonExtIntegration"; import { refreshCloudTreeView } from "./viewsRobocorp"; @@ -197,6 +198,7 @@ import { import { getSema4AIStudioURLForAgentZipPath, getSema4AIStudioURLForFolderPath } from "./deepLink"; import { LocalPackageMetadataInfo } from "./protocols"; import { importActionPackage } from "./robo/importActions"; +import { DevTaskInfo, runActionPackageDevTask } from "./robo/runActionPackageDevTask"; interface InterpreterInfo { pythonExe: string; @@ -402,8 +404,8 @@ function registerRobocorpCodeCommands(C: CommandRegistry, context: ExtensionCont C.register(SEMA4AI_ROBOTS_VIEW_ACTION_DEBUG, (entry: RobotEntry) => views.runSelectedAction(false, entry)); C.register(SEMA4AI_ROBOTS_VIEW_ACTION_EDIT_INPUT, (entry: RobotEntry) => views.editInput(entry)); C.register(SEMA4AI_ROBOTS_VIEW_ACTION_OPEN, (entry: RobotEntry) => views.openAction(entry)); - C.register(SEMA4AI_RUN_ROBOCORPS_PYTHON_TASK, (args: string[]) => runRobocorpTasks(true, args)); - C.register(SEMA4AI_DEBUG_ROBOCORPS_PYTHON_TASK, (args: string[]) => runRobocorpTasks(false, args)); + C.register(SEMA4AI_RUN_ROBOCORPS_PYTHON_TASK, (args: string[] | undefined) => runRobocorpTasks(true, args)); + C.register(SEMA4AI_DEBUG_ROBOCORPS_PYTHON_TASK, (args: string[] | undefined) => runRobocorpTasks(false, args)); C.register(SEMA4AI_EDIT_ROBOCORP_INSPECTOR_LOCATOR, (locator?: LocatorEntry): Promise => { return showInspectorUI(context, IAppRoutes.LOCATORS_MANAGER); }); @@ -506,6 +508,9 @@ function registerRobocorpCodeCommands(C: CommandRegistry, context: ExtensionCont C.register(SEMA4AI_REFRESH_AGENT_SPEC, async (agentPath: string) => refreshAgentSpec(agentPath)); C.register(SEMA4AI_COLLAPSE_ALL_ENTRIES, collapseAllEntries); C.register(SEMA4AI_IMPORT_ACTION_PACKAGE, async (agentPath: string) => importActionPackage(agentPath)); + C.register(SEMA4AI_RUN_ACTION_PACKAGE_DEV_TASK, async (devTaskInfo: DevTaskInfo | undefined) => + runActionPackageDevTask(devTaskInfo) + ); } async function clearEnvAndRestart() { diff --git a/sema4ai/vscode-client/src/rccTerminal.ts b/sema4ai/vscode-client/src/rccTerminal.ts index 6d7ab955..d400d53f 100644 --- a/sema4ai/vscode-client/src/rccTerminal.ts +++ b/sema4ai/vscode-client/src/rccTerminal.ts @@ -2,12 +2,13 @@ import { Progress, ProgressLocation, window } from "vscode"; import { OUTPUT_CHANNEL } from "./channel"; import * as pathModule from "path"; import { listAndAskRobotSelection, resolveInterpreter } from "./activities"; -import { getRccLocation } from "./rcc"; +import { getRccLocation, getRobocorpHome } from "./rcc"; import { mergeEnviron } from "./subprocess"; import { getAutosetpythonextensiondisableactivateterminal } from "./robocorpSettings"; import { disablePythonTerminalActivateEnvironment } from "./pythonExtIntegration"; import { LocalPackageMetadataInfo, ActionResult, InterpreterInfo } from "./protocols"; import * as fsModule from "fs"; +import { randomBytes } from "crypto"; export async function askAndCreateRccTerminal() { let robot: LocalPackageMetadataInfo = await listAndAskRobotSelection( @@ -20,6 +21,127 @@ export async function askAndCreateRccTerminal() { } } +const createTempFile = async () => { + const sema4aiHome = await getRobocorpHome(); + const sema4aiHomeTemp = pathModule.join(sema4aiHome, "temp"); + if (!fsModule.existsSync(sema4aiHomeTemp)) { + fsModule.mkdirSync(sema4aiHomeTemp, { recursive: true }); + } + const isWindows = process.platform.toString() === "win32"; + const filename = "env-vars-" + randomBytes(16).toString("hex"); + const varsFilePath = pathModule.join(sema4aiHomeTemp, filename + (isWindows ? ".bat" : ".sh")); + + const deleteFile = () => { + fsModule.rmSync(varsFilePath, { force: true, recursive: true }); + }; + + // Delete temporary file when program exits + process.once("exit", deleteFile); + process.once("SIGINT", deleteFile); + process.once("SIGTERM", deleteFile); + + return varsFilePath; +}; + +/** + * Creates a terminal with the interpreter resolved from the given file path. + */ +export async function createTerminalForFile(filePath: string, terminalName: string, additionalPathEntry?: string) { + let result: ActionResult = await resolveInterpreter(filePath); + if (!result.success) { + window.showWarningMessage("Error resolving interpreter info: " + result.message); + return; + } + + let interpreter: InterpreterInfo = result.result; + if (!interpreter || !interpreter.pythonExe) { + window.showWarningMessage("Unable to obtain interpreter information from: " + filePath); + return; + } + OUTPUT_CHANNEL.appendLine("Retrieved Python interpreter: " + interpreter.pythonExe); + + // If vscode-python is installed, we need to disable the terminal activation as it + // conflicts with the robot environment. + if (getAutosetpythonextensiondisableactivateterminal()) { + await disablePythonTerminalActivateEnvironment(); + } + + let env = mergeEnviron(); + // Update env to contain rcc location. + if (interpreter.environ) { + for (let key of Object.keys(interpreter.environ)) { + let value = interpreter.environ[key]; + let isPath = false; + if (process.platform == "win32") { + key = key.toUpperCase(); + if (key == "PATH") { + isPath = true; + } + } else { + if (key == "PATH") { + isPath = true; + } + } + if (isPath) { + value = additionalPathEntry + pathModule.delimiter + value; + } + + env[key] = value; + } + } + OUTPUT_CHANNEL.appendLine("Retrieved environment: " + JSON.stringify(env, null, 2)); + + // We need to activate the RCC python environment after the terminal has spawned + // This way we avoid the environment being overwritten by shell startup scripts + // The Terminal env injection works if no overwrites happen + + const sema4aiHome = await getRobocorpHome(); + const sema4aiHomeTemp = pathModule.join(sema4aiHome, "temp"); + if (!fsModule.existsSync(sema4aiHomeTemp)) { + fsModule.mkdirSync(sema4aiHomeTemp, { recursive: true }); + } + const varsFilePath = await createTempFile(); + + if (process.platform.toString() === "win32") { + // Making sure we create a CMD prompt in Windows as it can default to PowerShell + // and the Python Environment activation fails + const terminal = window.createTerminal({ + name: terminalName, + env: env, + cwd: pathModule.dirname(filePath), + message: "Sema4.ai Package Activated Interpreter (Python Environment)", + shellPath: "C:\\Windows\\System32\\cmd.exe", + }); + + const envVarsContent = Object.keys(env) + .reduce((acc, key) => { + return `${acc}SET ${key}=${env[key]}\n`; + }, "") + .trim(); + OUTPUT_CHANNEL.appendLine("Create terminal with environment: " + envVarsContent); + fsModule.writeFileSync(varsFilePath, envVarsContent); + terminal.sendText(`call ${varsFilePath}\n`); + terminal.show(); + } else { + // The shell in UNIX doesn't matter that much as the syntax to set the Python Environment is common + const terminal = window.createTerminal({ + name: terminalName, + env: env, + cwd: pathModule.dirname(filePath), + message: "Sema4.ai Package Activated Interpreter (Python Environment)", + }); + const envVarsContent = Object.keys(env) + .reduce((acc, key) => { + return `${acc}export ${key}=${env[key]}\n`; + }, "") + .trim(); + OUTPUT_CHANNEL.appendLine("Create terminal with environment: " + envVarsContent); + fsModule.writeFileSync(varsFilePath, envVarsContent); + terminal.sendText(`source ${varsFilePath}\n`); + terminal.show(); + } +} + export async function createRccTerminal(robotInfo: LocalPackageMetadataInfo) { if (robotInfo) { async function startShell(progress: Progress<{ message?: string; increment?: number }>): Promise { @@ -35,96 +157,14 @@ export async function createRccTerminal(robotInfo: LocalPackageMetadataInfo) { return; } - let result: ActionResult = await resolveInterpreter(robotInfo.filePath); - if (!result.success) { - window.showWarningMessage("Error resolving interpreter info: " + result.message); - return; - } - - let interpreter: InterpreterInfo = result.result; - if (!interpreter || !interpreter.pythonExe) { - window.showWarningMessage("Unable to obtain interpreter information from: " + robotInfo.filePath); - return; - } - OUTPUT_CHANNEL.appendLine("Retrieved Python interpreter: " + interpreter.pythonExe); - - // If vscode-python is installed, we need to disable the terminal activation as it - // conflicts with the robot environment. - if (getAutosetpythonextensiondisableactivateterminal()) { - await disablePythonTerminalActivateEnvironment(); - } - - let env = mergeEnviron(); - // Update env to contain rcc location. - if (interpreter.environ) { - for (let key of Object.keys(interpreter.environ)) { - let value = interpreter.environ[key]; - let isPath = false; - if (process.platform == "win32") { - key = key.toUpperCase(); - if (key == "PATH") { - isPath = true; - } - } else { - if (key == "PATH") { - isPath = true; - } - } - if (isPath) { - value = pathModule.dirname(rccLocation) + pathModule.delimiter + value; - } - - env[key] = value; - } - } - OUTPUT_CHANNEL.appendLine("Retrieved environment: " + JSON.stringify(env, null, 2)); - OUTPUT_CHANNEL.appendLine( "Create terminal with RCC: " + rccLocation + " for Package: " + robotInfo.filePath ); - - // We need to activate the RCC python environment after the terminal has spawned - // This way we avoid the environment being overwritten by shell startup scripts - // The Terminal env injection works if no overwrites happen - if (process.platform.toString() === "win32") { - // Making sure we create a CMD prompt in Windows as it can default to PowerShell - // and the Python Environment activation fails - const terminal = window.createTerminal({ - name: robotInfo.name + " Package environment", - env: env, - cwd: pathModule.dirname(robotInfo.filePath), - message: "Sema4.ai Package Activated Interpreter (Python Environment)", - shellPath: "C:\\Windows\\System32\\cmd.exe", - }); - const varsFilePath = pathModule.join(env.RCC_HOLOTREE_SPACE_ROOT, "environment_vars.bat"); - const envVarsContent = Object.keys(env) - .reduce((acc, key) => { - return `${acc}SET ${key}=${env[key]}\n`; - }, "") - .trim(); - OUTPUT_CHANNEL.appendLine("Create terminal with RCC: " + envVarsContent); - fsModule.writeFileSync(varsFilePath, envVarsContent); - terminal.sendText(`call ${varsFilePath}\n`); - terminal.show(); - } else { - // The shell in UNIX doesn't matter that much as the syntax to set the Python Environment is common - const terminal = window.createTerminal({ - name: robotInfo.name + " Package environment", - env: env, - cwd: pathModule.dirname(robotInfo.filePath), - message: "Sema4.ai Package Activated Interpreter (Python Environment)", - }); - const varsFilePath = pathModule.join(env.RCC_HOLOTREE_SPACE_ROOT, "environment_vars.sh"); - const envVarsContent = Object.keys(env) - .reduce((acc, key) => { - return `${acc}export ${key}=${env[key]}\n`; - }, "") - .trim(); - OUTPUT_CHANNEL.appendLine("Create terminal with RCC: " + envVarsContent); - fsModule.writeFileSync(varsFilePath, envVarsContent); - terminal.sendText(`source ${varsFilePath}\n`); - terminal.show(); - } + createTerminalForFile( + robotInfo.filePath, + robotInfo.name + " Package environment", + pathModule.dirname(rccLocation) + ); OUTPUT_CHANNEL.appendLine("Terminal created!"); return undefined; diff --git a/sema4ai/vscode-client/src/robo/actionPackage.ts b/sema4ai/vscode-client/src/robo/actionPackage.ts index 3932b71d..6666d095 100644 --- a/sema4ai/vscode-client/src/robo/actionPackage.ts +++ b/sema4ai/vscode-client/src/robo/actionPackage.ts @@ -52,6 +52,7 @@ import { import { loginToAuth2WhereRequired } from "./oauth2InInput"; import { RobotEntryType } from "../viewsCommon"; import { createActionInputs, errorMessageValidatingV2Input } from "./actionInputs"; +import { langServer } from "../extension"; export interface QuickPickItemAction extends QuickPickItem { actionPackageUri: vscode.Uri; @@ -62,25 +63,22 @@ export interface QuickPickItemAction extends QuickPickItem { keyInLRU: string; } -export async function askAndRunRobocorpActionFromActionPackage(noDebug: boolean) { - let textEditor = window.activeTextEditor; - let fileName: string | undefined = undefined; - - if (textEditor) { - fileName = textEditor.document.fileName; - } - - const RUN_ACTION_FROM_ACTION_PACKAGE_LRU_CACHE = "RUN_ACTION_FROM_ACTION_PACKAGE_LRU_CACHE"; - let runLRU: string[] = await commands.executeCommand(roboCommands.SEMA4AI_LOAD_FROM_DISK_LRU, { - "name": RUN_ACTION_FROM_ACTION_PACKAGE_LRU_CACHE, - }); +export interface QuickPickItemDevTask extends QuickPickItem { + taskName: string; + taskContents: string; + actionPackageYaml: string; +} +/** + * Lists all the action packages available in the workspace (both in the current workspace and in the agent packages). + * @returns The list of action packages or undefined if there was an error. + */ +export async function listAllActionPackages(): Promise> { let actionResult: ActionResult = await commands.executeCommand( roboCommands.SEMA4AI_LOCAL_LIST_ROBOTS_INTERNAL ); if (!actionResult.success) { - window.showErrorMessage("Error listing Action Packages: " + actionResult.message); - return; + return actionResult; } let robotsInfo: LocalPackageMetadataInfo[] = actionResult.result; if (robotsInfo) { @@ -107,8 +105,134 @@ export async function askAndRunRobocorpActionFromActionPackage(noDebug: boolean) } } - if (!robotsInfo || robotsInfo.length == 0) { - window.showInformationMessage("Unable to run Action Package (no Action Packages detected in the Workspace)."); + return { success: true, message: undefined, result: robotsInfo }; +} + +export async function runActionPackageFromFileAndName(noDebug: boolean, fileName: string, actionName: string) { + let actionPackageYamlDirectory: string | undefined = undefined; + let packageYaml: string | undefined = undefined; + + // Search the parents of fileName until a package.yaml is found. + let currentDir = path.dirname(fileName); + while (true) { + const potentialPackageYaml = path.join(currentDir, "package.yaml"); + if (await fileExists(potentialPackageYaml)) { + packageYaml = potentialPackageYaml; + actionPackageYamlDirectory = currentDir; + break; + } + const oldDir = currentDir; + currentDir = path.dirname(currentDir); + if (oldDir === currentDir || currentDir === "/" || !currentDir) { + break; + } + } + + if (!packageYaml || !actionPackageYamlDirectory) { + window.showErrorMessage("No package.yaml found in the parent directories."); + return; + } + + const actionFileUri: vscode.Uri = vscode.Uri.file(fileName); + await runActionFromActionPackage(noDebug, actionName, actionPackageYamlDirectory, packageYaml, actionFileUri); +} + +export interface ActionPackageAndActionResult { + actionName: string; + actionPackageYamlDirectory: string; + packageYaml: string; + actionFileUri: vscode.Uri; +} + +export interface ActionPackageAndDevTaskResult { + taskName: string; + taskContents: string; + actionPackageYaml: string; +} + +export async function askForActionPackageAndDevTask(): Promise { + let actionResult: ActionResult = await listAllActionPackages(); + if (!actionResult.success) { + window.showInformationMessage(actionResult.message); + return; + } + let localActionPackages: LocalPackageMetadataInfo[] = actionResult.result; + if (localActionPackages.length == 0) { + window.showInformationMessage( + "Unable to select Action Package (no Action Packages detected in the Workspace)." + ); + return; + } + + let items: QuickPickItemDevTask[] = new Array(); + + for (let actionPackage of localActionPackages) { + try { + const actionPackageYamlUri = vscode.Uri.file(actionPackage.filePath); + const result: ActionResult<{ [key: string]: string }> = await langServer.sendRequest("listDevTasks", { + "action_package_uri": actionPackageYamlUri.toString(), + }); + + if (result.success) { + let devTasks: { [key: string]: string } = result.result; + for (const [taskName, taskContents] of Object.entries(devTasks)) { + const label = `${actionPackage.name}: ${taskName}`; + const item: QuickPickItemDevTask = { + "label": label, + "taskName": taskName, + "taskContents": taskContents, + "actionPackageYaml": actionPackage.filePath, + }; + items.push(item); + } + } + } catch (error) { + logError("Error collecting dev tasks.", error, "ACT_COLLECT_DEV_TASKS"); + } + } + + if (!items) { + window.showInformationMessage("Unable to select Dev Task (no Dev Tasks detected in the Workspace)."); + return; + } + + let selectedItem: QuickPickItemDevTask; + if (items.length == 1) { + selectedItem = items[0]; + } else { + selectedItem = await window.showQuickPick(items, { + "canPickMany": false, + "placeHolder": "Please select the Action Package and Dev Task.", + "ignoreFocusOut": true, + }); + } + + if (!selectedItem) { + return; + } + + const taskName: string = selectedItem.taskName; + const taskContents: string = selectedItem.taskContents; + const actionPackageYaml: string = selectedItem.actionPackageYaml; + return { taskName, taskContents, actionPackageYaml }; +} + +export async function askForActionPackageAndAction(): Promise { + const RUN_ACTION_FROM_ACTION_PACKAGE_LRU_CACHE = "RUN_ACTION_FROM_ACTION_PACKAGE_LRU_CACHE"; + let runLRU: string[] = await commands.executeCommand(roboCommands.SEMA4AI_LOAD_FROM_DISK_LRU, { + "name": RUN_ACTION_FROM_ACTION_PACKAGE_LRU_CACHE, + }); + + let actionResult: ActionResult = await listAllActionPackages(); + if (!actionResult.success) { + window.showInformationMessage(actionResult.message); + return; + } + let robotsInfo: LocalPackageMetadataInfo[] = actionResult.result; + if (robotsInfo.length == 0) { + window.showInformationMessage( + "Unable to select Action Package (no Action Packages detected in the Workspace)." + ); return; } @@ -151,7 +275,7 @@ export async function askAndRunRobocorpActionFromActionPackage(noDebug: boolean) } if (!items) { - window.showInformationMessage("Unable to run Action Package (no Action Package detected in the Workspace)."); + window.showInformationMessage("Unable to select Action Package (no Action Package detected in the Workspace)."); return; } @@ -161,7 +285,7 @@ export async function askAndRunRobocorpActionFromActionPackage(noDebug: boolean) } else { selectedItem = await window.showQuickPick(items, { "canPickMany": false, - "placeHolder": "Please select the Action Package and Action to run.", + "placeHolder": "Please select the Action Package and Action.", "ignoreFocusOut": true, }); } @@ -180,6 +304,15 @@ export async function askAndRunRobocorpActionFromActionPackage(noDebug: boolean) const actionPackageYamlDirectory: string = selectedItem.actionPackageYamlDirectory; const packageYaml: string = selectedItem.actionPackageUri.fsPath; const actionFileUri: vscode.Uri = selectedItem.actionFileUri; + return { actionName, actionPackageYamlDirectory, packageYaml, actionFileUri }; +} + +export async function askAndRunRobocorpActionFromActionPackage(noDebug: boolean) { + const selectedActionPackageAndAction = await askForActionPackageAndAction(); + if (!selectedActionPackageAndAction) { + return; + } + const { actionName, actionPackageYamlDirectory, packageYaml, actionFileUri } = selectedActionPackageAndAction; await runActionFromActionPackage(noDebug, actionName, actionPackageYamlDirectory, packageYaml, actionFileUri); } diff --git a/sema4ai/vscode-client/src/robo/importActions.ts b/sema4ai/vscode-client/src/robo/importActions.ts index 80489d3a..f4e8b88d 100644 --- a/sema4ai/vscode-client/src/robo/importActions.ts +++ b/sema4ai/vscode-client/src/robo/importActions.ts @@ -2,7 +2,6 @@ import { Uri, window, WorkspaceFolder } from "vscode"; import { askForWs, QuickPickItemWithAction, showSelectOneQuickPick } from "../ask"; import { afterActionPackageCreated, askActionPackageTargetDir } from "./actionPackage"; -import { verifyIfPathOkToCreatePackage } from "../common"; import { langServer } from "../extension"; import { logError, OUTPUT_CHANNEL } from "../channel"; import { PackageYamlName } from "../protocols"; diff --git a/sema4ai/vscode-client/src/robo/runActionPackageDevTask.ts b/sema4ai/vscode-client/src/robo/runActionPackageDevTask.ts new file mode 100644 index 00000000..94e50e18 --- /dev/null +++ b/sema4ai/vscode-client/src/robo/runActionPackageDevTask.ts @@ -0,0 +1,91 @@ +import { ProcessExecution, Task, TaskDefinition, tasks, window, WorkspaceFolder } from "vscode"; +import { askForActionPackageAndDevTask } from "./actionPackage"; +import { langServer } from "../extension"; +import { ActionResult } from "../protocols"; +import { askForWs } from "../ask"; + +export interface DevTaskInfo { + packageYamlPath: string; + taskName: string; +} + +export const runActionPackageDevTask = async (devTaskInfo: DevTaskInfo | undefined) => { + if (!devTaskInfo) { + devTaskInfo = { + packageYamlPath: undefined, + taskName: undefined, + }; + } + + if (!devTaskInfo.packageYamlPath && devTaskInfo.taskName) { + // This would be an error in the caller! + window.showErrorMessage( + "Unable to run Action Package dev task: The task name was provided, but the package.yaml file path was not." + ); + return; + } + + if (!devTaskInfo.packageYamlPath) { + // TODO: Filter based on the active text editor. + // const activeTextEditor = window.activeTextEditor; + // if (activeTextEditor && activeTextEditor.document && activeTextEditor.document.fileName) { + // if (activeTextEditor.document.fileName.endsWith("package.yaml")) { + // devTaskInfo.packageYamlPath = activeTextEditor.document.fileName; + // } + // } + + if (!devTaskInfo.packageYamlPath) { + // We don't even have an active package.yaml file opened. This means we have to ask the user to + // select an action package first. + const selection = await askForActionPackageAndDevTask(); + if (!selection) { + return; + } + devTaskInfo.packageYamlPath = selection.actionPackageYaml; + devTaskInfo.taskName = selection.taskName; + } + } + + const { packageYamlPath, taskName } = devTaskInfo; + + const definition: DevTaskDefinition = { + type: "Sema4.ai: dev-task", + }; + + let ws: WorkspaceFolder | undefined = await askForWs(); + if (!ws) { + // Operation cancelled. + return; + } + const result: ActionResult = await langServer.sendRequest("computeDevTaskSpecToRun", { + "package_yaml_path": packageYamlPath, + "task_name": taskName, + }); + + if (!result.success) { + window.showErrorMessage(result.message); + return; + } + + const processExecutionInfo: ProcessExecutionInfo = result.result; + if (!processExecutionInfo) { + window.showErrorMessage("Unable to run Action Package dev task: The task specification is missing."); + return; + } + + const processExecution: ProcessExecution = new ProcessExecution( + processExecutionInfo.program, + processExecutionInfo.args, + { cwd: processExecutionInfo.cwd, env: processExecutionInfo.env } + ); + tasks.executeTask(new Task(definition, ws, `Sema4.ai dev-task: ${taskName}`, "dev-task", processExecution)); +}; + +interface DevTaskDefinition extends TaskDefinition {} + +interface ProcessExecutionInfo { + cwd: string; + program: string; + args: string[]; + env: Record; +} diff --git a/sema4ai/vscode-client/src/robocorpCommands.ts b/sema4ai/vscode-client/src/robocorpCommands.ts index 083fabc2..70a27016 100644 --- a/sema4ai/vscode-client/src/robocorpCommands.ts +++ b/sema4ai/vscode-client/src/robocorpCommands.ts @@ -142,4 +142,5 @@ export const SEMA4AI_REFRESH_AGENT_SPEC_INTERNAL = "sema4ai.refreshAgentSpec.int export const SEMA4AI_UPDATE_AGENT_VERSION = "sema4ai.updateAgentVersion"; // Update Agent Version export const SEMA4AI_UPDATE_AGENT_VERSION_INTERNAL = "sema4ai.updateAgentVersion.internal"; // Update Agent Version (internal) export const SEMA4AI_COLLAPSE_ALL_ENTRIES = "sema4ai.collapseAllEntries"; // Collapse All Entries -export const SEMA4AI_IMPORT_ACTION_PACKAGE = "sema4ai.importActionPackage"; // Import Action Package \ No newline at end of file +export const SEMA4AI_IMPORT_ACTION_PACKAGE = "sema4ai.importActionPackage"; // Import Action Package +export const SEMA4AI_RUN_ACTION_PACKAGE_DEV_TASK = "sema4ai.runActionPackageDevTask"; // Run dev-task (from Action Package) \ No newline at end of file diff --git a/sema4ai/vscode-client/src/robocorpSettings.ts b/sema4ai/vscode-client/src/robocorpSettings.ts index c6314b3a..62213ab3 100644 --- a/sema4ai/vscode-client/src/robocorpSettings.ts +++ b/sema4ai/vscode-client/src/robocorpSettings.ts @@ -22,6 +22,8 @@ export const SEMA4AI_AUTO_SET_PYTHON_EXTENSION_DISABLE_ACTIVATE_TERMINAL = "sema export const SEMA4AI_PROCEED_WITH_LONG_PATHS_DISABLED = "sema4ai.proceedWithLongPathsDisabled"; export const SEMA4AI_VAULT_TOKEN_TIMEOUT_IN_MINUTES = "sema4ai.vaultTokenTimeoutInMinutes"; export const SEMA4AI_CODE_LENS_ROBO_LAUNCH = "sema4ai.codeLens.roboLaunch"; +export const SEMA4AI_CODE_LENS_ACTIONS_LAUNCH = "sema4ai.codeLens.actionsLaunch"; +export const SEMA4AI_CODE_LENS_DEV_TASK = "sema4ai.codeLens.devTask"; export function getLanguageServerTcpPort(): number { let key = SEMA4AI_LANGUAGE_SERVER_TCP_PORT; @@ -216,3 +218,33 @@ export async function setCodelensRobolaunch(value): Promise { let config = workspace.getConfiguration(key.slice(0, i)); await config.update(key.slice(i + 1), value, ConfigurationTarget.Global); } + + +export function getCodelensActionslaunch(): boolean { + let key = SEMA4AI_CODE_LENS_ACTIONS_LAUNCH; + return get(key); +} + + +export async function setCodelensActionslaunch(value): Promise { + let key = SEMA4AI_CODE_LENS_ACTIONS_LAUNCH; + let i = key.lastIndexOf('.'); + + let config = workspace.getConfiguration(key.slice(0, i)); + await config.update(key.slice(i + 1), value, ConfigurationTarget.Global); +} + + +export function getCodelensDevtask(): boolean { + let key = SEMA4AI_CODE_LENS_DEV_TASK; + return get(key); +} + + +export async function setCodelensDevtask(value): Promise { + let key = SEMA4AI_CODE_LENS_DEV_TASK; + let i = key.lastIndexOf('.'); + + let config = workspace.getConfiguration(key.slice(0, i)); + await config.update(key.slice(i + 1), value, ConfigurationTarget.Global); +}