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.
- {autobots_devtools_shared_lib-0.8.0 → autobots_devtools_shared_lib-0.10.0}/PKG-INFO +1 -1
- {autobots_devtools_shared_lib-0.8.0 → autobots_devtools_shared_lib-0.10.0}/pyproject.toml +1 -1
- autobots_devtools_shared_lib-0.10.0/src/autobots_devtools_shared_lib/common/servers/noderedmanagerserver/__init__.py +23 -0
- {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
- {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
- {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
- autobots_devtools_shared_lib-0.10.0/src/autobots_devtools_shared_lib/common/servers/noderedmanagerserver/exceptions.py +66 -0
- {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
- autobots_devtools_shared_lib-0.10.0/src/autobots_devtools_shared_lib/common/tools/noderedmanager_client_tools.py +140 -0
- autobots_devtools_shared_lib-0.10.0/src/autobots_devtools_shared_lib/common/utils/noderedmanager_client_utils.py +249 -0
- autobots_devtools_shared_lib-0.8.0/src/autobots_devtools_shared_lib/common/servers/noderedmanagerserver/__init__.py +0 -5
- {autobots_devtools_shared_lib-0.8.0 → autobots_devtools_shared_lib-0.10.0}/README.md +0 -0
- {autobots_devtools_shared_lib-0.8.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/__init__.py +0 -0
- {autobots_devtools_shared_lib-0.8.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/__init__.py +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {autobots_devtools_shared_lib-0.8.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/dynagent/__init__.py +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {autobots_devtools_shared_lib-0.8.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/eval/__init__.py +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {autobots_devtools_shared_lib-0.8.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/py.typed +0 -0
|
@@ -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=
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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.
|
|
231
|
-
|
|
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(
|
|
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
|
|
257
|
-
|
|
258
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
96
|
-
|
|
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
|