autobots-devtools-shared-lib 0.9.0__tar.gz → 0.10.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/PKG-INFO +1 -1
  2. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/pyproject.toml +1 -1
  3. autobots_devtools_shared_lib-0.10.0/src/autobots_devtools_shared_lib/common/servers/noderedmanagerserver/__init__.py +23 -0
  4. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/servers/noderedmanagerserver/__main__.py +10 -1
  5. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/servers/noderedmanagerserver/app.py +136 -67
  6. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/servers/noderedmanagerserver/config.py +24 -6
  7. autobots_devtools_shared_lib-0.10.0/src/autobots_devtools_shared_lib/common/servers/noderedmanagerserver/exceptions.py +66 -0
  8. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/servers/noderedmanagerserver/models.py +11 -0
  9. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/tools/noderedmanager_client_tools.py +9 -3
  10. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/utils/noderedmanager_client_utils.py +38 -9
  11. autobots_devtools_shared_lib-0.9.0/src/autobots_devtools_shared_lib/common/servers/noderedmanagerserver/__init__.py +0 -5
  12. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/README.md +0 -0
  13. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/__init__.py +0 -0
  14. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/__init__.py +0 -0
  15. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/config/__init__.py +0 -0
  16. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/config/jenkins_config.py +0 -0
  17. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/config/jenkins_constants.py +0 -0
  18. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/config/jenkins_loader.py +0 -0
  19. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/observability/__init__.py +0 -0
  20. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/observability/logging_utils.py +0 -0
  21. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/observability/otel_fastapi.py +0 -0
  22. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/observability/trace_metadata.py +0 -0
  23. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/observability/trace_propagation.py +0 -0
  24. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/observability/tracing.py +0 -0
  25. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/servers/__init__.py +0 -0
  26. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/servers/fileserver/README.md +0 -0
  27. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/servers/fileserver/__init__.py +0 -0
  28. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/servers/fileserver/app.py +0 -0
  29. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/servers/fileserver/config.py +0 -0
  30. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/servers/fileserver/models.py +0 -0
  31. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/servers/noderedmanagerserver/README.md +0 -0
  32. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/services/__init__.py +0 -0
  33. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/services/context/README.md +0 -0
  34. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/services/context/__init__.py +0 -0
  35. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/services/context/cache_backed.py +0 -0
  36. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/services/context/db_repository.py +0 -0
  37. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/services/context/factory.py +0 -0
  38. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/services/context/in_memory.py +0 -0
  39. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/services/context/redis_store.py +0 -0
  40. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/services/context/store.py +0 -0
  41. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/tools/__init__.py +0 -0
  42. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/tools/context_tools.py +0 -0
  43. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/tools/format_tools.py +0 -0
  44. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/tools/fserver_client_tools.py +0 -0
  45. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/tools/jenkins_builtin_tools.py +0 -0
  46. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/tools/jenkins_pipeline_tools.py +0 -0
  47. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/utils/__init__.py +0 -0
  48. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/utils/context_utils.py +0 -0
  49. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/utils/format_utils.py +0 -0
  50. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/utils/fserver_client_utils.py +0 -0
  51. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/utils/jenkins_builtin_utils.py +0 -0
  52. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/utils/jenkins_http_utils.py +0 -0
  53. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/utils/jenkins_pipeline_utils.py +0 -0
  54. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/dynagent/__init__.py +0 -0
  55. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/dynagent/agents/__init__.py +0 -0
  56. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/dynagent/agents/agent_config_utils.py +0 -0
  57. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/dynagent/agents/agent_meta.py +0 -0
  58. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/dynagent/agents/base_agent.py +0 -0
  59. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/dynagent/agents/batch.py +0 -0
  60. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/dynagent/agents/invocation_utils.py +0 -0
  61. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/dynagent/agents/middleware.py +0 -0
  62. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/dynagent/config/__init__.py +0 -0
  63. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/dynagent/config/dynagent_settings.py +0 -0
  64. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/dynagent/llm/__init__.py +0 -0
  65. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/dynagent/llm/llm.py +0 -0
  66. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/dynagent/models/__init__.py +0 -0
  67. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/dynagent/models/state.py +0 -0
  68. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/dynagent/services/__init__.py +0 -0
  69. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/dynagent/services/structured_converter.py +0 -0
  70. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/dynagent/tools/__init__.py +0 -0
  71. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/dynagent/tools/state_tools.py +0 -0
  72. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/dynagent/tools/tool_registry.py +0 -0
  73. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/dynagent/ui/__init__.py +0 -0
  74. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/dynagent/ui/default_ui.py +0 -0
  75. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/dynagent/ui/ui_utils.py +0 -0
  76. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/dynagent/utils/__init__.py +0 -0
  77. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/dynagent/utils/schema_directive_resolver.py +0 -0
  78. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/eval/__init__.py +0 -0
  79. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/eval/assertions/__init__.py +0 -0
  80. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/eval/assertions/deterministic.py +0 -0
  81. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/eval/assertions/golden.py +0 -0
  82. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/eval/assertions/llm_judge.py +0 -0
  83. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/eval/assertions/registry.py +0 -0
  84. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/eval/assertions/written_file.py +0 -0
  85. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/eval/core/__init__.py +0 -0
  86. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/eval/core/cost_tracker.py +0 -0
  87. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/eval/core/loader.py +0 -0
  88. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/eval/core/runner.py +0 -0
  89. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/eval/core/workspace.py +0 -0
  90. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/eval/models/__init__.py +0 -0
  91. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/eval/models/eval_case.py +0 -0
  92. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/eval/models/result.py +0 -0
  93. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/eval/pytest_plugin/__init__.py +0 -0
  94. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/eval/pytest_plugin/fixtures.py +0 -0
  95. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/eval/pytest_plugin/plugin.py +0 -0
  96. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/eval/pytest_plugin/reporting.py +0 -0
  97. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/eval/scoring/__init__.py +0 -0
  98. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/eval/scoring/langfuse_scorer.py +0 -0
  99. {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: autobots-devtools-shared-lib
3
- Version: 0.9.0
3
+ Version: 0.10.0
4
4
  Summary: Shared library functions to be used for all autobots projects
5
5
  License: MIT
6
6
  Author: Pralhad
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "autobots-devtools-shared-lib"
3
- version = "0.9.0"
3
+ version = "0.10.0"
4
4
  description = "Shared library functions to be used for all autobots projects"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -0,0 +1,23 @@
1
+ """Node-RED instance manager server package."""
2
+
3
+ from .app import app # re-export for convenience
4
+ from .exceptions import (
5
+ FlowsFileNotFoundError,
6
+ InstanceNotFoundError,
7
+ InvalidWorkspacePathError,
8
+ NoAvailablePortError,
9
+ NodeRedLaunchError,
10
+ NodeRedManagerError,
11
+ UnknownEnvironmentError,
12
+ )
13
+
14
+ __all__ = [
15
+ "FlowsFileNotFoundError",
16
+ "InstanceNotFoundError",
17
+ "InvalidWorkspacePathError",
18
+ "NoAvailablePortError",
19
+ "NodeRedLaunchError",
20
+ "NodeRedManagerError",
21
+ "UnknownEnvironmentError",
22
+ "app",
23
+ ]
@@ -1,16 +1,25 @@
1
1
  """Entry point: reads host/port from node-red-config.yaml and starts the server."""
2
2
 
3
+ import asyncio
4
+ import sys
5
+
3
6
  import uvicorn
4
7
 
5
8
  from autobots_devtools_shared_lib.common.servers.noderedmanagerserver.config import (
6
9
  NodeRedManagerServerConfig,
7
10
  )
8
11
 
12
+ # asyncio.create_subprocess_exec requires ProactorEventLoop on Windows.
13
+ # Python 3.12+ defaults to it, but set explicitly to guard against uvicorn
14
+ # or dependency changes that could reset the event loop policy.
15
+ if sys.platform == "win32":
16
+ asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
17
+
9
18
  if __name__ == "__main__":
10
19
  cfg = NodeRedManagerServerConfig()
11
20
  uvicorn.run(
12
21
  "autobots_devtools_shared_lib.common.servers.noderedmanagerserver.app:app",
13
22
  host=cfg.node_red_manager_server_host,
14
23
  port=cfg.node_red_manager_server_port,
15
- reload=True,
24
+ reload=False,
16
25
  )
@@ -16,18 +16,31 @@ Or: make node-red-server (from autobots-devtools-shared-lib)
16
16
  import asyncio
17
17
  import contextlib
18
18
  import os
19
+ import subprocess
20
+ import sys
19
21
  from contextlib import asynccontextmanager
20
- from datetime import UTC, datetime
22
+ from datetime import UTC, datetime, timedelta
21
23
  from pathlib import Path
22
24
  from typing import Any
23
25
 
24
- from fastapi import FastAPI, HTTPException, status
26
+ from fastapi import FastAPI
27
+ from fastapi.requests import Request
28
+ from fastapi.responses import JSONResponse
25
29
 
26
30
  from autobots_devtools_shared_lib.common.observability.logging_utils import get_logger
27
31
  from autobots_devtools_shared_lib.common.servers.noderedmanagerserver.config import (
28
32
  NodeRedManagerServerConfig,
29
33
  TemplateConfig,
30
34
  )
35
+ from autobots_devtools_shared_lib.common.servers.noderedmanagerserver.exceptions import (
36
+ FlowsFileNotFoundError,
37
+ InstanceNotFoundError,
38
+ InvalidWorkspacePathError,
39
+ NoAvailablePortError,
40
+ NodeRedLaunchError,
41
+ NodeRedManagerError,
42
+ UnknownEnvironmentError,
43
+ )
31
44
  from autobots_devtools_shared_lib.common.servers.noderedmanagerserver.models import (
32
45
  CreateInstanceRequest,
33
46
  CreateInstanceResponse,
@@ -39,8 +52,8 @@ from autobots_devtools_shared_lib.common.servers.noderedmanagerserver.models imp
39
52
  logger = get_logger(__name__)
40
53
  config = NodeRedManagerServerConfig()
41
54
 
42
- # In-memory registry: instance_id -> (InstanceInfo, subprocess handle)
43
- _registry: dict[str, tuple[InstanceInfo, asyncio.subprocess.Process]] = {}
55
+ # In-memory registry: instance_id -> (InstanceInfo, subprocess handle, TTL task)
56
+ _registry: dict[str, tuple[InstanceInfo, asyncio.subprocess.Process, asyncio.Task[None]]] = {}
44
57
 
45
58
 
46
59
  # ---------------------------------------------------------------------------
@@ -62,13 +75,13 @@ async def _is_port_available(port: int) -> bool:
62
75
 
63
76
  async def _find_available_port(template: TemplateConfig) -> int:
64
77
  """Scan sequentially within the template's port range; skip ports used by tracked instances."""
65
- used_ports = {info.port for info, _ in _registry.values()}
78
+ used_ports = {info.port for info, _, _ in _registry.values()}
66
79
  for port in range(template.min_port, template.max_port + 1):
67
80
  if port in used_ports:
68
81
  continue
69
82
  if await _is_port_available(port):
70
83
  return port
71
- raise RuntimeError(
84
+ raise NoAvailablePortError(
72
85
  f"No available ports in range [{template.min_port}, {template.max_port}] "
73
86
  f"for environment '{template.name}'. All ports are occupied."
74
87
  )
@@ -86,26 +99,54 @@ async def _launch_node_red(
86
99
 
87
100
  INSTANCE_ID is passed as an env var so the environment's settings.js can set
88
101
  httpAdminRoot and httpNodeRoot to '/<instance_id>' for URL isolation.
102
+
103
+ On Windows, node-red is installed as a .cmd batch file and cannot be executed
104
+ directly by create_subprocess_exec — it must be routed through cmd.exe.
105
+ CREATE_NEW_PROCESS_GROUP assigns a new process group so taskkill /T can target
106
+ the entire tree (cmd.exe + node-red child) during cleanup.
89
107
  """
90
108
  env = {**os.environ, "FLOW": flows_json_path, "INSTANCE_ID": instance_id}
109
+ node_red_args = ["-u", str(template.path), "--port", str(port)]
110
+ if sys.platform == "win32":
111
+ cmd = ["cmd", "/c", config.node_red_executable, *node_red_args]
112
+ extra_kwargs: dict = {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP}
113
+ else:
114
+ cmd = [config.node_red_executable, *node_red_args]
115
+ extra_kwargs = {}
91
116
  return await asyncio.create_subprocess_exec(
92
- config.node_red_executable,
93
- "-u",
94
- str(template.path),
95
- "--port",
96
- str(port),
117
+ *cmd,
97
118
  env=env,
98
119
  stdout=asyncio.subprocess.PIPE,
99
120
  stderr=asyncio.subprocess.PIPE,
121
+ **extra_kwargs,
100
122
  )
101
123
 
102
124
 
103
125
  async def _kill_instance(instance_id: str, process: asyncio.subprocess.Process) -> None:
104
- """SIGTERM the process; escalate to SIGKILL after 5 seconds."""
126
+ """Terminate the process and its children.
127
+
128
+ On Unix: SIGTERM with a 5s grace period, then SIGKILL.
129
+ On Windows: taskkill /F /T kills the entire process tree (cmd.exe + node-red child)
130
+ because process.terminate() only kills cmd.exe, leaving node-red orphaned.
131
+ """
105
132
  try:
106
- process.terminate() # SIGTERM on Unix
107
- await asyncio.wait_for(process.wait(), timeout=5.0)
108
- logger.info("Instance %s terminated gracefully", instance_id)
133
+ if sys.platform == "win32":
134
+ killer = await asyncio.create_subprocess_exec(
135
+ "taskkill",
136
+ "/F",
137
+ "/T",
138
+ "/PID",
139
+ str(process.pid),
140
+ stdout=asyncio.subprocess.DEVNULL,
141
+ stderr=asyncio.subprocess.DEVNULL,
142
+ )
143
+ await killer.wait()
144
+ await process.wait()
145
+ logger.info("Instance %s terminated (Windows taskkill)", instance_id)
146
+ else:
147
+ process.terminate() # SIGTERM on Unix
148
+ await asyncio.wait_for(process.wait(), timeout=5.0)
149
+ logger.info("Instance %s terminated gracefully", instance_id)
109
150
  except ProcessLookupError:
110
151
  logger.info("Instance %s process already gone (pid=%s)", instance_id, process.pid)
111
152
  except TimeoutError:
@@ -115,6 +156,20 @@ async def _kill_instance(instance_id: str, process: asyncio.subprocess.Process)
115
156
  await process.wait()
116
157
 
117
158
 
159
+ async def _ttl_kill(instance_id: str, ttl_seconds: int) -> None:
160
+ """Background task: auto-kill an instance after its TTL expires."""
161
+ try:
162
+ await asyncio.sleep(ttl_seconds)
163
+ except asyncio.CancelledError:
164
+ return # killed manually before TTL; nothing to do
165
+ entry = _registry.pop(instance_id, None)
166
+ if entry is None:
167
+ return # already removed (e.g. manual kill lost the race)
168
+ _, process, _ = entry
169
+ logger.info("TTL expired for instance %s, killing", instance_id)
170
+ await _kill_instance(instance_id, process)
171
+
172
+
118
173
  # ---------------------------------------------------------------------------
119
174
  # Lifespan
120
175
  # ---------------------------------------------------------------------------
@@ -139,7 +194,9 @@ async def lifespan(_app: FastAPI):
139
194
  yield
140
195
 
141
196
  logger.info("Node-RED server shutting down, terminating %d instance(s)", len(_registry))
142
- tasks = [_kill_instance(iid, proc) for iid, (_, proc) in _registry.items()]
197
+ for _, _, ttl_task in _registry.values():
198
+ ttl_task.cancel()
199
+ tasks = [_kill_instance(iid, proc) for iid, (_, proc, _) in _registry.items()]
143
200
  if tasks:
144
201
  await asyncio.gather(*tasks, return_exceptions=True)
145
202
  _registry.clear()
@@ -163,6 +220,37 @@ app = FastAPI(
163
220
  )
164
221
 
165
222
 
223
+ @app.exception_handler(NodeRedManagerError)
224
+ async def _node_red_manager_error_handler(
225
+ _request: Request, exc: NodeRedManagerError
226
+ ) -> JSONResponse:
227
+ """Convert domain exceptions to HTTP JSON responses."""
228
+ return JSONResponse(
229
+ status_code=exc.status_code,
230
+ content={"detail": exc.detail, "error_code": exc.ERROR_CODE},
231
+ )
232
+
233
+
234
+ # ---------------------------------------------------------------------------
235
+ # Helpers
236
+ # ---------------------------------------------------------------------------
237
+
238
+
239
+ def _resolve_instance_id(workspace_context: dict, environment_name: str) -> str:
240
+ """Validate workspace_base_path and return the scoped instance ID.
241
+
242
+ Raises InvalidWorkspacePathError if the path is missing or contains '..'.
243
+ """
244
+ workspace_base_path = (workspace_context.get("workspace_base_path") or "").strip()
245
+ if not workspace_base_path:
246
+ raise InvalidWorkspacePathError(
247
+ "workspace_context.workspace_base_path is required and cannot be empty."
248
+ )
249
+ if ".." in workspace_base_path:
250
+ raise InvalidWorkspacePathError("workspace_base_path cannot contain '..'")
251
+ return f"{environment_name}/{workspace_base_path}"
252
+
253
+
166
254
  # ---------------------------------------------------------------------------
167
255
  # Endpoints
168
256
  # ---------------------------------------------------------------------------
@@ -206,8 +294,10 @@ def list_instances() -> dict[str, Any]:
206
294
  "environment_name": info.environment_name,
207
295
  "url": info.url,
208
296
  "pid": info.pid,
297
+ "created_at": info.created_at.isoformat(),
298
+ "expires_at": info.expires_at.isoformat(),
209
299
  }
210
- for info, _ in _registry.values()
300
+ for info, _, _ in _registry.values()
211
301
  ]
212
302
  return {"instances": instances, "count": len(instances)}
213
303
 
@@ -227,66 +317,48 @@ async def create_instance(body: CreateInstanceRequest) -> CreateInstanceResponse
227
317
  body.workspace_context,
228
318
  )
229
319
 
230
- # 1. Extract and validate workspace_base_path
231
- workspace_base_path: str = (body.workspace_context.get("workspace_base_path") or "").strip()
232
- if not workspace_base_path:
233
- raise HTTPException(
234
- status_code=status.HTTP_400_BAD_REQUEST,
235
- detail="workspace_context.workspace_base_path is required and cannot be empty.",
236
- )
237
- if ".." in workspace_base_path:
238
- raise HTTPException(
239
- status_code=status.HTTP_400_BAD_REQUEST,
240
- detail="workspace_base_path cannot contain '..'",
241
- )
242
- # Instance ID scoped per environment so the same workspace can run multiple environments
243
- instance_id = f"{body.environment_name}/{workspace_base_path}"
320
+ # 1. Validate workspace path and derive scoped instance ID
321
+ instance_id = _resolve_instance_id(body.workspace_context, body.environment_name)
244
322
 
245
323
  # 2. Return existing instance if one is already running for this workspace
246
324
  if instance_id in _registry:
247
- existing_info, _ = _registry[instance_id]
325
+ existing_info, _, _ = _registry[instance_id]
248
326
  logger.info(
249
327
  "create-instance reusing existing id=%s url=%s", existing_info.id, existing_info.url
250
328
  )
251
- return CreateInstanceResponse(id=existing_info.id, url=existing_info.url)
329
+ return CreateInstanceResponse(
330
+ id=existing_info.id, url=existing_info.url, expires_at=existing_info.expires_at
331
+ )
252
332
 
253
333
  # 3. Validate environment name
254
334
  environment = config.environments.get(body.environment_name)
255
335
  if environment is None:
256
336
  logger.warning("create-instance unknown environment=%s", body.environment_name)
257
- raise HTTPException(
258
- status_code=status.HTTP_400_BAD_REQUEST,
259
- detail=(
260
- f"Unknown environment '{body.environment_name}'. "
261
- f"Available: {list(config.environments.keys())}"
262
- ),
337
+ raise UnknownEnvironmentError(
338
+ f"Unknown environment '{body.environment_name}'. "
339
+ f"Available: {list(config.environments.keys())}"
263
340
  )
264
341
 
265
342
  # 4. Resolve full flows path: base_path / workspace_base_path / flows_json_path
343
+ workspace_base_path = instance_id.split("/", 1)[1]
266
344
  flows_path = Path(config.base_path) / workspace_base_path / body.flows_json_path
267
345
  if not flows_path.exists():
268
- raise HTTPException(
269
- status_code=status.HTTP_400_BAD_REQUEST,
270
- detail=f"flows.json not found at resolved path: {flows_path}",
271
- )
346
+ raise FlowsFileNotFoundError(f"flows.json not found at resolved path: {flows_path}")
272
347
 
273
- # 5. Find next available port within this environment's port range
274
- try:
275
- port = await _find_available_port(environment)
276
- except RuntimeError as e:
277
- raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(e)) from e
348
+ # 5. Find next available port within this environment's port range (raises NoAvailablePortError)
349
+ port = await _find_available_port(environment)
278
350
 
279
351
  # 6. Launch subprocess — INSTANCE_ID env var picked up by the environment's settings.js
280
352
  try:
281
353
  process = await _launch_node_red(environment, str(flows_path), port, instance_id)
282
354
  except Exception as e:
283
355
  logger.exception("create-instance failed to launch node-red")
284
- raise HTTPException(
285
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
286
- detail=f"Failed to launch Node-RED: {e!s}",
287
- ) from e
356
+ raise NodeRedLaunchError(f"Failed to launch Node-RED: {e!s}") from e
288
357
 
289
358
  # 7. Register and return
359
+ ttl = body.ttl_seconds if body.ttl_seconds is not None else config.instance_ttl_seconds
360
+ created_at = datetime.now(UTC)
361
+ expires_at = created_at + timedelta(seconds=ttl)
290
362
  url = f"http://{config.node_red_manager_server_host}:{port}/{instance_id}"
291
363
  info = InstanceInfo(
292
364
  id=instance_id,
@@ -294,32 +366,29 @@ async def create_instance(body: CreateInstanceRequest) -> CreateInstanceResponse
294
366
  environment_name=body.environment_name,
295
367
  url=url,
296
368
  pid=process.pid or 0,
369
+ created_at=created_at,
370
+ expires_at=expires_at,
297
371
  )
298
- _registry[instance_id] = (info, process)
299
- logger.info("create-instance success id=%s url=%s pid=%s", instance_id, url, process.pid)
300
- return CreateInstanceResponse(id=instance_id, url=url)
372
+ ttl_task = asyncio.create_task(_ttl_kill(instance_id, ttl))
373
+ _registry[instance_id] = (info, process, ttl_task)
374
+ logger.info(
375
+ "create-instance success id=%s url=%s pid=%s ttl=%ss", instance_id, url, process.pid, ttl
376
+ )
377
+ return CreateInstanceResponse(id=instance_id, url=url, expires_at=expires_at)
301
378
 
302
379
 
303
380
  @app.post("/kill-instance")
304
381
  async def kill_instance(body: KillInstanceRequest) -> KillInstanceResponse:
305
382
  """Kill a running Node-RED instance by workspace_base_path."""
306
- workspace_base_path: str = (body.workspace_context.get("workspace_base_path") or "").strip()
307
- if not workspace_base_path:
308
- raise HTTPException(
309
- status_code=status.HTTP_400_BAD_REQUEST,
310
- detail="workspace_context.workspace_base_path is required and cannot be empty.",
311
- )
312
- instance_id = f"{body.environment_name}/{workspace_base_path}"
383
+ instance_id = _resolve_instance_id(body.workspace_context, body.environment_name)
313
384
  logger.info("kill-instance called id=%s", instance_id)
314
385
 
315
386
  entry = _registry.get(instance_id)
316
387
  if entry is None:
317
- raise HTTPException(
318
- status_code=status.HTTP_404_NOT_FOUND,
319
- detail=f"Instance '{instance_id}' not found",
320
- )
388
+ raise InstanceNotFoundError(f"Instance '{instance_id}' not found")
321
389
 
322
- _, process = entry
390
+ _, process, ttl_task = entry
391
+ ttl_task.cancel()
323
392
  await _kill_instance(instance_id, process)
324
393
  del _registry[instance_id]
325
394
 
@@ -22,10 +22,10 @@ class TemplateConfig:
22
22
 
23
23
  def _load_yaml_config(
24
24
  config_file: Path,
25
- ) -> tuple[str, str, str, int, dict[str, TemplateConfig]]:
25
+ ) -> tuple[str, str, str, int, int, dict[str, TemplateConfig]]:
26
26
  """
27
27
  Load node-red-config.yaml and return
28
- (node_red_executable, base_path, manager_host, manager_port, environments_by_name).
28
+ (node_red_executable, base_path, manager_host, manager_port, instance_ttl_seconds, environments_by_name).
29
29
 
30
30
  Expected YAML structure::
31
31
 
@@ -38,6 +38,9 @@ def _load_yaml_config(
38
38
  node_red_manager_server_host: 0.0.0.0 # optional, defaults to "0.0.0.0"
39
39
  node_red_manager_server_port: 9003 # optional, defaults to 9003
40
40
 
41
+ # Seconds before an idle instance is auto-killed. Optional, defaults to 2700 (45 min).
42
+ instance_ttl_seconds: 2700
43
+
41
44
  environments:
42
45
  - name: compose-engine-template
43
46
  path: /path/to/compose-engine-template
@@ -64,6 +67,7 @@ def _load_yaml_config(
64
67
  data.get("node_red_manager_server_host", "0.0.0.0") or "0.0.0.0" # noqa: S104
65
68
  )
66
69
  manager_port: int = int(data.get("node_red_manager_server_port", 9003))
70
+ instance_ttl_seconds: int = int(data.get("instance_ttl_seconds", 2700))
67
71
 
68
72
  raw_environments: list[dict] = data.get("environments") or []
69
73
  environments: dict[str, TemplateConfig] = {}
@@ -83,7 +87,14 @@ def _load_yaml_config(
83
87
  max_port=max_port,
84
88
  )
85
89
 
86
- return node_red_executable, base_path, manager_host, manager_port, environments
90
+ return (
91
+ node_red_executable,
92
+ base_path,
93
+ manager_host,
94
+ manager_port,
95
+ instance_ttl_seconds,
96
+ environments,
97
+ )
87
98
 
88
99
 
89
100
  # Resolve config file path from env var; default to node-red-config.yaml in cwd.
@@ -92,14 +103,20 @@ _config_file = Path(os.getenv("NODE_RED_CONFIG_FILE", "node-red-config.yaml"))
92
103
  # Load at module import time so config is available immediately.
93
104
  # If the file is absent the server will fail fast during lifespan startup (validate() call).
94
105
  try:
95
- _node_red_executable, _base_path, _manager_host, _manager_port, _environments = (
96
- _load_yaml_config(_config_file)
97
- )
106
+ (
107
+ _node_red_executable,
108
+ _base_path,
109
+ _manager_host,
110
+ _manager_port,
111
+ _instance_ttl_seconds,
112
+ _environments,
113
+ ) = _load_yaml_config(_config_file)
98
114
  except FileNotFoundError:
99
115
  _node_red_executable = "node-red"
100
116
  _base_path = ""
101
117
  _manager_host = "0.0.0.0" # noqa: S104
102
118
  _manager_port = 9003
119
+ _instance_ttl_seconds = 2700
103
120
  _environments = {}
104
121
 
105
122
 
@@ -111,6 +128,7 @@ class NodeRedManagerServerConfig:
111
128
  base_path: str = _base_path
112
129
  node_red_manager_server_host: str = _manager_host
113
130
  node_red_manager_server_port: int = _manager_port
131
+ instance_ttl_seconds: int = _instance_ttl_seconds
114
132
  environments: dict[str, TemplateConfig] = _environments
115
133
 
116
134
  @classmethod
@@ -0,0 +1,66 @@
1
+ """Domain exceptions for the Node-RED instance manager server."""
2
+
3
+
4
+ class NodeRedManagerError(Exception):
5
+ """Base exception for Node-RED instance manager errors.
6
+
7
+ Subclasses declare a status_code so the central FastAPI exception handler
8
+ can convert them to HTTP responses without any per-endpoint boilerplate.
9
+
10
+ ERROR_CODE is a stable string constant included in HTTP responses and utils
11
+ error strings so consumers can distinguish error types without re-raising:
12
+
13
+ from ...exceptions import FlowsFileNotFoundError
14
+ result = create_instance(...)
15
+ if FlowsFileNotFoundError.ERROR_CODE in result:
16
+ ...
17
+ """
18
+
19
+ status_code: int = 500
20
+ ERROR_CODE: str = "NODE_RED_MANAGER_ERROR"
21
+
22
+ def __init__(self, detail: str) -> None:
23
+ super().__init__(detail)
24
+ self.detail = detail
25
+
26
+
27
+ class InvalidWorkspacePathError(NodeRedManagerError):
28
+ """Raised when workspace_base_path is empty or contains invalid path segments."""
29
+
30
+ status_code = 400
31
+ ERROR_CODE = "INVALID_WORKSPACE_PATH"
32
+
33
+
34
+ class UnknownEnvironmentError(NodeRedManagerError):
35
+ """Raised when the requested environment name is not in the server config."""
36
+
37
+ status_code = 400
38
+ ERROR_CODE = "UNKNOWN_ENVIRONMENT"
39
+
40
+
41
+ class FlowsFileNotFoundError(NodeRedManagerError):
42
+ """Raised when flows.json does not exist at the resolved workspace path."""
43
+
44
+ status_code = 404
45
+ ERROR_CODE = "FLOWS_FILE_NOT_FOUND"
46
+
47
+
48
+ class NoAvailablePortError(NodeRedManagerError):
49
+ """Raised when no free port exists within the environment's configured range."""
50
+
51
+ status_code = 503
52
+ ERROR_CODE = "NO_AVAILABLE_PORT"
53
+
54
+
55
+ class NodeRedLaunchError(NodeRedManagerError):
56
+ """Raised when the node-red subprocess fails to start."""
57
+
58
+ status_code = 500
59
+ ERROR_CODE = "NODE_RED_LAUNCH_ERROR"
60
+
61
+
62
+ class InstanceNotFoundError(NodeRedManagerError):
63
+ """Raised when the requested instance ID is not in the registry."""
64
+
65
+ status_code = 404
66
+ ERROR_CODE = "INSTANCE_NOT_FOUND"
@@ -1,5 +1,6 @@
1
1
  """Pydantic request/response models for the Node-RED instance manager API."""
2
2
 
3
+ from datetime import datetime
3
4
  from typing import Any
4
5
 
5
6
  from pydantic import BaseModel, Field, field_validator
@@ -20,6 +21,11 @@ class CreateInstanceRequest(BaseModel):
20
21
  description="Relative path to flows.json within the workspace directory.",
21
22
  )
22
23
  environment_name: str = Field(..., description="Name of the Node-RED environment to use.")
24
+ ttl_seconds: int | None = Field(
25
+ default=None,
26
+ description="TTL in seconds before the instance is auto-killed. Uses server default if not provided.",
27
+ ge=1,
28
+ )
23
29
 
24
30
  @field_validator("flows_json_path")
25
31
  @classmethod
@@ -41,6 +47,9 @@ class CreateInstanceRequest(BaseModel):
41
47
  class CreateInstanceResponse(BaseModel):
42
48
  id: str = Field(..., description="Instance ID (workspace_base_path).")
43
49
  url: str = Field(..., description="URL to access the Node-RED instance (includes port).")
50
+ expires_at: datetime = Field(
51
+ ..., description="UTC datetime when this instance will be auto-killed."
52
+ )
44
53
 
45
54
 
46
55
  class KillInstanceRequest(BaseModel):
@@ -62,3 +71,5 @@ class InstanceInfo(BaseModel):
62
71
  environment_name: str
63
72
  url: str
64
73
  pid: int
74
+ created_at: datetime
75
+ expires_at: datetime
@@ -72,24 +72,30 @@ def create_instance_tool(
72
72
  workspace_base_path: str = "",
73
73
  flows_json_path: str = "",
74
74
  environment_name: str = "",
75
+ ttl_seconds: int | None = None,
75
76
  ) -> str:
76
77
  """
77
78
  Launch a new Node-RED instance for the given workspace.
78
79
 
79
- Returns the instance id and URL. If an instance already exists for this workspace,
80
- the existing one is returned without launching a new process.
80
+ Returns the instance id, URL, and expiry time. If an instance already exists for this
81
+ workspace, the existing one is returned without launching a new process.
82
+
83
+ ttl_seconds overrides the server default TTL (45 min). The instance is auto-killed after
84
+ the TTL expires.
81
85
  """
82
86
  logger.info(
83
- "[tools] Creating Node-RED instance workspace=%r flows=%r environment=%r",
87
+ "[tools] Creating Node-RED instance workspace=%r flows=%r environment=%r ttl=%s",
84
88
  workspace_base_path,
85
89
  flows_json_path,
86
90
  environment_name,
91
+ ttl_seconds,
87
92
  )
88
93
  try:
89
94
  result = create_instance(
90
95
  workspace_base_path,
91
96
  flows_json_path,
92
97
  environment_name,
98
+ ttl_seconds=ttl_seconds,
93
99
  session_id=_session_id_from_runtime(runtime),
94
100
  )
95
101
  _check_result(result, "create_instance")
@@ -10,6 +10,27 @@ from autobots_devtools_shared_lib.common.observability.trace_propagation import
10
10
  logger = get_logger(__name__)
11
11
 
12
12
  NODE_RED_MANAGER_HOST = os.getenv("NODE_RED_MANAGER_HOST", "localhost")
13
+
14
+
15
+ def _http_error_str(prefix: str, e: httpx.HTTPStatusError) -> str:
16
+ """Format an HTTPStatusError into a standardised error string.
17
+
18
+ Parses the JSON body to include the server's error_code (if present) so
19
+ consumers can compare against exception constants without re-raising:
20
+
21
+ if FlowsFileNotFoundError.ERROR_CODE in result: ...
22
+ """
23
+ try:
24
+ body = e.response.json()
25
+ error_code = body.get("error_code", "")
26
+ detail = body.get("detail", e.response.text)
27
+ except Exception:
28
+ error_code = ""
29
+ detail = e.response.text
30
+ code_part = f" [{error_code}]" if error_code else ""
31
+ return f"Error {prefix}: HTTP {e.response.status_code}{code_part} - {detail}"
32
+
33
+
13
34
  NODE_RED_MANAGER_PORT = os.getenv("NODE_RED_MANAGER_PORT", "9003")
14
35
  NODE_RED_MANAGER_BASE_URL = f"http://{NODE_RED_MANAGER_HOST}:{NODE_RED_MANAGER_PORT}"
15
36
 
@@ -43,7 +64,7 @@ def get_health(session_id: str | None = None) -> str:
43
64
  e.response.status_code,
44
65
  e.response.text,
45
66
  )
46
- return f"Error getting health: HTTP {e.response.status_code} - {e.response.text}"
67
+ return _http_error_str("getting health", e)
47
68
  except Exception as e:
48
69
  logger.exception("Error getting Node-RED manager health")
49
70
  return f"Error getting health: {e!s}"
@@ -81,7 +102,7 @@ def list_instances(session_id: str | None = None) -> str:
81
102
  e.response.status_code,
82
103
  e.response.text,
83
104
  )
84
- return f"Error listing instances: HTTP {e.response.status_code} - {e.response.text}"
105
+ return _http_error_str("listing instances", e)
85
106
  except Exception as e:
86
107
  logger.exception("Error listing Node-RED instances")
87
108
  return f"Error listing instances: {e!s}"
@@ -95,6 +116,7 @@ def create_instance(
95
116
  workspace_base_path: str,
96
117
  flows_json_path: str,
97
118
  environment_name: str,
119
+ ttl_seconds: int | None = None,
98
120
  session_id: str | None = None,
99
121
  ) -> str:
100
122
  """
@@ -107,23 +129,27 @@ def create_instance(
107
129
  workspace_base_path: Workspace path (e.g. 'user/repo-JIRA-42').
108
130
  flows_json_path: Relative path to flows.json within the workspace directory.
109
131
  environment_name: Name of the Node-RED environment template to use.
132
+ ttl_seconds: TTL in seconds before the instance is auto-killed. Uses server default if None.
110
133
  session_id: Optional session ID for trace correlation.
111
134
 
112
135
  Returns:
113
- Success message with instance id and url.
136
+ Success message with instance id, url, and expiry time.
114
137
  """
115
138
  logger.info(
116
- "Creating Node-RED instance workspace=%r flows=%r environment=%r",
139
+ "Creating Node-RED instance workspace=%r flows=%r environment=%r ttl=%s",
117
140
  workspace_base_path,
118
141
  flows_json_path,
119
142
  environment_name,
143
+ ttl_seconds,
120
144
  )
121
145
  try:
122
- payload = {
146
+ payload: dict = {
123
147
  "workspace_context": {"workspace_base_path": workspace_base_path},
124
148
  "flows_json_path": flows_json_path,
125
149
  "environment_name": environment_name,
126
150
  }
151
+ if ttl_seconds is not None:
152
+ payload["ttl_seconds"] = ttl_seconds
127
153
  with (
128
154
  traced_http_call(
129
155
  "noderedManagerCreateInstance", session_id=session_id
@@ -145,15 +171,18 @@ def create_instance(
145
171
  e.response.status_code,
146
172
  e.response.text,
147
173
  )
148
- return f"Error creating instance: HTTP {e.response.status_code} - {e.response.text}"
174
+ return _http_error_str("creating instance", e)
149
175
  except Exception as e:
150
176
  logger.exception("Error creating Node-RED instance workspace=%r", workspace_base_path)
151
177
  return f"Error creating instance: {e!s}"
152
178
  else:
153
179
  logger.info(
154
- "Node-RED instance created/reused: id=%s url=%s", result.get("id"), result.get("url")
180
+ "Node-RED instance created/reused: id=%s url=%s expires_at=%s",
181
+ result.get("id"),
182
+ result.get("url"),
183
+ result.get("expires_at"),
155
184
  )
156
- return f"Instance created: id={result['id']} url={result['url']}"
185
+ return f"Instance created: id={result['id']} url={result['url']} expires_at={result['expires_at']}"
157
186
 
158
187
 
159
188
  def kill_instance(
@@ -201,7 +230,7 @@ def kill_instance(
201
230
  e.response.status_code,
202
231
  e.response.text,
203
232
  )
204
- return f"Error killing instance: HTTP {e.response.status_code} - {e.response.text}"
233
+ return _http_error_str("killing instance", e)
205
234
  except Exception as e:
206
235
  logger.exception(
207
236
  "Error killing Node-RED instance workspace=%r environment=%r",
@@ -1,5 +0,0 @@
1
- """Node-RED instance manager server package."""
2
-
3
- from .app import app # re-export for convenience
4
-
5
- __all__ = ["app"]