From 47b8354cb3416bd4ed48d3136a7cce62f3e63931 Mon Sep 17 00:00:00 2001 From: kgarg2468 Date: Thu, 11 Jun 2026 16:27:03 -0700 Subject: [PATCH] feat(nodes): add Steadwing root-cause-analysis agent tool New agent-tool node (modeled on tool_tavily / tool_v0) exposing Steadwing's AI root-cause analysis: - run_rca(error, files?) -> POST https://api.steadwing.com/api/mcp/analyze (X-API-Key); returns the Steadwing investigation URL. - Secure apikey config field with ROCKETRIDE_STEADWING_KEY env fallback. - Pure-Python/REST via shared post_with_retry; raise semantics (tool_v0 style). - Official Steadwing brand logo, doc.md, and 29 network-free unit tests. Experimental V0. Lefthook ruff/gitleaks unavailable in env; ran ruff + secret scan manually. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 5 + nodes/src/nodes/tool_steadwing/IGlobal.py | 74 ++++ nodes/src/nodes/tool_steadwing/IInstance.py | 207 +++++++++ nodes/src/nodes/tool_steadwing/__init__.py | 29 ++ nodes/src/nodes/tool_steadwing/doc.md | 74 ++++ .../src/nodes/tool_steadwing/requirements.txt | 1 + nodes/src/nodes/tool_steadwing/services.json | 52 +++ nodes/src/nodes/tool_steadwing/steadwing.svg | 1 + nodes/test/test_tool_steadwing.py | 394 ++++++++++++++++++ 9 files changed, 837 insertions(+) create mode 100644 nodes/src/nodes/tool_steadwing/IGlobal.py create mode 100644 nodes/src/nodes/tool_steadwing/IInstance.py create mode 100644 nodes/src/nodes/tool_steadwing/__init__.py create mode 100644 nodes/src/nodes/tool_steadwing/doc.md create mode 100644 nodes/src/nodes/tool_steadwing/requirements.txt create mode 100644 nodes/src/nodes/tool_steadwing/services.json create mode 100644 nodes/src/nodes/tool_steadwing/steadwing.svg create mode 100644 nodes/test/test_tool_steadwing.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 876675b69..ae28f4067 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] — since 2026-06-08 +### Added + +#### New Nodes +- **Steadwing RCA** — agent tool exposing Steadwing's AI root-cause analysis (`run_rca`). Given an error or stack trace (and optional source files), it opens a Steadwing investigation that correlates logs, metrics, traces, and code across the stack, and returns the investigation URL. REST-backed (`POST /api/mcp/analyze`, `X-API-Key`) with a secure API key (env fallback `ROCKETRIDE_STEADWING_KEY`). Experimental V0. (#1248) + ## [3.3.0] - 2026-06-08 ### ⚠ Breaking Changes — Client SDKs (`rocketride` / `rocketride-python`) diff --git a/nodes/src/nodes/tool_steadwing/IGlobal.py b/nodes/src/nodes/tool_steadwing/IGlobal.py new file mode 100644 index 000000000..af53ab9fe --- /dev/null +++ b/nodes/src/nodes/tool_steadwing/IGlobal.py @@ -0,0 +1,74 @@ +# ============================================================================= +# RocketRide Engine +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +""" +Steadwing tool node - global (shared) state. + +Reads the Steadwing API key from the node config (or the ROCKETRIDE_STEADWING_KEY +env var). The root-cause-analysis tool itself lives on IInstance via @tool_function. +""" + +from __future__ import annotations + +import os + +from ai.common.config import Config +from rocketlib import IGlobalBase, OPEN_MODE, error, warning + +# Pipeline env vars must be ROCKETRIDE_-prefixed (only those are substituted, and +# the node-test framework maps ROCKETRIDE__ -> config). +STEADWING_API_KEY_ENV = 'ROCKETRIDE_STEADWING_KEY' + + +class IGlobal(IGlobalBase): + """Global state for tool_steadwing.""" + + apikey: str = '' + + def beginGlobal(self) -> None: + if self.IEndpoint.endpoint.openMode == OPEN_MODE.CONFIG: + return + + cfg = Config.getNodeConfig(self.glb.logicalType, self.glb.connConfig) + + apikey = str(cfg.get('apikey') or '').strip() or os.environ.get(STEADWING_API_KEY_ENV, '').strip() + + if not apikey: + error(f'tool_steadwing: apikey is required — set it in node config or the {STEADWING_API_KEY_ENV} env var') + raise ValueError('tool_steadwing: apikey is required') + + self.apikey = apikey + + def validateConfig(self) -> None: + try: + cfg = Config.getNodeConfig(self.glb.logicalType, self.glb.connConfig) + apikey = str(cfg.get('apikey') or '').strip() or os.environ.get(STEADWING_API_KEY_ENV, '').strip() + if not apikey: + warning('apikey is required') + except Exception as e: + warning(str(e)) + + def endGlobal(self) -> None: + self.apikey = '' diff --git a/nodes/src/nodes/tool_steadwing/IInstance.py b/nodes/src/nodes/tool_steadwing/IInstance.py new file mode 100644 index 000000000..196ed0dbc --- /dev/null +++ b/nodes/src/nodes/tool_steadwing/IInstance.py @@ -0,0 +1,207 @@ +# ============================================================================= +# RocketRide Engine +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +""" +Steadwing tool node instance. + +Exposes ``run_rca`` as a @tool_function: given an error / stack trace (and optional +source files), it calls Steadwing's root-cause-analysis API and returns the URL of +the Steadwing investigation where the cross-tool RCA runs. +""" + +from __future__ import annotations + +from typing import Any, Dict, List + +from rocketlib import IInstanceBase, tool_function, warning + +from ai.common.utils import normalize_tool_input, post_with_retry + +from .IGlobal import IGlobal + +# --------------------------------------------------------------------------- +# Steadwing API configuration +# --------------------------------------------------------------------------- + +STEADWING_API_BASE = 'https://api.steadwing.com' +STEADWING_ANALYZE_ENDPOINT = f'{STEADWING_API_BASE}/api/mcp/analyze' +STEADWING_REQUEST_TIMEOUT = 60 # seconds +MAX_FILES = 20 + + +class IInstance(IInstanceBase): + """Node instance exposing Steadwing root-cause analysis as an agent tool.""" + + IGlobal: IGlobal + + @tool_function( + input_schema={ + 'type': 'object', + 'required': ['error'], + 'properties': { + 'error': { + 'type': 'string', + 'description': ( + 'The complete error message, stack trace, or incident description to ' + 'analyze. Include as much context as possible — error type, message, ' + 'stack trace, line numbers, and what was happening when it occurred.' + ), + }, + 'files': { + 'type': 'array', + 'description': ( + 'Optional source files that give the analysis context (max 20). Start with ' + 'the file named in the stack trace, then its direct imports and any relevant ' + 'configuration.' + ), + 'items': { + 'type': 'object', + 'required': ['name', 'content'], + 'properties': { + 'name': { + 'type': 'string', + 'description': 'Relative path from the project root, e.g. "src/app.js".', + }, + 'content': { + 'type': 'string', + 'description': 'Complete, unmodified file contents.', + }, + }, + }, + }, + }, + }, + output_schema={ + 'type': 'object', + 'properties': { + 'success': {'type': 'boolean'}, + 'incident_url': { + 'type': 'string', + 'description': 'URL of the Steadwing investigation where the RCA runs.', + }, + 'message': {'type': 'string'}, + }, + }, + description=( + 'Run AI-powered root-cause analysis on a production error or incident using Steadwing. ' + 'Provide the full error message / stack trace as `error`, and the relevant source `files` ' + 'when you have them. Steadwing correlates logs, metrics, traces, and code across the stack ' + 'and returns the URL of an investigation that then runs in the background. Call this ONCE ' + 'per incident — the returned URL is the deliverable; do not call it again or wait for more.' + ), + ) + def run_rca(self, args): + """Open a Steadwing root-cause analysis for an error / incident.""" + args = normalize_tool_input(args, tool_name='steadwing') + + error_text = (args.get('error') or '').strip() + if not error_text: + raise ValueError('error is required and must be a non-empty string') + + payload: Dict[str, Any] = {'error_log': error_text} + files = _normalize_files(args.get('files')) + if files: + payload['files'] = files + + headers = { + 'accept': 'application/json', + 'content-type': 'application/json', + 'X-API-Key': self.IGlobal.apikey, + } + + resp = post_with_retry( + STEADWING_ANALYZE_ENDPOINT, headers=headers, json=payload, timeout=STEADWING_REQUEST_TIMEOUT + ) + try: + body = resp.json() + except ValueError as exc: + # Malformed / non-JSON body. Log the status only — never the body, + # which can echo the submitted error context — and re-raise. + status = getattr(resp, 'status_code', None) + warning(f'Steadwing API returned a non-JSON response body: status={status}') + raise RuntimeError('Steadwing returned a non-JSON response body') from exc + + if not isinstance(body, dict): + raise RuntimeError(f'Steadwing returned an unexpected payload type: {type(body).__name__}') + + api_error = body.get('error') + if isinstance(api_error, dict): + msg = api_error.get('message') or api_error.get('detail') or api_error.get('code') or 'unknown error' + raise RuntimeError(f'Steadwing API error: {msg}') + if api_error: + raise RuntimeError(f'Steadwing API error: {api_error}') + + return _shape_result(body) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _normalize_files(files: Any) -> List[Dict[str, str]]: + """Coerce the optional ``files`` argument into a clean ``[{name, content}]`` list (max 20).""" + if not files: + return [] + if not isinstance(files, list): + raise ValueError('files must be an array of {name, content} objects') + + out: List[Dict[str, str]] = [] + for item in files[:MAX_FILES]: + if not isinstance(item, dict): + continue + name = str(item.get('name') or '').strip() + content = item.get('content') + if not name or not isinstance(content, str): + continue + out.append({'name': name, 'content': content}) + return out + + +def _shape_result(body: Dict[str, Any]) -> Dict[str, Any]: + """Extract the Steadwing investigation URL from an ``analyze`` response. + + The endpoint returns the URL either at the root or nested under ``data`` + (``{"data": {"incident_url": ...}}``); accept either, plus a few key spellings. + """ + data = body.get('data') if isinstance(body.get('data'), dict) else {} + incident_url = ( + data.get('incident_url') + or data.get('incidentUrl') + or data.get('url') + or body.get('incident_url') + or body.get('incidentUrl') + or body.get('url') + or '' + ) + incident_url = str(incident_url or '').strip() + if not incident_url: + raise RuntimeError('Steadwing response did not include an investigation URL') + + return { + 'success': True, + 'incident_url': incident_url, + 'message': f'Root-cause analysis started. Track the investigation at {incident_url}', + } diff --git a/nodes/src/nodes/tool_steadwing/__init__.py b/nodes/src/nodes/tool_steadwing/__init__.py new file mode 100644 index 000000000..37de11f28 --- /dev/null +++ b/nodes/src/nodes/tool_steadwing/__init__.py @@ -0,0 +1,29 @@ +# ============================================================================= +# RocketRide Engine +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +from .IGlobal import IGlobal +from .IInstance import IInstance + +__all__ = ['IGlobal', 'IInstance'] diff --git a/nodes/src/nodes/tool_steadwing/doc.md b/nodes/src/nodes/tool_steadwing/doc.md new file mode 100644 index 000000000..ca5b70ae0 --- /dev/null +++ b/nodes/src/nodes/tool_steadwing/doc.md @@ -0,0 +1,74 @@ +# tool_steadwing + +Exposes [Steadwing](https://www.steadwing.com)'s AI root-cause analysis as an agent tool node. + +## What it does + +Agents invoke this node via the tool invoke channel. Given a production error or stack +trace (and, optionally, the relevant source files), `run_rca` opens a Steadwing +investigation that correlates logs, metrics, traces, and code across the stack, and +returns the investigation URL. + +Because `lanes` is empty (`{}`), this node has no pipeline input/output lanes — it is +consumed exclusively by agent runtimes through the `invoke` capability. + +`run_rca` is **asynchronous**: it returns the investigation URL immediately and the +analysis runs in the background on the Steadwing platform. Call it once per incident — +the URL is the deliverable. + +## Setup + +Set your Steadwing API key (from `app.steadwing.com/organization`) via the node config +field **API Key** or the environment variable: + +```bash +ROCKETRIDE_STEADWING_KEY=st_... +``` + +## Tool — `run_rca` + +| Parameter | Required | Description | +| --------- | -------- | ----------------------------------------------------------------------------------- | +| `error` | yes | The full error message, stack trace, or incident description to analyze. | +| `files` | no | Up to 20 source files (`{ name, content }`) for context — start with the file named in the stack trace. | + +Returns `{ success, incident_url, message }`. Backed by +`POST https://api.steadwing.com/api/mcp/analyze` (`X-API-Key`). + +## Config fields + +| Field | Default | Description | +| ------- | --------- | --------------------------------------------------------------------------- | +| API Key | *(empty)* | Steadwing API key. Env fallback `ROCKETRIDE_STEADWING_KEY`. Encrypted at rest. | + +## Status — V0 + +Intentional first cut: a minimal `run_rca` wrapper. Planned next (notably for power +users): return the finished analysis (not just the tracking URL); richer `files` +ergonomics; idempotency / guardrails so agent loops don't burn metered RCAs; a +Steadwing-incident → pipeline trigger node for autonomous on-call; power-user config +(analysis depth, integrations, output shaping). + +## Reference + + + + +| Property | Value | +| --- | --- | +| Class type | tool | +| Capabilities | invoke, experimental | +| Protocol | `tool_steadwing://` | + +**Profiles** + +| Profile | Title | Model | +| --- | --- | --- | +| `default` | Steadwing RCA | | + +**Configuration sections** + +| Section | Fields | +| --- | --- | +| Steadwing RCA | `type`, `tool_steadwing.apikey` | + diff --git a/nodes/src/nodes/tool_steadwing/requirements.txt b/nodes/src/nodes/tool_steadwing/requirements.txt new file mode 100644 index 000000000..f2293605c --- /dev/null +++ b/nodes/src/nodes/tool_steadwing/requirements.txt @@ -0,0 +1 @@ +requests diff --git a/nodes/src/nodes/tool_steadwing/services.json b/nodes/src/nodes/tool_steadwing/services.json new file mode 100644 index 000000000..3080b11fa --- /dev/null +++ b/nodes/src/nodes/tool_steadwing/services.json @@ -0,0 +1,52 @@ +{ + // + // Steadwing — AI root-cause-analysis tool node + // + // Exposes a single tool, run_rca: given an error / stack trace (and optional + // source files), it opens a Steadwing investigation that correlates logs, + // metrics, traces, and code across the stack, and returns the investigation URL. + // + "title": "Steadwing RCA", + "protocol": "tool_steadwing://", + "classType": ["tool"], + // + // "experimental" marks this node as not yet production-ready (V0). + // + "capabilities": ["invoke", "experimental"], + "register": "filter", + "node": "python", + "path": "nodes.tool_steadwing", + "prefix": "steadwing", + "icon": "steadwing.svg", + "description": ["Exposes Steadwing's AI root-cause analysis as an agent tool.", "Given an error or stack trace (and optional source files), opens a Steadwing investigation that correlates logs, metrics, traces, and code, and returns the investigation URL. Experimental V0: API and behavior may change."], + "tile": ["Tool: steadwing.run_rca"], + "lanes": {}, + "preconfig": { + "default": "default", + "profiles": { + "default": { + "title": "Steadwing RCA", + "apikey": "" + } + } + }, + "fields": { + "tool_steadwing.apikey": { + "type": "string", + "title": "API Key", + "description": "Steadwing API key (from app.steadwing.com/organization). Env fallback: ROCKETRIDE_STEADWING_KEY", + "default": "", + "secure": true, + "ui": { + "ui:widget": "ApiKeyWidget" + } + } + }, + "shape": [ + { + "section": "Pipe", + "title": "Steadwing RCA", + "properties": ["type", "tool_steadwing.apikey"] + } + ] +} diff --git a/nodes/src/nodes/tool_steadwing/steadwing.svg b/nodes/src/nodes/tool_steadwing/steadwing.svg new file mode 100644 index 000000000..c187da1af --- /dev/null +++ b/nodes/src/nodes/tool_steadwing/steadwing.svg @@ -0,0 +1 @@ + diff --git a/nodes/test/test_tool_steadwing.py b/nodes/test/test_tool_steadwing.py new file mode 100644 index 000000000..df609ec4f --- /dev/null +++ b/nodes/test/test_tool_steadwing.py @@ -0,0 +1,394 @@ +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# ============================================================================= + +"""Unit tests for tool_steadwing IInstance (no network). + +Covers the Steadwing analyze-response parsing (_shape_result), the optional files +normalization (_normalize_files), and the run_rca tool method against a mocked +post_with_retry — including success, nested/`data` URL shapes, API-error and +non-JSON bodies, and the no-network input-validation paths. +""" + +from __future__ import annotations + +import importlib.util +import sys +import types +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +# --------------------------------------------------------------------------- +# Stubs — installed before importing the module under test. IInstance imports +# `normalize_tool_input` and `post_with_retry` from `ai.common.utils` and +# `warning` from `rocketlib`; provide lightweight stand-ins so the module imports +# and runs without the engine runtime or network. +# --------------------------------------------------------------------------- + +_WARNING_CALLS: list[str] = [] + + +def _reset_warnings() -> None: + _WARNING_CALLS.clear() + + +def _stub_warning(msg: str, *_a: object, **_k: object) -> None: + _WARNING_CALLS.append(msg) + + +# Captures the args post_with_retry is called with, and what it should return/raise. +_POST = SimpleNamespace(calls=[], return_body=None, side_effect=None) + + +def _reset_post() -> None: + _POST.calls = [] + _POST.return_body = None + _POST.side_effect = None + + +def _stub_post_with_retry(url, *, headers=None, json=None, timeout=None, **_kw): + _POST.calls.append({'url': url, 'headers': headers, 'json': json, 'timeout': timeout}) + if _POST.side_effect is not None: + raise _POST.side_effect + resp = MagicMock() + resp.json.return_value = _POST.return_body + return resp + + +def _build_import_stubs() -> dict: + """Return {module_name: stub} for the deps needed only to import the module.""" + rocketlib = types.ModuleType('rocketlib') + rocketlib.IInstanceBase = object + rocketlib.IGlobalBase = object + rocketlib.tool_function = lambda *_a, **_k: lambda fn: fn + rocketlib.warning = _stub_warning + rocketlib.debug = lambda *_a, **_k: None + rocketlib.error = lambda *_a, **_k: None + rocketlib.OPEN_MODE = SimpleNamespace(CONFIG='config') + + requests = types.ModuleType('requests') + requests.exceptions = types.SimpleNamespace() + requests.exceptions.Timeout = TimeoutError + requests.exceptions.ConnectionError = ConnectionError + + class _RequestException(Exception): + pass + + class _InvalidJSONError(_RequestException, ValueError): + pass + + requests.exceptions.RequestException = _RequestException + requests.exceptions.InvalidJSONError = _InvalidJSONError + requests.RequestException = _RequestException + + ai_pkg = types.ModuleType('ai') + ai_pkg.__path__ = [] + ai_common = types.ModuleType('ai.common') + ai_common.__path__ = [] + ai_utils = types.ModuleType('ai.common.utils') + ai_utils.normalize_tool_input = lambda args, **_kw: args if isinstance(args, dict) else {} + ai_utils.post_with_retry = _stub_post_with_retry + ai_config = types.ModuleType('ai.common.config') + ai_config.Config = MagicMock() + + return { + 'rocketlib': rocketlib, + 'requests': requests, + 'ai': ai_pkg, + 'ai.common': ai_common, + 'ai.common.utils': ai_utils, + 'ai.common.config': ai_config, + } + + +# --------------------------------------------------------------------------- +# Load the module under test via importlib so we avoid the package __init__ chain. +# Inject stubs ONLY for modules not already present, import, then REMOVE exactly +# the stubs we added (install-then-pop) so nothing leaks into the shared pytest +# session under `builder nodes:test-full`. +# --------------------------------------------------------------------------- + +_NODES_ROOT = Path(__file__).resolve().parent.parent / 'src' / 'nodes' +_IINSTANCE_PATH = _NODES_ROOT / 'tool_steadwing' / 'IInstance.py' + + +def _load_iinstance(): + added: list[str] = [] + for name, stub in _build_import_stubs().items(): + if name not in sys.modules: + sys.modules[name] = stub + added.append(name) + + # Scaffold keys may overwrite a pre-existing sys.modules entry; capture the + # prior binding so the finally block can RESTORE it rather than unconditionally + # pop, keeping full-suite test ordering deterministic. + scaffold: list[str] = [] + _missing = object() + previous: dict[str, object] = {} + + pkg_name = 'tool_steadwing' + pkg_stub = types.ModuleType(pkg_name) + pkg_stub.__path__ = [str(_NODES_ROOT / 'tool_steadwing')] + pkg_stub.__package__ = pkg_name + previous[pkg_name] = sys.modules.get(pkg_name, _missing) + sys.modules[pkg_name] = pkg_stub + scaffold.append(pkg_name) + + iglobal_key = f'{pkg_name}.IGlobal' + iglobal_mod = types.ModuleType(iglobal_key) + iglobal_mod.IGlobal = type('IGlobal', (), {}) + previous[iglobal_key] = sys.modules.get(iglobal_key, _missing) + sys.modules[iglobal_key] = iglobal_mod + scaffold.append(iglobal_key) + pkg_stub.IGlobal = iglobal_mod + + try: + spec = importlib.util.spec_from_file_location( + f'{pkg_name}.IInstance', + _IINSTANCE_PATH, + submodule_search_locations=[], + ) + assert spec is not None and spec.loader is not None + mod = importlib.util.module_from_spec(spec) + mod.__package__ = pkg_name + iinstance_key = f'{pkg_name}.IInstance' + previous[iinstance_key] = sys.modules.get(iinstance_key, _missing) + sys.modules[iinstance_key] = mod + scaffold.append(iinstance_key) + spec.loader.exec_module(mod) + finally: + # Stubs were only inserted when absent → pop them. + for name in added: + sys.modules.pop(name, None) + # Scaffold keys may have shadowed a real module → restore the prior binding. + for name in scaffold: + prior = previous.get(name, _missing) + if prior is _missing: + sys.modules.pop(name, None) + else: + sys.modules[name] = prior + + return mod + + +_mod = _load_iinstance() +# `from rocketlib import warning` binds at import time; point it at our capture. +_mod.warning = _stub_warning + +_shape_result = _mod._shape_result +_normalize_files = _mod._normalize_files +IInstance = _mod.IInstance +ANALYZE_URL = _mod.STEADWING_ANALYZE_ENDPOINT + + +def _make_instance(apikey='test-key') -> IInstance: + inst = IInstance.__new__(IInstance) + inst.IGlobal = SimpleNamespace(apikey=apikey) + return inst + + +# ============================================================================= +# (a) _shape_result — analyze response parsing +# ============================================================================= + + +class TestShapeResult: + def test_root_incident_url(self): + out = _shape_result({'incident_url': 'https://app.steadwing.com/incident/abc'}) + assert out['success'] is True + assert out['incident_url'] == 'https://app.steadwing.com/incident/abc' + assert 'abc' in out['message'] + + def test_nested_data_incident_url(self): + out = _shape_result({'data': {'incident_url': 'https://app.steadwing.com/incident/xyz'}}) + assert out['incident_url'] == 'https://app.steadwing.com/incident/xyz' + + def test_camelcase_incidenturl(self): + out = _shape_result({'data': {'incidentUrl': 'https://app.steadwing.com/incident/cc'}}) + assert out['incident_url'] == 'https://app.steadwing.com/incident/cc' + + def test_generic_url_key(self): + out = _shape_result({'url': 'https://app.steadwing.com/incident/uu'}) + assert out['incident_url'] == 'https://app.steadwing.com/incident/uu' + + def test_nested_data_preferred_over_root(self): + out = _shape_result( + {'data': {'incident_url': 'https://app.steadwing.com/incident/nested'}, 'incident_url': 'root'} + ) + assert out['incident_url'] == 'https://app.steadwing.com/incident/nested' + + def test_missing_url_raises(self): + with pytest.raises(RuntimeError, match='investigation URL'): + _shape_result({'data': {}, 'status': 'ok'}) + + def test_blank_url_raises(self): + with pytest.raises(RuntimeError, match='investigation URL'): + _shape_result({'incident_url': ' '}) + + def test_non_dict_data_falls_back_to_root(self): + out = _shape_result({'data': 'oops', 'incident_url': 'https://app.steadwing.com/incident/r'}) + assert out['incident_url'] == 'https://app.steadwing.com/incident/r' + + +# ============================================================================= +# (b) _normalize_files +# ============================================================================= + + +class TestNormalizeFiles: + def test_none_returns_empty(self): + assert _normalize_files(None) == [] + + def test_empty_list_returns_empty(self): + assert _normalize_files([]) == [] + + def test_non_list_raises(self): + with pytest.raises(ValueError, match='files'): + _normalize_files({'name': 'a', 'content': 'b'}) + + def test_valid_files_passed_through(self): + files = [{'name': 'src/app.js', 'content': 'x'}, {'name': 'b.py', 'content': 'y'}] + assert _normalize_files(files) == files + + def test_skips_non_dict_and_invalid_entries(self): + files = [ + 'oops', + None, + {'name': '', 'content': 'x'}, # blank name + {'name': 'no-content'}, # missing content + {'name': 'ok.js', 'content': 'good'}, + ] + assert _normalize_files(files) == [{'name': 'ok.js', 'content': 'good'}] + + def test_caps_at_twenty(self): + files = [{'name': f'f{i}.js', 'content': 'x'} for i in range(40)] + out = _normalize_files(files) + assert len(out) == 20 + + def test_name_coerced_and_stripped(self): + out = _normalize_files([{'name': ' a.js ', 'content': 'x'}]) + assert out == [{'name': 'a.js', 'content': 'x'}] + + def test_non_string_content_skipped(self): + assert _normalize_files([{'name': 'a.js', 'content': 123}]) == [] + + +# ============================================================================= +# (c) run_rca +# ============================================================================= + + +class TestRunRca: + def setup_method(self): + _reset_post() + _reset_warnings() + + def test_missing_error_raises_no_network(self): + inst = _make_instance() + with pytest.raises(ValueError, match='error'): + inst.run_rca({}) + assert _POST.calls == [] + + def test_blank_error_raises_no_network(self): + inst = _make_instance() + with pytest.raises(ValueError, match='error'): + inst.run_rca({'error': ' '}) + assert _POST.calls == [] + + def test_success_returns_incident_url(self): + inst = _make_instance() + _POST.return_body = {'data': {'incident_url': 'https://app.steadwing.com/incident/ok'}} + out = inst.run_rca({'error': 'TypeError: boom at app.js:1'}) + assert out['success'] is True + assert out['incident_url'] == 'https://app.steadwing.com/incident/ok' + + def test_posts_error_log_to_analyze_endpoint(self): + inst = _make_instance() + _POST.return_body = {'incident_url': 'https://app.steadwing.com/incident/ok'} + inst.run_rca({'error': 'boom'}) + call = _POST.calls[0] + assert call['url'] == ANALYZE_URL + assert call['url'].endswith('/api/mcp/analyze') + assert call['json'] == {'error_log': 'boom'} + assert call['timeout'] == 60 + assert call['headers']['X-API-Key'] == 'test-key' + assert call['headers']['content-type'] == 'application/json' + + def test_files_included_when_provided(self): + inst = _make_instance() + _POST.return_body = {'incident_url': 'https://app.steadwing.com/incident/ok'} + inst.run_rca({'error': 'boom', 'files': [{'name': 'app.js', 'content': 'code'}]}) + assert _POST.calls[0]['json']['files'] == [{'name': 'app.js', 'content': 'code'}] + + def test_files_omitted_when_empty(self): + inst = _make_instance() + _POST.return_body = {'incident_url': 'https://app.steadwing.com/incident/ok'} + inst.run_rca({'error': 'boom', 'files': []}) + assert 'files' not in _POST.calls[0]['json'] + + def test_api_error_dict_raises(self): + inst = _make_instance() + _POST.return_body = {'error': {'message': 'monthly RCA quota exceeded', 'code': 'quota'}} + with pytest.raises(RuntimeError, match='quota exceeded'): + inst.run_rca({'error': 'boom'}) + + def test_api_error_string_raises(self): + inst = _make_instance() + _POST.return_body = {'error': 'unauthorized'} + with pytest.raises(RuntimeError, match='unauthorized'): + inst.run_rca({'error': 'boom'}) + + def test_non_dict_payload_raises(self): + inst = _make_instance() + _POST.return_body = ['not', 'a', 'dict'] + with pytest.raises(RuntimeError, match='unexpected payload type'): + inst.run_rca({'error': 'boom'}) + + def test_missing_url_raises(self): + inst = _make_instance() + _POST.return_body = {'status': 'accepted'} + with pytest.raises(RuntimeError, match='investigation URL'): + inst.run_rca({'error': 'boom'}) + + def test_request_exception_propagates(self): + # post_with_retry already retries; a final failure must propagate so the + # framework records a proper tool failure (no error-dict swallowing). + inst = _make_instance() + _POST.side_effect = RuntimeError('boom after retries') + with pytest.raises(RuntimeError, match='boom after retries'): + inst.run_rca({'error': 'boom'}) + + def test_non_json_body_raises_and_logs_status_only(self): + inst = _make_instance() + + def _raising_post(url, *, headers=None, json=None, timeout=None, **_kw): + resp = MagicMock() + resp.status_code = 502 + resp.json.side_effect = ValueError('bad') + return resp + + _mod.post_with_retry = _raising_post + try: + with pytest.raises(RuntimeError, match='non-JSON'): + inst.run_rca({'error': 'super-secret stack trace'}) + finally: + _mod.post_with_retry = _stub_post_with_retry + # Warning logs status only — never the submitted error context or the key. + assert any('status=502' in w for w in _WARNING_CALLS) + assert all('super-secret' not in w for w in _WARNING_CALLS) + assert all('test-key' not in w for w in _WARNING_CALLS) + + def test_api_key_never_logged_on_error(self): + inst = _make_instance(apikey='st_supersecret') + _POST.return_body = {'error': {'message': 'bad request'}} + with pytest.raises(RuntimeError): + inst.run_rca({'error': 'boom'}) + assert all('st_supersecret' not in w for w in _WARNING_CALLS) + + +if __name__ == '__main__': + sys.exit(pytest.main([__file__, '-v']))