autobots-devtools-shared-lib 0.8.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.8.0 → autobots_devtools_shared_lib-0.10.0}/PKG-INFO +1 -1
  2. {autobots_devtools_shared_lib-0.8.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.8.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.8.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/servers/noderedmanagerserver/app.py +136 -65
  6. {autobots_devtools_shared_lib-0.8.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.8.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/servers/noderedmanagerserver/models.py +12 -0
  9. autobots_devtools_shared_lib-0.10.0/src/autobots_devtools_shared_lib/common/tools/noderedmanager_client_tools.py +140 -0
  10. autobots_devtools_shared_lib-0.10.0/src/autobots_devtools_shared_lib/common/utils/noderedmanager_client_utils.py +249 -0
  11. autobots_devtools_shared_lib-0.8.0/src/autobots_devtools_shared_lib/common/servers/noderedmanagerserver/__init__.py +0 -5
  12. {autobots_devtools_shared_lib-0.8.0 → autobots_devtools_shared_lib-0.10.0}/README.md +0 -0
  13. {autobots_devtools_shared_lib-0.8.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/__init__.py +0 -0
  14. {autobots_devtools_shared_lib-0.8.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/__init__.py +0 -0
  15. {autobots_devtools_shared_lib-0.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/dynagent/__init__.py +0 -0
  55. {autobots_devtools_shared_lib-0.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/eval/__init__.py +0 -0
  79. {autobots_devtools_shared_lib-0.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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.8.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,65 +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 used as the instance ID
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 = 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)
243
322
 
244
323
  # 2. Return existing instance if one is already running for this workspace
245
324
  if instance_id in _registry:
246
- existing_info, _ = _registry[instance_id]
325
+ existing_info, _, _ = _registry[instance_id]
247
326
  logger.info(
248
327
  "create-instance reusing existing id=%s url=%s", existing_info.id, existing_info.url
249
328
  )
250
- 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
+ )
251
332
 
252
333
  # 3. Validate environment name
253
334
  environment = config.environments.get(body.environment_name)
254
335
  if environment is None:
255
336
  logger.warning("create-instance unknown environment=%s", body.environment_name)
256
- raise HTTPException(
257
- status_code=status.HTTP_400_BAD_REQUEST,
258
- detail=(
259
- f"Unknown environment '{body.environment_name}'. "
260
- f"Available: {list(config.environments.keys())}"
261
- ),
337
+ raise UnknownEnvironmentError(
338
+ f"Unknown environment '{body.environment_name}'. "
339
+ f"Available: {list(config.environments.keys())}"
262
340
  )
263
341
 
264
342
  # 4. Resolve full flows path: base_path / workspace_base_path / flows_json_path
343
+ workspace_base_path = instance_id.split("/", 1)[1]
265
344
  flows_path = Path(config.base_path) / workspace_base_path / body.flows_json_path
266
345
  if not flows_path.exists():
267
- raise HTTPException(
268
- status_code=status.HTTP_400_BAD_REQUEST,
269
- detail=f"flows.json not found at resolved path: {flows_path}",
270
- )
346
+ raise FlowsFileNotFoundError(f"flows.json not found at resolved path: {flows_path}")
271
347
 
272
- # 5. Find next available port within this environment's port range
273
- try:
274
- port = await _find_available_port(environment)
275
- except RuntimeError as e:
276
- 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)
277
350
 
278
351
  # 6. Launch subprocess — INSTANCE_ID env var picked up by the environment's settings.js
279
352
  try:
280
353
  process = await _launch_node_red(environment, str(flows_path), port, instance_id)
281
354
  except Exception as e:
282
355
  logger.exception("create-instance failed to launch node-red")
283
- raise HTTPException(
284
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
285
- detail=f"Failed to launch Node-RED: {e!s}",
286
- ) from e
356
+ raise NodeRedLaunchError(f"Failed to launch Node-RED: {e!s}") from e
287
357
 
288
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)
289
362
  url = f"http://{config.node_red_manager_server_host}:{port}/{instance_id}"
290
363
  info = InstanceInfo(
291
364
  id=instance_id,
@@ -293,31 +366,29 @@ async def create_instance(body: CreateInstanceRequest) -> CreateInstanceResponse
293
366
  environment_name=body.environment_name,
294
367
  url=url,
295
368
  pid=process.pid or 0,
369
+ created_at=created_at,
370
+ expires_at=expires_at,
296
371
  )
297
- _registry[instance_id] = (info, process)
298
- logger.info("create-instance success id=%s url=%s pid=%s", instance_id, url, process.pid)
299
- 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)
300
378
 
301
379
 
302
380
  @app.post("/kill-instance")
303
381
  async def kill_instance(body: KillInstanceRequest) -> KillInstanceResponse:
304
382
  """Kill a running Node-RED instance by workspace_base_path."""
305
- instance_id: str = (body.workspace_context.get("workspace_base_path") or "").strip()
306
- if not instance_id:
307
- raise HTTPException(
308
- status_code=status.HTTP_400_BAD_REQUEST,
309
- detail="workspace_context.workspace_base_path is required and cannot be empty.",
310
- )
383
+ instance_id = _resolve_instance_id(body.workspace_context, body.environment_name)
311
384
  logger.info("kill-instance called id=%s", instance_id)
312
385
 
313
386
  entry = _registry.get(instance_id)
314
387
  if entry is None:
315
- raise HTTPException(
316
- status_code=status.HTTP_404_NOT_FOUND,
317
- detail=f"Instance '{instance_id}' not found",
318
- )
388
+ raise InstanceNotFoundError(f"Instance '{instance_id}' not found")
319
389
 
320
- _, process = entry
390
+ _, process, ttl_task = entry
391
+ ttl_task.cancel()
321
392
  await _kill_instance(instance_id, process)
322
393
  del _registry[instance_id]
323
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):
@@ -49,6 +58,7 @@ class KillInstanceRequest(BaseModel):
49
58
  description="Workspace scoping context. Must include `workspace_base_path`.",
50
59
  json_schema_extra={"examples": [{"workspace_base_path": "alice/my-project-JIRA-42"}]},
51
60
  )
61
+ environment_name: str = Field(..., description="Name of the Node-RED environment to kill.")
52
62
 
53
63
 
54
64
  class KillInstanceResponse(BaseModel):
@@ -61,3 +71,5 @@ class InstanceInfo(BaseModel):
61
71
  environment_name: str
62
72
  url: str
63
73
  pid: int
74
+ created_at: datetime
75
+ expires_at: datetime