mirror of
https://github.com/langgenius/dify.git
synced 2026-07-09 14:48:12 +08:00
Compare commits
6 Commits
deploy/age
...
fix/report
| Author | SHA1 | Date | |
|---|---|---|---|
| d93965fd38 | |||
| b68dce3ca0 | |||
| 395644ad66 | |||
| a1b45415ac | |||
| 5741f8f9d1 | |||
| 512f39dede |
@ -663,9 +663,6 @@ PLUGIN_MODEL_SCHEMA_CACHE_TTL=3600
|
||||
PLUGIN_MODEL_PROVIDERS_CACHE_TTL=86400
|
||||
INNER_API_KEY_FOR_PLUGIN=QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1
|
||||
|
||||
# Dify Agent backend
|
||||
AGENT_BACKEND_BASE_URL=http://localhost:5050
|
||||
|
||||
# Marketplace configuration
|
||||
MARKETPLACE_ENABLED=true
|
||||
MARKETPLACE_API_URL=https://marketplace.dify.ai
|
||||
|
||||
@ -25,10 +25,11 @@ class AgentBackendConfig(BaseSettings):
|
||||
AGENT_SHELL_ENABLED: bool = Field(
|
||||
description=(
|
||||
"Inject the dify.shell layer (sandboxed bash workspace) into Agent runs. "
|
||||
"Requires the agent backend to be wired with a shellctl entrypoint before "
|
||||
"shell-using Agent runs are executed."
|
||||
"Requires the agent backend to be wired with a shellctl entrypoint; keep it "
|
||||
"off until shellctl is deployed, otherwise every agent run that includes the "
|
||||
"shell layer will fail."
|
||||
),
|
||||
default=True,
|
||||
default=False,
|
||||
)
|
||||
|
||||
AGENT_APP_TEXT_DELTA_DEBOUNCE_SECONDS: NonNegativeFloat = Field(
|
||||
|
||||
@ -1233,16 +1233,24 @@ class ProviderConfiguration(BaseModel):
|
||||
available_credentials_count = session.execute(count_stmt).scalar() or 0
|
||||
session.delete(credential_record)
|
||||
|
||||
model_credentials_cache_identity_id: str | None = None
|
||||
if provider_model_record and (
|
||||
available_credentials_count <= 1 or provider_model_record.credential_id == credential_id
|
||||
):
|
||||
model_credentials_cache_identity_id = provider_model_record.id
|
||||
|
||||
if provider_model_record and available_credentials_count <= 1:
|
||||
# If all credentials are deleted, delete the custom model record
|
||||
session.delete(provider_model_record)
|
||||
elif provider_model_record and provider_model_record.credential_id == credential_id:
|
||||
provider_model_record.credential_id = None
|
||||
provider_model_record.updated_at = naive_utc_now()
|
||||
|
||||
if model_credentials_cache_identity_id:
|
||||
provider_model_credentials_cache = ProviderCredentialsCache(
|
||||
tenant_id=self.tenant_id,
|
||||
identity_id=provider_model_record.id,
|
||||
cache_type=ProviderCredentialsCacheType.PROVIDER,
|
||||
identity_id=model_credentials_cache_identity_id,
|
||||
cache_type=ProviderCredentialsCacheType.MODEL,
|
||||
)
|
||||
provider_model_credentials_cache.delete()
|
||||
|
||||
|
||||
@ -7,7 +7,6 @@ from typing import TYPE_CHECKING, Any, override
|
||||
from agenton.compositor import CompositorSessionSnapshot
|
||||
|
||||
from clients.agent_backend import (
|
||||
AgentBackendAgentMessageDeltaInternalEvent,
|
||||
AgentBackendDeferredToolCallInternalEvent,
|
||||
AgentBackendError,
|
||||
AgentBackendHTTPError,
|
||||
@ -482,10 +481,6 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
|
||||
if isinstance(internal_event, AgentBackendStreamInternalEvent):
|
||||
self._record_stream_metadata(metadata, internal_event)
|
||||
continue
|
||||
if internal_event.type == AgentBackendInternalEventType.AGENT_MESSAGE_DELTA:
|
||||
if isinstance(internal_event, AgentBackendAgentMessageDeltaInternalEvent):
|
||||
self._record_agent_message_delta_metadata(metadata, internal_event)
|
||||
continue
|
||||
metadata["agent_backend"] = {
|
||||
**dict(metadata.get("agent_backend") or {}),
|
||||
"stream_event_count": stream_event_count,
|
||||
@ -739,17 +734,6 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
|
||||
agent_backend["usage"] = dict(usage)
|
||||
metadata["agent_backend"] = agent_backend
|
||||
|
||||
@staticmethod
|
||||
def _record_agent_message_delta_metadata(
|
||||
metadata: dict[str, Any], event: AgentBackendAgentMessageDeltaInternalEvent
|
||||
) -> None:
|
||||
agent_backend = dict(metadata.get("agent_backend") or {})
|
||||
agent_backend["agent_message_delta_count"] = int(agent_backend.get("agent_message_delta_count") or 0) + 1
|
||||
agent_backend["agent_message_delta_length"] = int(agent_backend.get("agent_message_delta_length") or 0) + len(
|
||||
event.delta
|
||||
)
|
||||
metadata["agent_backend"] = agent_backend
|
||||
|
||||
@classmethod
|
||||
@override
|
||||
def _extract_variable_selector_to_variable_mapping(
|
||||
|
||||
@ -75,7 +75,6 @@ def test_dify_config(monkeypatch: pytest.MonkeyPatch):
|
||||
# default values
|
||||
assert config.EDITION == "SELF_HOSTED"
|
||||
assert config.API_COMPRESSION_ENABLED is False
|
||||
assert config.AGENT_SHELL_ENABLED is True
|
||||
assert config.SENTRY_TRACES_SAMPLE_RATE == 1.0
|
||||
assert config.TEMPLATE_TRANSFORM_MAX_LENGTH == 400_000
|
||||
|
||||
|
||||
@ -25,6 +25,7 @@ from core.entities.provider_entities import (
|
||||
SystemConfiguration,
|
||||
SystemConfigurationStatus,
|
||||
)
|
||||
from core.helper.model_provider_cache import ProviderCredentialsCacheType
|
||||
from graphon.model_runtime.entities.common_entities import I18nObject
|
||||
from graphon.model_runtime.entities.model_entities import AIModelEntity, FetchFrom, ModelType
|
||||
from graphon.model_runtime.entities.provider_entities import (
|
||||
@ -1336,6 +1337,8 @@ def test_create_update_delete_custom_model_credential_flow() -> None:
|
||||
configuration.delete_custom_model_credential(ModelType.LLM, "gpt-4o", "cred-1")
|
||||
assert provider_model_record.credential_id is None
|
||||
assert mock_cache.return_value.delete.call_count == 2
|
||||
assert mock_cache.call_args_list[0].kwargs["cache_type"] == ProviderCredentialsCacheType.LOAD_BALANCING_MODEL
|
||||
assert mock_cache.call_args_list[1].kwargs["cache_type"] == ProviderCredentialsCacheType.MODEL
|
||||
|
||||
session = Mock()
|
||||
mismatched_credential_record = SimpleNamespace(
|
||||
@ -2032,9 +2035,16 @@ def test_delete_custom_model_credential_removes_custom_model_record_when_last_cr
|
||||
|
||||
with _patched_session(session):
|
||||
with patch.object(ProviderConfiguration, "_get_custom_model_record", return_value=provider_model_record):
|
||||
configuration.delete_custom_model_credential(ModelType.LLM, "gpt-4o", "cred-1")
|
||||
with patch("core.entities.provider_configuration.ProviderCredentialsCache") as mock_cache:
|
||||
configuration.delete_custom_model_credential(ModelType.LLM, "gpt-4o", "cred-1")
|
||||
|
||||
assert any(call.args and call.args[0] is provider_model_record for call in session.delete.call_args_list)
|
||||
mock_cache.assert_called_once_with(
|
||||
tenant_id="tenant-1",
|
||||
identity_id="model-1",
|
||||
cache_type=ProviderCredentialsCacheType.MODEL,
|
||||
)
|
||||
mock_cache.return_value.delete.assert_called_once()
|
||||
|
||||
|
||||
def test_delete_custom_model_credential_rolls_back_on_error() -> None:
|
||||
|
||||
@ -1,12 +1,10 @@
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from agenton.compositor import CompositorSessionSnapshot
|
||||
from dify_agent.layers.ask_human import AskHumanToolResult
|
||||
from dify_agent.protocol import PydanticAIStreamRunEvent, RunStartedEvent, RunSucceededEvent, RunSucceededEventData
|
||||
from pydantic_ai.messages import PartDeltaEvent, TextPartDelta
|
||||
from dify_agent.protocol import RunStartedEvent, RunSucceededEvent, RunSucceededEventData
|
||||
|
||||
from clients.agent_backend import (
|
||||
AgentBackendRunEventAdapter,
|
||||
@ -192,30 +190,6 @@ class FileOutputBackendClient(FakeAgentBackendRunClient):
|
||||
)
|
||||
|
||||
|
||||
class AgentMessageDeltaBackendClient(FakeAgentBackendRunClient):
|
||||
def _events(self, run_id: str):
|
||||
created_at = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
return (
|
||||
RunStartedEvent(id="1-0", run_id=run_id, created_at=created_at),
|
||||
PydanticAIStreamRunEvent(
|
||||
id="2-0",
|
||||
run_id=run_id,
|
||||
created_at=created_at,
|
||||
data=PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="hello ")),
|
||||
agent_message_delta="hello ",
|
||||
),
|
||||
RunSucceededEvent(
|
||||
id="3-0",
|
||||
run_id=run_id,
|
||||
created_at=created_at,
|
||||
data=RunSucceededEventData(
|
||||
output={"text": "hello agent"},
|
||||
session_snapshot=CompositorSessionSnapshot(layers=[]),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _node(
|
||||
*,
|
||||
scenario: FakeAgentBackendScenario = FakeAgentBackendScenario.SUCCESS,
|
||||
@ -303,19 +277,6 @@ def test_agent_node_run_maps_successful_agent_backend_run_to_node_result():
|
||||
assert layers["llm"]["config"]["credentials"] == "[REDACTED]"
|
||||
|
||||
|
||||
def test_agent_node_run_ignores_agent_message_delta_until_terminal_result():
|
||||
events = list(_node(agent_backend_client=AgentMessageDeltaBackendClient())._run())
|
||||
|
||||
assert len(events) == 1
|
||||
result = cast(StreamCompletedEvent, events[0]).node_run_result
|
||||
assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED
|
||||
assert result.outputs == {"text": "hello agent"}
|
||||
agent_backend = result.metadata[WorkflowNodeExecutionMetadataKey.AGENT_LOG]["agent_backend"]
|
||||
assert agent_backend["status"] == "succeeded"
|
||||
assert agent_backend["agent_message_delta_count"] == 1
|
||||
assert agent_backend["agent_message_delta_length"] == len("hello ")
|
||||
|
||||
|
||||
def test_agent_node_run_normalizes_declared_file_output_with_canonical_mapping():
|
||||
tool_reference = build_file_reference(record_id="tool-file-1")
|
||||
with patch(
|
||||
|
||||
37
api/uv.lock
generated
37
api/uv.lock
generated
@ -322,15 +322,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.14.1"
|
||||
version = "4.11.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "sniffio" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1283,9 +1284,7 @@ name = "dify-agent"
|
||||
version = "0.1.0"
|
||||
source = { editable = "../dify-agent" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "httpx" },
|
||||
{ name = "httpx2" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-ai-slim" },
|
||||
{ name = "typer" },
|
||||
@ -1294,14 +1293,10 @@ dependencies = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "aiosqlite", marker = "extra == 'shellctl-server'", specifier = ">=0.21.0,<1.0.0" },
|
||||
{ name = "anyio", specifier = ">=4.12.1,<5.0.0" },
|
||||
{ name = "fastapi", marker = "extra == 'server'", specifier = "==0.136.0" },
|
||||
{ name = "fastapi", marker = "extra == 'shellctl-server'", specifier = "==0.136.0" },
|
||||
{ name = "graphon", marker = "extra == 'server'", specifier = "==0.5.2" },
|
||||
{ name = "grpclib", extras = ["protobuf"], marker = "extra == 'grpc'", specifier = ">=0.4.9,<0.5.0" },
|
||||
{ name = "httpx", specifier = "==0.28.1" },
|
||||
{ name = "httpx2", specifier = ">=2.5.0,<3.0.0" },
|
||||
{ name = "jsonschema", marker = "extra == 'server'", specifier = ">=4.23.0,<5.0.0" },
|
||||
{ name = "jwcrypto", marker = "extra == 'server'", specifier = ">=1.5.6,<2" },
|
||||
{ name = "logfire", extras = ["fastapi", "httpx", "redis"], marker = "extra == 'server'", specifier = ">=4.37.0,<5.0.0" },
|
||||
@ -1311,13 +1306,12 @@ requires-dist = [
|
||||
{ name = "pydantic-ai-slim", extras = ["anthropic", "google", "openai"], marker = "extra == 'server'", specifier = ">=1.85.1,<2.0.0" },
|
||||
{ name = "pydantic-settings", marker = "extra == 'server'", specifier = ">=2.12.0,<3.0.0" },
|
||||
{ name = "redis", marker = "extra == 'server'", specifier = ">=7.4.0,<8.0.0" },
|
||||
{ name = "sqlmodel", marker = "extra == 'shellctl-server'", specifier = ">=0.0.24,<0.1.0" },
|
||||
{ name = "shell-session-manager", marker = "extra == 'server'", specifier = "==2.4.0" },
|
||||
{ name = "typer", specifier = ">=0.16.1,<0.17" },
|
||||
{ name = "typing-extensions", specifier = ">=4.12.2,<5.0.0" },
|
||||
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'server'", specifier = "==0.46.0" },
|
||||
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'shellctl-server'", specifier = "==0.46.0" },
|
||||
]
|
||||
provides-extras = ["grpc", "server", "shellctl-server"]
|
||||
provides-extras = ["grpc", "server"]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
@ -3289,15 +3283,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "httpcore2"
|
||||
version = "2.5.0"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "h11" },
|
||||
{ name = "truststore" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/47/06/5c12df521b5322fb1114a83d46911b2fbcb8855ddb3a635f11c01a214af5/httpcore2-2.5.0.tar.gz", hash = "sha256:88aa170137c17328d5ac44234f9fd10706466d5fb347f3edac4d39b91137b09d", size = 64808, upload-time = "2026-06-25T14:16:56.472Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e6/34/18f1c596e677962f040284246f393b10a1f8ce440b3a7e69c637d0f1c7ad/httpcore2-2.3.0.tar.gz", hash = "sha256:07327e251560960eea8e969d92d4c6a325feb13cca39e25340731336c3baf924", size = 64300, upload-time = "2026-06-01T13:15:02.998Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/a1/7564199d1a8728fe737b0a72e5b3f8d92dfe085a74ddf7cdd83bce5f206d/httpcore2-2.5.0-py3-none-any.whl", hash = "sha256:5ce35188de461d31e8d000bfb8ef8bf22c6c16587a211e5571deaa5e9bdf842a", size = 80330, upload-time = "2026-06-25T14:16:53.634Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/dd/3357218c69360d1cecc196c230c9a1d5c9afd5dba362056e23e60a5e64e5/httpcore2-2.3.0-py3-none-any.whl", hash = "sha256:477e9e334f74e5240dcac002e890580f36a57d40ff0fb14cc9655731d23b8415", size = 80024, upload-time = "2026-06-01T13:15:00.001Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -3361,18 +3355,17 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "httpx2"
|
||||
version = "2.5.0"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "httpcore2" },
|
||||
{ name = "idna" },
|
||||
{ name = "truststore" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d0/e2/b5dedc0cf35aa65de5f541ccd30d2bc1fd7f1d43c9ab09f8ed9a7342317b/httpx2-2.5.0.tar.gz", hash = "sha256:e2df9cb4611021527ff8a675b1c320b610a2ec397acc8d6fe6e91df2d9b33c29", size = 83121, upload-time = "2026-06-25T14:16:57.491Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9f/9a/cca0b9145f13d8ae34b885ae28d403a1469a433abc78e0f94f4ce94e650b/httpx2-2.3.0.tar.gz", hash = "sha256:227e7c41d95a76d4077a52640564132777215fc3394e07b66a3116c33d668fa9", size = 81115, upload-time = "2026-06-01T13:15:04.324Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/31/22/859d8252dad9bc9adee34b52e62cde621ece07b042ccb2ab4da1be46695f/httpx2-2.5.0-py3-none-any.whl", hash = "sha256:3d2d4d9cf4b61f1a1f46a95947cfdb47e80cb56a2f91c6256ac8f58e4891df41", size = 76652, upload-time = "2026-06-25T14:16:55.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/ce/ae2911859847f9ba1d6b23027e53481cbeb50b93234f355a968d300ca2cb/httpx2-2.3.0-py3-none-any.whl", hash = "sha256:6f393663bdf6dbe7fe90118e3eb5b2bd024a675cae0390ac08cec9198812d8b7", size = 74538, upload-time = "2026-06-01T13:15:01.566Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -3430,11 +3423,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.18"
|
||||
version = "3.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@ -67,7 +67,7 @@
|
||||
"test:e2e:local": "DIFY_E2E_MODE=local vp test --config vitest.e2e.config.ts",
|
||||
"lint": "eslint",
|
||||
"lint:fix": "eslint --fix",
|
||||
"type-check": "tsgo",
|
||||
"type-check": "tsc",
|
||||
"tree:gen": "bun scripts/generate-command-tree.ts",
|
||||
"tree:check": "bun scripts/generate-command-tree.ts --check",
|
||||
"prebuild": "pnpm tree:gen",
|
||||
@ -101,7 +101,7 @@
|
||||
"@types/js-yaml": "catalog:",
|
||||
"@types/lockfile": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"@typescript/native": "catalog:",
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"eslint": "catalog:",
|
||||
"hono": "catalog:",
|
||||
|
||||
@ -20,12 +20,6 @@ DIFY_AGENT_PLUGIN_DAEMON_URL=http://localhost:5002
|
||||
# API key sent to the Dify plugin daemon.
|
||||
DIFY_AGENT_PLUGIN_DAEMON_API_KEY=
|
||||
|
||||
# Dify API inner endpoints
|
||||
# Base URL for Dify API inner endpoints used by Agent Stub config/file/drive requests.
|
||||
DIFY_AGENT_INNER_API_URL=http://localhost:5001
|
||||
# Must match API/worker INNER_API_KEY_FOR_PLUGIN, not the generic INNER_API_KEY.
|
||||
DIFY_AGENT_INNER_API_KEY=
|
||||
|
||||
# Shell layer
|
||||
# Base URL for the shellctl server used by the dify.shell layer. Leave empty to disable shell layer use.
|
||||
DIFY_AGENT_SHELLCTL_ENTRYPOINT=
|
||||
@ -36,12 +30,12 @@ DIFY_AGENT_SHELLCTL_AUTH_TOKEN=
|
||||
# Public Agent Stub URL reachable from shellctl-managed remote machines.
|
||||
# Use http(s)://.../agent-stub for HTTP or grpc://host:port for gRPC.
|
||||
# Leave empty to avoid injecting DIFY_AGENT_STUB_* into shell.run jobs.
|
||||
DIFY_AGENT_STUB_API_BASE_URL=http://localhost:5050/agent-stub
|
||||
# Optional bind override used only when DIFY_AGENT_STUB_API_BASE_URL uses grpc://.
|
||||
DIFY_AGENT_STUB_URL=
|
||||
# Optional bind override used only when DIFY_AGENT_STUB_URL uses grpc://.
|
||||
DIFY_AGENT_STUB_GRPC_BIND_ADDRESS=
|
||||
# Server-wide root secret used to derive Agent Stub JWE keys.
|
||||
# Required when DIFY_AGENT_STUB_API_BASE_URL is set; must be unpadded base64url for 32 bytes.
|
||||
DIFY_AGENT_SERVER_SECRET_KEY=replace-with-base64url-32-byte-secret
|
||||
# Required when DIFY_AGENT_STUB_URL is set; must be unpadded base64url for 32 bytes.
|
||||
DIFY_AGENT_SERVER_SECRET_KEY=
|
||||
|
||||
# Shared plugin-daemon HTTP client timeouts and limits.
|
||||
# Plugin-daemon HTTP connect timeout in seconds.
|
||||
|
||||
@ -6,8 +6,8 @@
|
||||
# cd /app/api && .venv/bin/uvicorn dify_agent.server.app:app --host 0.0.0.0 --port 5050
|
||||
#
|
||||
# Unlike the dify-api image (which only installs the base `dify-agent`
|
||||
# dependency), this image installs the `[server]` extra, so jwcrypto, fastapi,
|
||||
# uvicorn, etc. are present and the server can
|
||||
# dependency), this image installs the `[server]` extra, so jwcrypto,
|
||||
# shell-session-manager, fastapi, uvicorn, etc. are present and the server can
|
||||
# actually start. dify-api is intentionally left lean.
|
||||
|
||||
# base image
|
||||
|
||||
@ -12,7 +12,8 @@ FROM python:3.12-slim-bookworm AS base
|
||||
ARG NODE_VERSION=22.22.1
|
||||
ARG PNPM_VERSION=11.9.0
|
||||
ARG UV_VERSION=0.8.9
|
||||
ARG DIFY_AGENT_TOOL_SPEC=.[grpc,shellctl-server]
|
||||
ARG DIFY_AGENT_TOOL_SPEC=.[grpc]
|
||||
ARG SHELL_SESSION_MANAGER_TOOL_SPEC=shell-session-manager==2.4.0
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
@ -24,7 +25,6 @@ ENV PATH="${UV_TOOL_BIN_DIR}:${PATH}"
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
bash \
|
||||
ca-certificates \
|
||||
curl \
|
||||
file \
|
||||
@ -62,6 +62,7 @@ WORKDIR /opt/dify-agent
|
||||
FROM base AS tools
|
||||
|
||||
ARG DIFY_AGENT_TOOL_SPEC
|
||||
ARG SHELL_SESSION_MANAGER_TOOL_SPEC
|
||||
|
||||
COPY pyproject.toml uv.lock README.md ./
|
||||
COPY src ./src
|
||||
@ -70,6 +71,8 @@ RUN uv export --frozen --no-dev --all-extras --no-emit-project --no-hashes \
|
||||
> /tmp/dify-agent-constraints.txt \
|
||||
&& uv tool install --force --python /usr/local/bin/python --no-python-downloads \
|
||||
--constraints /tmp/dify-agent-constraints.txt --link-mode=copy "${DIFY_AGENT_TOOL_SPEC}" \
|
||||
&& uv tool install --force --python /usr/local/bin/python --no-python-downloads \
|
||||
--constraints /tmp/dify-agent-constraints.txt --link-mode=copy "${SHELL_SESSION_MANAGER_TOOL_SPEC}" \
|
||||
&& rm -f /tmp/dify-agent-constraints.txt
|
||||
|
||||
|
||||
|
||||
@ -230,11 +230,12 @@ The provided `docker/local-sandbox/Dockerfile` installs:
|
||||
- `tmux`, required by `shellctl` to manage shell jobs;
|
||||
- common shell workspace tools: `git`, `openssh-client`, `jq`, `ripgrep`,
|
||||
`unzip`, `zip`, `file`, `procps`, and `less`;
|
||||
- `dify-agent[grpc,shellctl-server]` as a standalone uv tool, which provides
|
||||
both the Agent Stub client CLI and the built-in `shellctl` CLI/server;
|
||||
- `shell-session-manager==2.3.1` as a standalone uv tool, which provides the
|
||||
`shellctl` CLI/server;
|
||||
- `uv`, so uv shebang scripts with PEP 723 metadata can run inside the shell
|
||||
workspace and Python CLI tools can be installed with isolated tool
|
||||
environments;
|
||||
- `node==22.22.1` and `pnpm==11.9.0`, so JavaScript and TypeScript tooling can
|
||||
run inside the shell workspace without per-job installation;
|
||||
- the `dify-agent[grpc]` Agent Stub client CLI as a standalone uv tool;
|
||||
- a non-root default user named `dify`.
|
||||
|
||||
@ -5,9 +5,7 @@ description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12,<4.0"
|
||||
dependencies = [
|
||||
"anyio>=4.12.1,<5.0.0",
|
||||
"httpx==0.28.1",
|
||||
"httpx2>=2.5.0,<3.0.0",
|
||||
"pydantic>=2.12.5,<2.13",
|
||||
"pydantic-ai-slim>=1.102.0,<2.0.0",
|
||||
"typer>=0.16.1,<0.17",
|
||||
@ -17,9 +15,6 @@ dependencies = [
|
||||
[project.scripts]
|
||||
dify-agent = "dify_agent.agent_stub.cli.main:main"
|
||||
dify-agent-stub-server = "dify_agent.agent_stub.server.cli:main"
|
||||
shellctl = "shellctl.cli:main"
|
||||
shellctl-sanitize-pty = "shellctl_runtime.sanitize:main"
|
||||
shellctl-runner-exit = "shellctl_runtime.runner_exit:main"
|
||||
|
||||
[project.optional-dependencies]
|
||||
grpc = ["grpclib[protobuf]>=0.4.9,<0.5.0", "protobuf>=6.33.5,<7.0.0"]
|
||||
@ -32,18 +27,13 @@ server = [
|
||||
"pydantic-ai-slim[anthropic,google,openai]>=1.85.1,<2.0.0",
|
||||
"pydantic-settings>=2.12.0,<3.0.0",
|
||||
"redis>=7.4.0,<8.0.0",
|
||||
"uvicorn[standard]==0.46.0",
|
||||
]
|
||||
shellctl-server = [
|
||||
"aiosqlite>=0.21.0,<1.0.0",
|
||||
"fastapi==0.136.0",
|
||||
"sqlmodel>=0.0.24,<0.1.0",
|
||||
"shell-session-manager==2.4.0",
|
||||
"uvicorn[standard]==0.46.0",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
include = ["agenton*", "agenton_collections*", "dify_agent*", "shellctl*", "shellctl_runtime*"]
|
||||
include = ["agenton*", "agenton_collections*", "dify_agent*"]
|
||||
|
||||
[tool.pyright]
|
||||
include = ["src", "examples", "tests"]
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"""Shellctl-backed shell provider adapter for dify-agent.
|
||||
|
||||
The built-in shellctl SDK owns the HTTP timeout policy for long-polling
|
||||
The shell-session-manager SDK owns the HTTP timeout policy for long-polling
|
||||
shellctl requests. This adapter stays narrowly focused on translating SDK and
|
||||
transport failures into ``ShellProviderError`` so the shell layer can return
|
||||
tool observations instead of aborting the agent loop.
|
||||
@ -16,9 +16,9 @@ import time
|
||||
from collections.abc import Awaitable
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol, TypeVar, cast
|
||||
from typing import Protocol, TypeVar
|
||||
|
||||
import httpx2 as httpx
|
||||
import httpx
|
||||
|
||||
from dify_agent.adapters.shell.protocols import (
|
||||
ShellCommandProtocol,
|
||||
@ -228,16 +228,8 @@ class ShellctlFileTransfer(ShellFileTransferProtocol):
|
||||
@dataclass(slots=True)
|
||||
class ShellctlResource(ShellResourceProtocol):
|
||||
client: ShellctlClientProtocol
|
||||
_commands: ShellCommandProtocol
|
||||
_files: ShellFileTransferProtocol
|
||||
|
||||
@property
|
||||
def commands(self) -> ShellCommandProtocol:
|
||||
return self._commands
|
||||
|
||||
@property
|
||||
def files(self) -> ShellFileTransferProtocol:
|
||||
return self._files
|
||||
commands: ShellCommandProtocol
|
||||
files: ShellFileTransferProtocol
|
||||
|
||||
async def close(self) -> None:
|
||||
try:
|
||||
@ -265,8 +257,8 @@ class ShellctlProvider(ShellProviderProtocol):
|
||||
)
|
||||
return ShellctlResource(
|
||||
client=client,
|
||||
_commands=ShellctlCommands(client=client),
|
||||
_files=ShellctlFileTransfer(client=client),
|
||||
commands=ShellctlCommands(client=client),
|
||||
files=ShellctlFileTransfer(client=client),
|
||||
)
|
||||
|
||||
|
||||
@ -277,15 +269,9 @@ def create_default_shellctl_client_factory(
|
||||
output_limit: int = _SHELLCTL_OUTPUT_LIMIT_BYTES,
|
||||
) -> ShellctlClientFactory:
|
||||
def factory() -> ShellctlClientProtocol:
|
||||
from shellctl.client import ShellctlClient
|
||||
from shell_session_manager.shellctl.client import ShellctlClient
|
||||
|
||||
return cast(
|
||||
ShellctlClientProtocol,
|
||||
cast(
|
||||
object,
|
||||
ShellctlClient(entrypoint, token=token, output_limit=output_limit),
|
||||
),
|
||||
)
|
||||
return ShellctlClient(entrypoint, token=token, output_limit=output_limit)
|
||||
|
||||
return factory
|
||||
|
||||
|
||||
@ -1,124 +0,0 @@
|
||||
"""Public shellctl package exports.
|
||||
|
||||
This package stays lazy on purpose. Hot-path runtime helpers live outside the
|
||||
`shellctl` package, and importing this package root should
|
||||
not pull the full client/server/public DTO surface unless a caller explicitly
|
||||
asks for those exports.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import import_module
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from shellctl.client import (
|
||||
ShellctlClient,
|
||||
ShellctlClientError,
|
||||
)
|
||||
from shellctl.shared import (
|
||||
DEFAULT_AUTH_TOKEN_ENV,
|
||||
DEFAULT_BASE_URL,
|
||||
DEFAULT_BASE_URL_ENV,
|
||||
DEFAULT_GC_FINISHED_JOB_RETENTION_SECONDS,
|
||||
DEFAULT_GC_INTERVAL_SECONDS,
|
||||
DEFAULT_IDLE_FLUSH_SECONDS,
|
||||
DEFAULT_LIST_LIMIT,
|
||||
DEFAULT_OUTPUT_LIMIT_BYTES,
|
||||
DEFAULT_TERMINAL_COLS,
|
||||
DEFAULT_TERMINAL_ROWS,
|
||||
DEFAULT_TERMINATE_GRACE_SECONDS,
|
||||
DEFAULT_TIMEOUT_SECONDS,
|
||||
DeleteJobResponse,
|
||||
HealthResponse,
|
||||
InputJobRequest,
|
||||
JobInfo,
|
||||
JobResult,
|
||||
JobStatusName,
|
||||
JobStatusView,
|
||||
ListJobsResponse,
|
||||
RunJobRequest,
|
||||
TerminalSize,
|
||||
TerminateJobRequest,
|
||||
WaitJobRequest,
|
||||
generate_job_id,
|
||||
read_output_window,
|
||||
tail_output_window,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_AUTH_TOKEN_ENV",
|
||||
"DEFAULT_BASE_URL",
|
||||
"DEFAULT_BASE_URL_ENV",
|
||||
"DEFAULT_GC_FINISHED_JOB_RETENTION_SECONDS",
|
||||
"DEFAULT_GC_INTERVAL_SECONDS",
|
||||
"DEFAULT_IDLE_FLUSH_SECONDS",
|
||||
"DEFAULT_LIST_LIMIT",
|
||||
"DEFAULT_OUTPUT_LIMIT_BYTES",
|
||||
"DEFAULT_TERMINAL_COLS",
|
||||
"DEFAULT_TERMINAL_ROWS",
|
||||
"DEFAULT_TERMINATE_GRACE_SECONDS",
|
||||
"DEFAULT_TIMEOUT_SECONDS",
|
||||
"DeleteJobResponse",
|
||||
"HealthResponse",
|
||||
"InputJobRequest",
|
||||
"JobInfo",
|
||||
"JobResult",
|
||||
"JobStatusName",
|
||||
"JobStatusView",
|
||||
"ListJobsResponse",
|
||||
"RunJobRequest",
|
||||
"ShellctlClient",
|
||||
"ShellctlClientError",
|
||||
"TerminalSize",
|
||||
"TerminateJobRequest",
|
||||
"WaitJobRequest",
|
||||
"generate_job_id",
|
||||
"read_output_window",
|
||||
"tail_output_window",
|
||||
]
|
||||
|
||||
_EXPORTS = {
|
||||
"ShellctlClient": "shellctl.client",
|
||||
"ShellctlClientError": "shellctl.client",
|
||||
"DEFAULT_AUTH_TOKEN_ENV": "shellctl.shared",
|
||||
"DEFAULT_BASE_URL": "shellctl.shared",
|
||||
"DEFAULT_BASE_URL_ENV": "shellctl.shared",
|
||||
"DEFAULT_GC_FINISHED_JOB_RETENTION_SECONDS": "shellctl.shared",
|
||||
"DEFAULT_GC_INTERVAL_SECONDS": "shellctl.shared",
|
||||
"DEFAULT_IDLE_FLUSH_SECONDS": "shellctl.shared",
|
||||
"DEFAULT_LIST_LIMIT": "shellctl.shared",
|
||||
"DEFAULT_OUTPUT_LIMIT_BYTES": "shellctl.shared",
|
||||
"DEFAULT_TERMINAL_COLS": "shellctl.shared",
|
||||
"DEFAULT_TERMINAL_ROWS": "shellctl.shared",
|
||||
"DEFAULT_TERMINATE_GRACE_SECONDS": "shellctl.shared",
|
||||
"DEFAULT_TIMEOUT_SECONDS": "shellctl.shared",
|
||||
"DeleteJobResponse": "shellctl.shared",
|
||||
"HealthResponse": "shellctl.shared",
|
||||
"InputJobRequest": "shellctl.shared",
|
||||
"JobInfo": "shellctl.shared",
|
||||
"JobResult": "shellctl.shared",
|
||||
"JobStatusName": "shellctl.shared",
|
||||
"JobStatusView": "shellctl.shared",
|
||||
"ListJobsResponse": "shellctl.shared",
|
||||
"RunJobRequest": "shellctl.shared",
|
||||
"TerminalSize": "shellctl.shared",
|
||||
"TerminateJobRequest": "shellctl.shared",
|
||||
"WaitJobRequest": "shellctl.shared",
|
||||
"generate_job_id": "shellctl.shared",
|
||||
"read_output_window": "shellctl.shared",
|
||||
"tail_output_window": "shellctl.shared",
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
if name not in _EXPORTS:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
module = import_module(_EXPORTS[name])
|
||||
value = getattr(module, name)
|
||||
globals()[name] = value
|
||||
return value
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return sorted(set(globals()) | set(__all__))
|
||||
@ -1,589 +0,0 @@
|
||||
"""Typer CLI for network-backed shellctl commands.
|
||||
|
||||
Job-management commands in this module intentionally stay on the SDK side of
|
||||
the boundary: they parse CLI options, call `ShellctlClient`, and render compact
|
||||
JSON. That keeps `shellctl --help` and `shellctl run --help` free of FastAPI,
|
||||
SQLAlchemy, tmux, and local runtime bootstrap imports.
|
||||
|
||||
Only `serve` lazily imports server-side modules when that subcommand is
|
||||
actually invoked.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Awaitable, Callable
|
||||
from pathlib import Path
|
||||
from typing import NoReturn
|
||||
|
||||
import anyio
|
||||
import httpx2 as httpx
|
||||
import typer
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from shellctl.client import ShellctlClient, ShellctlClientError
|
||||
from shellctl.shared.constants import (
|
||||
DEFAULT_AUTH_TOKEN_ENV,
|
||||
DEFAULT_BASE_URL,
|
||||
DEFAULT_BASE_URL_ENV,
|
||||
DEFAULT_GC_FINISHED_JOB_RETENTION_SECONDS,
|
||||
DEFAULT_GC_INTERVAL_SECONDS,
|
||||
DEFAULT_IDLE_FLUSH_SECONDS,
|
||||
DEFAULT_LIST_LIMIT,
|
||||
DEFAULT_OUTPUT_LIMIT_BYTES,
|
||||
DEFAULT_TERMINAL_COLS,
|
||||
DEFAULT_TERMINAL_ROWS,
|
||||
DEFAULT_TERMINATE_GRACE_SECONDS,
|
||||
DEFAULT_TIMEOUT_SECONDS,
|
||||
MAX_LIST_LIMIT,
|
||||
MAX_OUTPUT_LIMIT_BYTES,
|
||||
)
|
||||
from shellctl.shared.schemas import (
|
||||
DeleteJobResponse,
|
||||
HealthResponse,
|
||||
JobInfo,
|
||||
JobResult,
|
||||
JobStatusName,
|
||||
JobStatusView,
|
||||
RunJobRequest,
|
||||
TerminalSize,
|
||||
)
|
||||
|
||||
cli = typer.Typer(
|
||||
no_args_is_help=True,
|
||||
pretty_exceptions_enable=False,
|
||||
rich_markup_mode=None,
|
||||
)
|
||||
|
||||
|
||||
@cli.command("health")
|
||||
def health_command(
|
||||
base_url: str = typer.Option(
|
||||
DEFAULT_BASE_URL,
|
||||
"--base-url",
|
||||
envvar=DEFAULT_BASE_URL_ENV,
|
||||
help="shellctl server base URL. You can also set SHELLCTL_BASE_URL.",
|
||||
),
|
||||
auth_token: str | None = typer.Option(
|
||||
None,
|
||||
"--auth-token",
|
||||
envvar=DEFAULT_AUTH_TOKEN_ENV,
|
||||
help="Accepted for CLI consistency but ignored because /healthz is public.",
|
||||
),
|
||||
) -> None:
|
||||
"""Call the public health endpoint and report JSON."""
|
||||
|
||||
del auth_token
|
||||
|
||||
async def action(client: ShellctlClient) -> HealthResponse:
|
||||
return await client.health()
|
||||
|
||||
_run_client_action(
|
||||
base_url=base_url,
|
||||
auth_token=None,
|
||||
action=action,
|
||||
emit=_emit_model,
|
||||
)
|
||||
|
||||
|
||||
@cli.command("run")
|
||||
def run_command(
|
||||
script: str = typer.Argument(...),
|
||||
base_url: str = typer.Option(
|
||||
DEFAULT_BASE_URL,
|
||||
"--base-url",
|
||||
envvar=DEFAULT_BASE_URL_ENV,
|
||||
help="shellctl server base URL. You can also set SHELLCTL_BASE_URL.",
|
||||
),
|
||||
auth_token: str | None = typer.Option(
|
||||
None,
|
||||
"--auth-token",
|
||||
envvar=DEFAULT_AUTH_TOKEN_ENV,
|
||||
help=(
|
||||
"Bearer token value. You can also set SHELLCTL_AUTH_TOKEN. "
|
||||
"Leave it unset or empty when the server does not require auth."
|
||||
),
|
||||
),
|
||||
cwd: Path | None = typer.Option(None, "--cwd"),
|
||||
env: list[str] | None = typer.Option(None, "--env"),
|
||||
timeout: float = typer.Option(DEFAULT_TIMEOUT_SECONDS, "--timeout"),
|
||||
output_limit: int = typer.Option(DEFAULT_OUTPUT_LIMIT_BYTES, "--output-limit"),
|
||||
idle_flush_seconds: float = typer.Option(
|
||||
DEFAULT_IDLE_FLUSH_SECONDS,
|
||||
"--idle-flush-seconds",
|
||||
),
|
||||
cols: int | None = typer.Option(None, "--cols"),
|
||||
rows: int | None = typer.Option(None, "--rows"),
|
||||
) -> None:
|
||||
"""Create a job through the running shellctl server."""
|
||||
|
||||
request = _build_model(
|
||||
RunJobRequest,
|
||||
script=script,
|
||||
cwd=str(cwd) if cwd is not None else None,
|
||||
env=_parse_env(env),
|
||||
terminal=_terminal_size(cols=cols, rows=rows),
|
||||
timeout=timeout,
|
||||
output_limit=output_limit,
|
||||
idle_flush_seconds=idle_flush_seconds,
|
||||
)
|
||||
|
||||
async def action(client: ShellctlClient) -> JobResult:
|
||||
return await client.run(
|
||||
request.script,
|
||||
cwd=request.cwd,
|
||||
env=request.env,
|
||||
timeout=request.timeout,
|
||||
terminal=request.terminal,
|
||||
)
|
||||
|
||||
_run_client_action(
|
||||
base_url=base_url,
|
||||
auth_token=auth_token,
|
||||
output_limit=output_limit,
|
||||
idle_flush_seconds=idle_flush_seconds,
|
||||
action=action,
|
||||
emit=_emit_model,
|
||||
)
|
||||
|
||||
|
||||
@cli.command("wait")
|
||||
def wait_command(
|
||||
job_id: str = typer.Argument(...),
|
||||
base_url: str = typer.Option(
|
||||
DEFAULT_BASE_URL,
|
||||
"--base-url",
|
||||
envvar=DEFAULT_BASE_URL_ENV,
|
||||
help="shellctl server base URL. You can also set SHELLCTL_BASE_URL.",
|
||||
),
|
||||
auth_token: str | None = typer.Option(
|
||||
None,
|
||||
"--auth-token",
|
||||
envvar=DEFAULT_AUTH_TOKEN_ENV,
|
||||
help=(
|
||||
"Bearer token value. You can also set SHELLCTL_AUTH_TOKEN. "
|
||||
"Leave it unset or empty when the server does not require auth."
|
||||
),
|
||||
),
|
||||
offset: int = typer.Option(..., "--offset"),
|
||||
timeout: float = typer.Option(DEFAULT_TIMEOUT_SECONDS, "--timeout"),
|
||||
output_limit: int = typer.Option(DEFAULT_OUTPUT_LIMIT_BYTES, "--output-limit"),
|
||||
idle_flush_seconds: float = typer.Option(
|
||||
DEFAULT_IDLE_FLUSH_SECONDS,
|
||||
"--idle-flush-seconds",
|
||||
),
|
||||
) -> None:
|
||||
"""Wait for incremental output, completion, truncation, or timeout."""
|
||||
|
||||
async def action(client: ShellctlClient) -> JobResult:
|
||||
return await client.wait(job_id, offset=offset, timeout=timeout)
|
||||
|
||||
_run_client_action(
|
||||
base_url=base_url,
|
||||
auth_token=auth_token,
|
||||
output_limit=output_limit,
|
||||
idle_flush_seconds=idle_flush_seconds,
|
||||
action=action,
|
||||
emit=_emit_model,
|
||||
)
|
||||
|
||||
|
||||
@cli.command("status")
|
||||
def status_command(
|
||||
job_id: str = typer.Argument(...),
|
||||
base_url: str = typer.Option(
|
||||
DEFAULT_BASE_URL,
|
||||
"--base-url",
|
||||
envvar=DEFAULT_BASE_URL_ENV,
|
||||
help="shellctl server base URL. You can also set SHELLCTL_BASE_URL.",
|
||||
),
|
||||
auth_token: str | None = typer.Option(
|
||||
None,
|
||||
"--auth-token",
|
||||
envvar=DEFAULT_AUTH_TOKEN_ENV,
|
||||
help=(
|
||||
"Bearer token value. You can also set SHELLCTL_AUTH_TOKEN. "
|
||||
"Leave it unset or empty when the server does not require auth."
|
||||
),
|
||||
),
|
||||
) -> None:
|
||||
"""Materialize the current status view for one job."""
|
||||
|
||||
async def action(client: ShellctlClient) -> JobStatusView:
|
||||
return await client.status(job_id)
|
||||
|
||||
_run_client_action(
|
||||
base_url=base_url,
|
||||
auth_token=auth_token,
|
||||
action=action,
|
||||
emit=_emit_model,
|
||||
)
|
||||
|
||||
|
||||
@cli.command("list")
|
||||
def list_command(
|
||||
base_url: str = typer.Option(
|
||||
DEFAULT_BASE_URL,
|
||||
"--base-url",
|
||||
envvar=DEFAULT_BASE_URL_ENV,
|
||||
help="shellctl server base URL. You can also set SHELLCTL_BASE_URL.",
|
||||
),
|
||||
auth_token: str | None = typer.Option(
|
||||
None,
|
||||
"--auth-token",
|
||||
envvar=DEFAULT_AUTH_TOKEN_ENV,
|
||||
help=(
|
||||
"Bearer token value. You can also set SHELLCTL_AUTH_TOKEN. "
|
||||
"Leave it unset or empty when the server does not require auth."
|
||||
),
|
||||
),
|
||||
status: JobStatusName | None = typer.Option(None, "--status"),
|
||||
limit: int = typer.Option(DEFAULT_LIST_LIMIT, "--limit", min=1, max=MAX_LIST_LIMIT),
|
||||
) -> None:
|
||||
"""List recent jobs, optionally filtered by lifecycle status."""
|
||||
|
||||
async def action(client: ShellctlClient) -> list[JobInfo]:
|
||||
return await client.list_jobs(status=status, limit=limit)
|
||||
|
||||
_run_client_action(
|
||||
base_url=base_url,
|
||||
auth_token=auth_token,
|
||||
action=action,
|
||||
emit=_emit_job_list,
|
||||
)
|
||||
|
||||
|
||||
@cli.command("input")
|
||||
def input_command(
|
||||
job_id: str = typer.Argument(...),
|
||||
text: str = typer.Argument(...),
|
||||
base_url: str = typer.Option(
|
||||
DEFAULT_BASE_URL,
|
||||
"--base-url",
|
||||
envvar=DEFAULT_BASE_URL_ENV,
|
||||
help="shellctl server base URL. You can also set SHELLCTL_BASE_URL.",
|
||||
),
|
||||
auth_token: str | None = typer.Option(
|
||||
None,
|
||||
"--auth-token",
|
||||
envvar=DEFAULT_AUTH_TOKEN_ENV,
|
||||
help=(
|
||||
"Bearer token value. You can also set SHELLCTL_AUTH_TOKEN. "
|
||||
"Leave it unset or empty when the server does not require auth."
|
||||
),
|
||||
),
|
||||
offset: int = typer.Option(..., "--offset"),
|
||||
timeout: float = typer.Option(DEFAULT_TIMEOUT_SECONDS, "--timeout"),
|
||||
output_limit: int = typer.Option(DEFAULT_OUTPUT_LIMIT_BYTES, "--output-limit"),
|
||||
idle_flush_seconds: float = typer.Option(
|
||||
DEFAULT_IDLE_FLUSH_SECONDS,
|
||||
"--idle-flush-seconds",
|
||||
),
|
||||
) -> None:
|
||||
"""Send text input to a running job and wait for the next result window."""
|
||||
|
||||
async def action(client: ShellctlClient) -> JobResult:
|
||||
return await client.input(job_id, text, offset=offset, timeout=timeout)
|
||||
|
||||
_run_client_action(
|
||||
base_url=base_url,
|
||||
auth_token=auth_token,
|
||||
output_limit=output_limit,
|
||||
idle_flush_seconds=idle_flush_seconds,
|
||||
action=action,
|
||||
emit=_emit_model,
|
||||
)
|
||||
|
||||
|
||||
@cli.command("tail")
|
||||
def tail_command(
|
||||
job_id: str = typer.Argument(...),
|
||||
base_url: str = typer.Option(
|
||||
DEFAULT_BASE_URL,
|
||||
"--base-url",
|
||||
envvar=DEFAULT_BASE_URL_ENV,
|
||||
help="shellctl server base URL. You can also set SHELLCTL_BASE_URL.",
|
||||
),
|
||||
auth_token: str | None = typer.Option(
|
||||
None,
|
||||
"--auth-token",
|
||||
envvar=DEFAULT_AUTH_TOKEN_ENV,
|
||||
help=(
|
||||
"Bearer token value. You can also set SHELLCTL_AUTH_TOKEN. "
|
||||
"Leave it unset or empty when the server does not require auth."
|
||||
),
|
||||
),
|
||||
output_limit: int = typer.Option(
|
||||
DEFAULT_OUTPUT_LIMIT_BYTES,
|
||||
"--output-limit",
|
||||
min=1,
|
||||
max=MAX_OUTPUT_LIMIT_BYTES,
|
||||
),
|
||||
) -> None:
|
||||
"""Read a UTF-8-safe output tail for one job."""
|
||||
|
||||
async def action(client: ShellctlClient) -> JobResult:
|
||||
return await client.tail(job_id)
|
||||
|
||||
_run_client_action(
|
||||
base_url=base_url,
|
||||
auth_token=auth_token,
|
||||
output_limit=output_limit,
|
||||
action=action,
|
||||
emit=_emit_model,
|
||||
)
|
||||
|
||||
|
||||
@cli.command("terminate")
|
||||
def terminate_command(
|
||||
job_id: str = typer.Argument(...),
|
||||
base_url: str = typer.Option(
|
||||
DEFAULT_BASE_URL,
|
||||
"--base-url",
|
||||
envvar=DEFAULT_BASE_URL_ENV,
|
||||
help="shellctl server base URL. You can also set SHELLCTL_BASE_URL.",
|
||||
),
|
||||
auth_token: str | None = typer.Option(
|
||||
None,
|
||||
"--auth-token",
|
||||
envvar=DEFAULT_AUTH_TOKEN_ENV,
|
||||
help=(
|
||||
"Bearer token value. You can also set SHELLCTL_AUTH_TOKEN. "
|
||||
"Leave it unset or empty when the server does not require auth."
|
||||
),
|
||||
),
|
||||
grace_seconds: float = typer.Option(
|
||||
DEFAULT_TERMINATE_GRACE_SECONDS,
|
||||
"--grace-seconds",
|
||||
),
|
||||
) -> None:
|
||||
"""Terminate a job and return its materialized status."""
|
||||
|
||||
async def action(client: ShellctlClient) -> JobStatusView:
|
||||
return await client.terminate(job_id, grace_seconds=grace_seconds)
|
||||
|
||||
_run_client_action(
|
||||
base_url=base_url,
|
||||
auth_token=auth_token,
|
||||
action=action,
|
||||
emit=_emit_model,
|
||||
)
|
||||
|
||||
|
||||
@cli.command("delete")
|
||||
def delete_command(
|
||||
job_id: str = typer.Argument(...),
|
||||
base_url: str = typer.Option(
|
||||
DEFAULT_BASE_URL,
|
||||
"--base-url",
|
||||
envvar=DEFAULT_BASE_URL_ENV,
|
||||
help="shellctl server base URL. You can also set SHELLCTL_BASE_URL.",
|
||||
),
|
||||
auth_token: str | None = typer.Option(
|
||||
None,
|
||||
"--auth-token",
|
||||
envvar=DEFAULT_AUTH_TOKEN_ENV,
|
||||
help=(
|
||||
"Bearer token value. You can also set SHELLCTL_AUTH_TOKEN. "
|
||||
"Leave it unset or empty when the server does not require auth."
|
||||
),
|
||||
),
|
||||
force: bool = typer.Option(False, "--force"),
|
||||
grace_seconds: float = typer.Option(
|
||||
DEFAULT_TERMINATE_GRACE_SECONDS,
|
||||
"--grace-seconds",
|
||||
),
|
||||
) -> None:
|
||||
"""Delete a job row and artifacts, optionally terminating first."""
|
||||
|
||||
async def action(client: ShellctlClient) -> DeleteJobResponse:
|
||||
return await client.delete(
|
||||
job_id,
|
||||
force=force,
|
||||
grace_seconds=grace_seconds,
|
||||
)
|
||||
|
||||
_run_client_action(
|
||||
base_url=base_url,
|
||||
auth_token=auth_token,
|
||||
action=action,
|
||||
emit=_emit_model,
|
||||
)
|
||||
|
||||
|
||||
@cli.command("serve")
|
||||
def serve_command(
|
||||
listen: str = "127.0.0.1:8765",
|
||||
auth_token: str | None = typer.Option(
|
||||
None,
|
||||
"--auth-token",
|
||||
envvar=DEFAULT_AUTH_TOKEN_ENV,
|
||||
help=(
|
||||
"Bearer token value. You can also set SHELLCTL_AUTH_TOKEN. "
|
||||
"Leave it unset or empty to disable HTTP bearer auth."
|
||||
),
|
||||
),
|
||||
state_dir: Path | None = None,
|
||||
runtime_dir: Path | None = None,
|
||||
gc_interval_seconds: float = typer.Option(
|
||||
DEFAULT_GC_INTERVAL_SECONDS,
|
||||
"--gc-interval-seconds",
|
||||
),
|
||||
gc_finished_job_retention_seconds: float = typer.Option(
|
||||
DEFAULT_GC_FINISHED_JOB_RETENTION_SECONDS,
|
||||
"--gc-finished-job-retention-seconds",
|
||||
),
|
||||
) -> None:
|
||||
"""Run the local shellctl FastAPI server via uvicorn."""
|
||||
|
||||
from shellctl.server.serve import (
|
||||
serve_command as server_serve_command,
|
||||
)
|
||||
|
||||
server_serve_command(
|
||||
listen=listen,
|
||||
auth_token=auth_token,
|
||||
state_dir=state_dir,
|
||||
runtime_dir=runtime_dir,
|
||||
gc_interval_seconds=gc_interval_seconds,
|
||||
gc_finished_job_retention_seconds=gc_finished_job_retention_seconds,
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""CLI entrypoint used by the console script and `python -m` invocations."""
|
||||
|
||||
cli()
|
||||
|
||||
|
||||
def _parse_env(values: list[str] | None) -> dict[str, str] | None:
|
||||
if not values:
|
||||
return None
|
||||
|
||||
parsed: dict[str, str] = {}
|
||||
for value in values:
|
||||
if "=" not in value:
|
||||
raise typer.BadParameter(
|
||||
"env entries must use NAME=VALUE format",
|
||||
param_hint="--env",
|
||||
)
|
||||
name, env_value = value.split("=", 1)
|
||||
if not name:
|
||||
raise typer.BadParameter(
|
||||
"env names must be non-empty",
|
||||
param_hint="--env",
|
||||
)
|
||||
parsed[name] = env_value
|
||||
return parsed
|
||||
|
||||
|
||||
def _terminal_size(*, cols: int | None, rows: int | None) -> TerminalSize | None:
|
||||
if cols is None and rows is None:
|
||||
return None
|
||||
return _build_model(
|
||||
TerminalSize,
|
||||
cols=cols if cols is not None else DEFAULT_TERMINAL_COLS,
|
||||
rows=rows if rows is not None else DEFAULT_TERMINAL_ROWS,
|
||||
)
|
||||
|
||||
|
||||
def _build_model[ModelT: BaseModel](
|
||||
model_type: type[ModelT], /, **data: object
|
||||
) -> ModelT:
|
||||
try:
|
||||
return model_type(**data)
|
||||
except ValidationError as exc:
|
||||
raise typer.BadParameter(_validation_error_message(exc)) from exc
|
||||
|
||||
|
||||
async def _with_client[ResponseT](
|
||||
base_url: str,
|
||||
auth_token: str | None,
|
||||
output_limit: int,
|
||||
idle_flush_seconds: float,
|
||||
action: Callable[[ShellctlClient], Awaitable[ResponseT]],
|
||||
) -> ResponseT:
|
||||
async with ShellctlClient(
|
||||
base_url,
|
||||
output_limit=output_limit,
|
||||
idle_flush_seconds=idle_flush_seconds,
|
||||
token=auth_token,
|
||||
) as client:
|
||||
return await action(client)
|
||||
|
||||
|
||||
def _run_client_action[ResponseT](
|
||||
*,
|
||||
base_url: str,
|
||||
auth_token: str | None,
|
||||
action: Callable[[ShellctlClient], Awaitable[ResponseT]],
|
||||
emit: Callable[[ResponseT], None],
|
||||
output_limit: int = DEFAULT_OUTPUT_LIMIT_BYTES,
|
||||
idle_flush_seconds: float = DEFAULT_IDLE_FLUSH_SECONDS,
|
||||
) -> None:
|
||||
try:
|
||||
payload = anyio.run(
|
||||
_with_client,
|
||||
base_url,
|
||||
auth_token,
|
||||
output_limit,
|
||||
idle_flush_seconds,
|
||||
action,
|
||||
)
|
||||
except ShellctlClientError as exc:
|
||||
_emit_error_and_exit(exc.code, exc.message)
|
||||
except httpx.TimeoutException:
|
||||
_emit_error_and_exit("request_timeout", "request timed out")
|
||||
except httpx.TransportError as exc:
|
||||
_emit_error_and_exit("connection_error", str(exc))
|
||||
|
||||
emit(payload)
|
||||
|
||||
|
||||
def _emit_model(model: BaseModel) -> None:
|
||||
typer.echo(model.model_dump_json(exclude_none=True), color=False)
|
||||
|
||||
|
||||
def _emit_job_list(jobs: list[JobInfo]) -> None:
|
||||
typer.echo(
|
||||
json.dumps(
|
||||
[item.model_dump(mode="json", exclude_none=True) for item in jobs],
|
||||
separators=(",", ":"),
|
||||
),
|
||||
color=False,
|
||||
)
|
||||
|
||||
|
||||
def _emit_error_and_exit(code: str, message: str) -> NoReturn:
|
||||
typer.echo(
|
||||
json.dumps(
|
||||
{"error": {"code": code, "message": message}},
|
||||
separators=(",", ":"),
|
||||
),
|
||||
err=True,
|
||||
color=False,
|
||||
)
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
|
||||
def _validation_error_message(exc: ValidationError) -> str:
|
||||
detail = exc.errors(include_url=False)[0]
|
||||
location = ".".join(str(part) for part in detail.get("loc", ()))
|
||||
message = detail["msg"]
|
||||
return f"{location}: {message}" if location else str(message)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"cli",
|
||||
"delete_command",
|
||||
"health_command",
|
||||
"input_command",
|
||||
"list_command",
|
||||
"main",
|
||||
"run_command",
|
||||
"serve_command",
|
||||
"status_command",
|
||||
"tail_command",
|
||||
"terminate_command",
|
||||
"wait_command",
|
||||
]
|
||||
@ -1,12 +0,0 @@
|
||||
"""Async HTTP client package for shellctl.
|
||||
|
||||
`shellctl.client` remains importable as before, but it is
|
||||
now a package so future client helpers can live beside the main SDK class.
|
||||
"""
|
||||
|
||||
from shellctl.client.sdk import (
|
||||
ShellctlClient,
|
||||
ShellctlClientError,
|
||||
)
|
||||
|
||||
__all__ = ["ShellctlClient", "ShellctlClientError"]
|
||||
@ -1,331 +0,0 @@
|
||||
"""Async HTTP client for the shellctl server API.
|
||||
|
||||
The SDK keeps transport-level knobs (`output_limit`, `idle_flush_seconds`, base
|
||||
URL selection, and bearer-token handling) on the client instance so individual
|
||||
method calls stay close to the network CLI's high-level workflow. Blocking shell
|
||||
operations reuse the shared client, but they override the HTTP read timeout per
|
||||
request so the transport does not fail before the server-side shell wait timeout
|
||||
or terminate-grace budget does.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import httpx2 as httpx
|
||||
|
||||
from shellctl.shared.constants import (
|
||||
DEFAULT_AUTH_TOKEN_ENV,
|
||||
DEFAULT_BASE_URL,
|
||||
DEFAULT_IDLE_FLUSH_SECONDS,
|
||||
DEFAULT_LIST_LIMIT,
|
||||
DEFAULT_OUTPUT_LIMIT_BYTES,
|
||||
DEFAULT_TERMINATE_GRACE_SECONDS,
|
||||
DEFAULT_TIMEOUT_SECONDS,
|
||||
)
|
||||
from shellctl.shared.schemas import (
|
||||
DeleteJobResponse,
|
||||
HealthResponse,
|
||||
JobInfo,
|
||||
JobResult,
|
||||
JobStatusView,
|
||||
ListJobsResponse,
|
||||
RunJobRequest,
|
||||
TerminalSize,
|
||||
)
|
||||
|
||||
|
||||
class ShellctlClientError(RuntimeError):
|
||||
"""Raised for API-declared failures and response decode/shape problems.
|
||||
|
||||
`ShellctlClient` raises this error when the server returns a structured
|
||||
error payload, and also when an otherwise successful HTTP response contains
|
||||
invalid JSON or a top-level payload shape that does not match the SDK
|
||||
contract. Transport and timeout failures remain raw `httpx2` exceptions so
|
||||
library callers can decide how to handle network-layer failures.
|
||||
"""
|
||||
|
||||
def __init__(self, status_code: int, code: str, message: str) -> None:
|
||||
super().__init__(f"{code} ({status_code}): {message}")
|
||||
self.status_code = status_code
|
||||
self.code = code
|
||||
self.message = message
|
||||
|
||||
|
||||
class ShellctlClient:
|
||||
"""Thin async SDK for the shellctl HTTP API.
|
||||
|
||||
The client owns a reusable `httpx.AsyncClient` unless one is injected via the
|
||||
`client` argument. Callers can therefore either keep one instance for a full
|
||||
workflow or treat it as an async context manager. Injected clients keep their
|
||||
original lifecycle; `close()` only closes clients that this SDK created.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str = DEFAULT_BASE_URL,
|
||||
*,
|
||||
output_limit: int = DEFAULT_OUTPUT_LIMIT_BYTES,
|
||||
idle_flush_seconds: float = DEFAULT_IDLE_FLUSH_SECONDS,
|
||||
token: str | None = None,
|
||||
client: httpx.AsyncClient | None = None,
|
||||
transport: httpx.AsyncBaseTransport | None = None,
|
||||
request_timeout_grace_seconds: float = 10.0,
|
||||
) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.output_limit = output_limit
|
||||
self.idle_flush_seconds = idle_flush_seconds
|
||||
self.request_timeout_grace_seconds = request_timeout_grace_seconds
|
||||
self.token = (
|
||||
token if token is not None else os.environ.get(DEFAULT_AUTH_TOKEN_ENV)
|
||||
)
|
||||
self._owns_client = client is None
|
||||
self._client = client or httpx.AsyncClient(
|
||||
base_url=self.base_url,
|
||||
follow_redirects=True,
|
||||
timeout=httpx.Timeout(
|
||||
DEFAULT_TIMEOUT_SECONDS, connect=DEFAULT_TIMEOUT_SECONDS
|
||||
),
|
||||
transport=transport,
|
||||
)
|
||||
|
||||
async def __aenter__(self) -> ShellctlClient:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None:
|
||||
await self.close()
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the underlying HTTP client if this SDK instance owns it."""
|
||||
|
||||
if self._owns_client:
|
||||
await self._client.aclose()
|
||||
|
||||
def _wait_request_timeout(self, timeout: float) -> httpx.Timeout:
|
||||
"""Return a request timeout for blocking shell calls.
|
||||
|
||||
The shellctl server enforces the payload timeout, while the SDK keeps a
|
||||
small HTTP read-timeout grace so the transport can wait slightly longer
|
||||
for that response without loosening connect/write/pool timeouts.
|
||||
"""
|
||||
|
||||
return httpx.Timeout(
|
||||
connect=DEFAULT_TIMEOUT_SECONDS,
|
||||
read=timeout + self.request_timeout_grace_seconds,
|
||||
write=DEFAULT_TIMEOUT_SECONDS,
|
||||
pool=DEFAULT_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
def _terminate_request_timeout(
|
||||
self, grace_seconds: float | None = None
|
||||
) -> httpx.Timeout:
|
||||
"""Return a request timeout for terminate-style calls.
|
||||
|
||||
`terminate()` and forced `delete()` block until the server finishes the
|
||||
terminate grace window, so their HTTP read timeout must cover that
|
||||
business wait budget even when the request relies on the API default.
|
||||
"""
|
||||
|
||||
effective_grace_seconds = (
|
||||
DEFAULT_TERMINATE_GRACE_SECONDS if grace_seconds is None else grace_seconds
|
||||
)
|
||||
return self._wait_request_timeout(effective_grace_seconds)
|
||||
|
||||
async def health(self) -> HealthResponse:
|
||||
"""Call the public health endpoint and decode it as `HealthResponse`."""
|
||||
|
||||
return HealthResponse.model_validate(await self.healthz())
|
||||
|
||||
async def healthz(self) -> dict[str, Any]:
|
||||
"""Call the public health endpoint without requiring auth."""
|
||||
|
||||
response = await self._client.get("/healthz")
|
||||
return self._decode_response(response)
|
||||
|
||||
async def run(
|
||||
self,
|
||||
script: str,
|
||||
*,
|
||||
cwd: str | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
||||
terminal: TerminalSize | None = None,
|
||||
) -> JobResult:
|
||||
"""Create a new job and wait for initial output or completion.
|
||||
|
||||
`cwd` and `env` preset the script's working directory and environment
|
||||
overlay on the server side.
|
||||
"""
|
||||
|
||||
payload = RunJobRequest(
|
||||
script=script,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
terminal=terminal,
|
||||
timeout=timeout,
|
||||
output_limit=self.output_limit,
|
||||
idle_flush_seconds=self.idle_flush_seconds,
|
||||
)
|
||||
response = await self._client.post(
|
||||
"/v1/jobs/run",
|
||||
json=payload.model_dump(mode="json", exclude_none=True),
|
||||
headers=self._auth_headers(),
|
||||
timeout=self._wait_request_timeout(timeout),
|
||||
)
|
||||
return JobResult.model_validate(self._decode_response(response))
|
||||
|
||||
async def wait(
|
||||
self,
|
||||
job_id: str,
|
||||
*,
|
||||
offset: int,
|
||||
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
||||
) -> JobResult:
|
||||
"""Wait for incremental output, completion, truncation, or timeout."""
|
||||
|
||||
response = await self._client.post(
|
||||
f"/v1/jobs/{job_id}/wait",
|
||||
json={
|
||||
"offset": offset,
|
||||
"timeout": timeout,
|
||||
"output_limit": self.output_limit,
|
||||
"idle_flush_seconds": self.idle_flush_seconds,
|
||||
},
|
||||
headers=self._auth_headers(),
|
||||
timeout=self._wait_request_timeout(timeout),
|
||||
)
|
||||
return JobResult.model_validate(self._decode_response(response))
|
||||
|
||||
async def status(self, job_id: str) -> JobStatusView:
|
||||
"""Fetch the materialized status view for one job."""
|
||||
|
||||
response = await self._client.get(
|
||||
f"/v1/jobs/{job_id}",
|
||||
headers=self._auth_headers(),
|
||||
)
|
||||
return JobStatusView.model_validate(self._decode_response(response))
|
||||
|
||||
async def list_jobs(
|
||||
self,
|
||||
*,
|
||||
status: str | None = None,
|
||||
limit: int = DEFAULT_LIST_LIMIT,
|
||||
) -> list[JobInfo]:
|
||||
"""List recent jobs, optionally filtered by lifecycle status."""
|
||||
|
||||
params: dict[str, Any] = {"limit": limit}
|
||||
if status is not None:
|
||||
params["status"] = status
|
||||
response = await self._client.get(
|
||||
"/v1/jobs",
|
||||
params=params,
|
||||
headers=self._auth_headers(),
|
||||
)
|
||||
payload = ListJobsResponse.model_validate(self._decode_response(response))
|
||||
return payload.jobs
|
||||
|
||||
async def input(
|
||||
self,
|
||||
job_id: str,
|
||||
text: str,
|
||||
*,
|
||||
offset: int,
|
||||
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
||||
) -> JobResult:
|
||||
"""Send text input to a running job and then wait like `wait()`."""
|
||||
|
||||
response = await self._client.post(
|
||||
f"/v1/jobs/{job_id}/input",
|
||||
json={
|
||||
"text": text,
|
||||
"offset": offset,
|
||||
"timeout": timeout,
|
||||
"output_limit": self.output_limit,
|
||||
"idle_flush_seconds": self.idle_flush_seconds,
|
||||
},
|
||||
headers=self._auth_headers(),
|
||||
timeout=self._wait_request_timeout(timeout),
|
||||
)
|
||||
return JobResult.model_validate(self._decode_response(response))
|
||||
|
||||
async def tail(self, job_id: str) -> JobResult:
|
||||
"""Fetch an immediate UTF-8-safe tail snapshot for a job."""
|
||||
|
||||
response = await self._client.get(
|
||||
f"/v1/jobs/{job_id}/log/tail",
|
||||
params={"output_limit": self.output_limit},
|
||||
headers=self._auth_headers(),
|
||||
)
|
||||
return JobResult.model_validate(self._decode_response(response))
|
||||
|
||||
async def terminate(
|
||||
self,
|
||||
job_id: str,
|
||||
grace_seconds: float = DEFAULT_TERMINATE_GRACE_SECONDS,
|
||||
) -> JobStatusView:
|
||||
"""Terminate a job, waiting long enough for the grace window to finish."""
|
||||
|
||||
response = await self._client.post(
|
||||
f"/v1/jobs/{job_id}/terminate",
|
||||
json={"grace_seconds": grace_seconds},
|
||||
headers=self._auth_headers(),
|
||||
timeout=self._terminate_request_timeout(grace_seconds),
|
||||
)
|
||||
return JobStatusView.model_validate(self._decode_response(response))
|
||||
|
||||
async def delete(
|
||||
self,
|
||||
job_id: str,
|
||||
*,
|
||||
force: bool = False,
|
||||
grace_seconds: float | None = None,
|
||||
) -> DeleteJobResponse:
|
||||
"""Delete job artifacts, optionally waiting for forced termination first."""
|
||||
|
||||
params: dict[str, Any] = {"force": str(force).lower()}
|
||||
if grace_seconds is not None:
|
||||
params["grace_seconds"] = grace_seconds
|
||||
request_kwargs: dict[str, Any] = {
|
||||
"params": params,
|
||||
"headers": self._auth_headers(),
|
||||
}
|
||||
if force:
|
||||
request_kwargs["timeout"] = self._terminate_request_timeout(grace_seconds)
|
||||
response = await self._client.delete(
|
||||
f"/v1/jobs/{job_id}",
|
||||
**request_kwargs,
|
||||
)
|
||||
return DeleteJobResponse.model_validate(self._decode_response(response))
|
||||
|
||||
def _auth_headers(self) -> dict[str, str]:
|
||||
if not self.token:
|
||||
return {}
|
||||
return {"Authorization": f"Bearer {self.token}"}
|
||||
|
||||
def _decode_response(self, response: httpx.Response) -> dict[str, Any]:
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError as exc: # pragma: no cover - network/proxy corruption
|
||||
raise ShellctlClientError(
|
||||
response.status_code, "invalid_json", response.text
|
||||
) from exc
|
||||
|
||||
if response.is_error:
|
||||
error = payload.get("error") if isinstance(payload, dict) else None
|
||||
if isinstance(error, dict):
|
||||
code = str(error.get("code", "request_failed"))
|
||||
message = str(error.get("message", response.text))
|
||||
else:
|
||||
code = "request_failed"
|
||||
message = response.text
|
||||
raise ShellctlClientError(response.status_code, code, message)
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
raise ShellctlClientError(
|
||||
response.status_code, "invalid_payload", response.text
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
__all__ = ["ShellctlClient", "ShellctlClientError"]
|
||||
@ -1 +0,0 @@
|
||||
|
||||
@ -1,58 +0,0 @@
|
||||
"""shellctl server package.
|
||||
|
||||
The server stack sits behind lazy exports so importing the network CLI does not
|
||||
pull in FastAPI, SQLAlchemy, tmux, or the local service runtime unless a
|
||||
server-side symbol is actually used.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import import_module
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from shellctl.server.api import create_app
|
||||
from shellctl.server.cli import (
|
||||
cli,
|
||||
main,
|
||||
)
|
||||
from shellctl.server.config import ShellctlConfig
|
||||
from shellctl.server.db import JobRow
|
||||
from shellctl.server.errors import ShellctlServerError
|
||||
from shellctl.server.serve import serve_command
|
||||
from shellctl.server.service import ShellctlService
|
||||
|
||||
__all__ = [
|
||||
"JobRow",
|
||||
"ShellctlConfig",
|
||||
"ShellctlServerError",
|
||||
"ShellctlService",
|
||||
"cli",
|
||||
"create_app",
|
||||
"main",
|
||||
"serve_command",
|
||||
]
|
||||
|
||||
_EXPORTS = {
|
||||
"JobRow": "shellctl.server.db",
|
||||
"ShellctlConfig": "shellctl.server.config",
|
||||
"ShellctlServerError": "shellctl.server.errors",
|
||||
"ShellctlService": "shellctl.server.service",
|
||||
"cli": "shellctl.server.cli",
|
||||
"create_app": "shellctl.server.api",
|
||||
"main": "shellctl.server.cli",
|
||||
"serve_command": "shellctl.server.serve",
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
if name not in _EXPORTS:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
module = import_module(_EXPORTS[name])
|
||||
value = getattr(module, name)
|
||||
globals()[name] = value
|
||||
return value
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return sorted(set(globals()) | set(__all__))
|
||||
@ -1,6 +0,0 @@
|
||||
"""Support `python -m shellctl.server`."""
|
||||
|
||||
from shellctl.cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,206 +0,0 @@
|
||||
"""FastAPI wiring for shellctl server endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Annotated, cast
|
||||
|
||||
from fastapi import Depends, FastAPI, Header, Query, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from shellctl.server.config import ShellctlConfig
|
||||
from shellctl.server.errors import ShellctlServerError
|
||||
from shellctl.server.service import ShellctlService
|
||||
from shellctl.shared.constants import (
|
||||
DEFAULT_HEALTH_STATUS,
|
||||
DEFAULT_LIST_LIMIT,
|
||||
DEFAULT_OUTPUT_LIMIT_BYTES,
|
||||
DEFAULT_TERMINATE_GRACE_SECONDS,
|
||||
MAX_LIST_LIMIT,
|
||||
MAX_OUTPUT_LIMIT_BYTES,
|
||||
)
|
||||
from shellctl.shared.schemas import (
|
||||
DeleteJobResponse,
|
||||
ErrorDetail,
|
||||
ErrorResponse,
|
||||
HealthResponse,
|
||||
InputJobRequest,
|
||||
JobResult,
|
||||
JobStatusName,
|
||||
JobStatusView,
|
||||
ListJobsResponse,
|
||||
RunJobRequest,
|
||||
TerminateJobRequest,
|
||||
WaitJobRequest,
|
||||
)
|
||||
|
||||
|
||||
def create_app(
|
||||
config: ShellctlConfig | None = None,
|
||||
*,
|
||||
service: ShellctlService | None = None,
|
||||
) -> FastAPI:
|
||||
"""Create the FastAPI application used by `shellctl serve`."""
|
||||
|
||||
resolved_config = config or ShellctlConfig()
|
||||
resolved_service = service or ShellctlService(resolved_config)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
await resolved_service.initialize()
|
||||
resolved_service.start_background_gc()
|
||||
resolved_service.start_background_pipe_monitor()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await resolved_service.shutdown()
|
||||
|
||||
app = FastAPI(title="shellctl", version="0.1.0", lifespan=lifespan)
|
||||
app.state.shellctl_service = resolved_service
|
||||
|
||||
@app.exception_handler(ShellctlServerError)
|
||||
async def handle_shellctl_error(
|
||||
_request: Request,
|
||||
exc: ShellctlServerError,
|
||||
) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=ErrorResponse(
|
||||
error=ErrorDetail(code=exc.code, message=exc.message)
|
||||
).model_dump(mode="json"),
|
||||
)
|
||||
|
||||
@app.exception_handler(RuntimeError)
|
||||
async def handle_runtime_error(
|
||||
_request: Request, exc: RuntimeError
|
||||
) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=ErrorResponse(
|
||||
error=ErrorDetail(
|
||||
code="internal_error",
|
||||
message=str(exc) or "internal server error",
|
||||
)
|
||||
).model_dump(mode="json"),
|
||||
)
|
||||
|
||||
def get_service() -> ShellctlService:
|
||||
return cast(ShellctlService, app.state.shellctl_service)
|
||||
|
||||
def verify_auth(
|
||||
authorization: Annotated[str | None, Header()] = None,
|
||||
) -> None:
|
||||
token = resolved_config.auth_token
|
||||
if token is None:
|
||||
return
|
||||
expected = f"Bearer {token}"
|
||||
if authorization != expected:
|
||||
raise ShellctlServerError(
|
||||
401, "unauthorized", "Missing or invalid bearer token"
|
||||
)
|
||||
|
||||
@app.get("/healthz", response_model=HealthResponse)
|
||||
async def healthz() -> HealthResponse:
|
||||
return HealthResponse(status=DEFAULT_HEALTH_STATUS)
|
||||
|
||||
@app.post(
|
||||
"/v1/jobs/run",
|
||||
response_model=JobResult,
|
||||
dependencies=[Depends(verify_auth)],
|
||||
)
|
||||
async def run_job(
|
||||
payload: RunJobRequest,
|
||||
svc: ShellctlService = Depends(get_service),
|
||||
) -> JobResult:
|
||||
return await svc.run_job(payload)
|
||||
|
||||
@app.post(
|
||||
"/v1/jobs/{job_id}/wait",
|
||||
response_model=JobResult,
|
||||
dependencies=[Depends(verify_auth)],
|
||||
)
|
||||
async def wait_job(
|
||||
job_id: str,
|
||||
payload: WaitJobRequest,
|
||||
svc: ShellctlService = Depends(get_service),
|
||||
) -> JobResult:
|
||||
return await svc.wait_job(job_id, payload)
|
||||
|
||||
@app.get(
|
||||
"/v1/jobs/{job_id}/log/tail",
|
||||
response_model=JobResult,
|
||||
dependencies=[Depends(verify_auth)],
|
||||
)
|
||||
async def tail_job(
|
||||
job_id: str,
|
||||
output_limit: Annotated[
|
||||
int, Query(ge=1, le=MAX_OUTPUT_LIMIT_BYTES)
|
||||
] = DEFAULT_OUTPUT_LIMIT_BYTES,
|
||||
svc: ShellctlService = Depends(get_service),
|
||||
) -> JobResult:
|
||||
return await svc.tail_job(job_id, output_limit=output_limit)
|
||||
|
||||
@app.get(
|
||||
"/v1/jobs/{job_id}",
|
||||
response_model=JobStatusView,
|
||||
dependencies=[Depends(verify_auth)],
|
||||
)
|
||||
async def job_status(
|
||||
job_id: str,
|
||||
svc: ShellctlService = Depends(get_service),
|
||||
) -> JobStatusView:
|
||||
return await svc.get_job_status(job_id)
|
||||
|
||||
@app.get(
|
||||
"/v1/jobs",
|
||||
response_model=ListJobsResponse,
|
||||
dependencies=[Depends(verify_auth)],
|
||||
)
|
||||
async def list_jobs(
|
||||
status: Annotated[JobStatusName | None, Query()] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=MAX_LIST_LIMIT)] = DEFAULT_LIST_LIMIT,
|
||||
svc: ShellctlService = Depends(get_service),
|
||||
) -> ListJobsResponse:
|
||||
return await svc.list_jobs(status=status, limit=limit)
|
||||
|
||||
@app.post(
|
||||
"/v1/jobs/{job_id}/input",
|
||||
response_model=JobResult,
|
||||
dependencies=[Depends(verify_auth)],
|
||||
)
|
||||
async def input_job(
|
||||
job_id: str,
|
||||
payload: InputJobRequest,
|
||||
svc: ShellctlService = Depends(get_service),
|
||||
) -> JobResult:
|
||||
return await svc.send_input(job_id, payload)
|
||||
|
||||
@app.post(
|
||||
"/v1/jobs/{job_id}/terminate",
|
||||
response_model=JobStatusView,
|
||||
dependencies=[Depends(verify_auth)],
|
||||
)
|
||||
async def terminate_job(
|
||||
job_id: str,
|
||||
payload: TerminateJobRequest,
|
||||
svc: ShellctlService = Depends(get_service),
|
||||
) -> JobStatusView:
|
||||
return await svc.terminate_job(job_id, payload)
|
||||
|
||||
@app.delete(
|
||||
"/v1/jobs/{job_id}",
|
||||
response_model=DeleteJobResponse,
|
||||
dependencies=[Depends(verify_auth)],
|
||||
)
|
||||
async def delete_job(
|
||||
job_id: str,
|
||||
force: bool = False,
|
||||
grace_seconds: float = DEFAULT_TERMINATE_GRACE_SECONDS,
|
||||
svc: ShellctlService = Depends(get_service),
|
||||
) -> DeleteJobResponse:
|
||||
return await svc.delete_job(job_id, force=force, grace_seconds=grace_seconds)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
__all__ = ["create_app"]
|
||||
@ -1,62 +0,0 @@
|
||||
"""Per-job artifact names used by shellctl server/runtime code.
|
||||
|
||||
Normal job completion is coordinated through small marker files inside each
|
||||
`jobs/<job_id>/` directory so the tmux output-pipe finalizer can publish the
|
||||
SQLite `exited(exit_code, ended_at)` state only after PTY output is fully
|
||||
drained into `output.log`. The same artifact directory also stores the request's
|
||||
environment overlay so the runner can merge arbitrary key/value pairs without
|
||||
shell-escaping them into the generated script. Separate failure markers and a
|
||||
dedicated `pipe-error.log` stderr capture keep startup diagnostics available
|
||||
when the sanitizer never reaches its ready-file handshake.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
RUNNER_EXIT_CODE_FILENAME = ".runner-exit-code"
|
||||
RUNNER_ENDED_AT_FILENAME = ".runner-ended-at"
|
||||
JOB_ENV_FILENAME = ".job-env.json"
|
||||
PIPE_DRAINED_FILENAME = ".pipe-drained"
|
||||
PIPE_FAILED_FILENAME = ".pipe-failed"
|
||||
PIPE_ERROR_LOG_FILENAME = "pipe-error.log"
|
||||
|
||||
|
||||
def runner_exit_code_path(job_dir: Path) -> Path:
|
||||
return job_dir / RUNNER_EXIT_CODE_FILENAME
|
||||
|
||||
|
||||
def runner_ended_at_path(job_dir: Path) -> Path:
|
||||
return job_dir / RUNNER_ENDED_AT_FILENAME
|
||||
|
||||
|
||||
def job_env_path(job_dir: Path) -> Path:
|
||||
return job_dir / JOB_ENV_FILENAME
|
||||
|
||||
|
||||
def pipe_drained_path(job_dir: Path) -> Path:
|
||||
return job_dir / PIPE_DRAINED_FILENAME
|
||||
|
||||
|
||||
def pipe_failed_path(job_dir: Path) -> Path:
|
||||
return job_dir / PIPE_FAILED_FILENAME
|
||||
|
||||
|
||||
def pipe_error_log_path(job_dir: Path) -> Path:
|
||||
return job_dir / PIPE_ERROR_LOG_FILENAME
|
||||
|
||||
|
||||
__all__ = [
|
||||
"JOB_ENV_FILENAME",
|
||||
"PIPE_DRAINED_FILENAME",
|
||||
"PIPE_ERROR_LOG_FILENAME",
|
||||
"PIPE_FAILED_FILENAME",
|
||||
"RUNNER_ENDED_AT_FILENAME",
|
||||
"RUNNER_EXIT_CODE_FILENAME",
|
||||
"job_env_path",
|
||||
"pipe_drained_path",
|
||||
"pipe_error_log_path",
|
||||
"pipe_failed_path",
|
||||
"runner_ended_at_path",
|
||||
"runner_exit_code_path",
|
||||
]
|
||||
@ -1,17 +0,0 @@
|
||||
"""Compatibility shim for historical `shellctl.server.cli` imports.
|
||||
|
||||
Job-management commands now live in `shellctl.cli`. This
|
||||
module is intentionally a thin legacy re-export for callers that still import
|
||||
CLI symbols from `shellctl.server.cli`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from shellctl.cli import cli, main
|
||||
from shellctl.server.serve import serve_command
|
||||
|
||||
__all__ = [
|
||||
"cli",
|
||||
"main",
|
||||
"serve_command",
|
||||
]
|
||||
@ -1,105 +0,0 @@
|
||||
"""Configuration objects for shellctl server/runtime modules."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
from shellctl.shared.constants import (
|
||||
DEFAULT_AUTH_TOKEN_ENV,
|
||||
DEFAULT_GC_FINISHED_JOB_RETENTION_SECONDS,
|
||||
DEFAULT_GC_INTERVAL_SECONDS,
|
||||
DEFAULT_IDLE_FLUSH_SECONDS,
|
||||
DEFAULT_LIST_LIMIT,
|
||||
DEFAULT_OUTPUT_LIMIT_BYTES,
|
||||
DEFAULT_TERMINAL_COLS,
|
||||
DEFAULT_TERMINAL_ROWS,
|
||||
DEFAULT_TERMINATE_GRACE_SECONDS,
|
||||
DEFAULT_TIMEOUT_SECONDS,
|
||||
MAX_LIST_LIMIT,
|
||||
MAX_OUTPUT_LIMIT_BYTES,
|
||||
MAX_WAIT_TIMEOUT_SECONDS,
|
||||
)
|
||||
from shellctl.shared.runtime import (
|
||||
default_runtime_dir,
|
||||
default_state_dir,
|
||||
)
|
||||
from shellctl_runtime.paths import DEFAULT_SQLITE_BUSY_TIMEOUT_MS
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class ShellctlConfig:
|
||||
"""Runtime configuration for the shellctl service and CLI.
|
||||
|
||||
The tmux subprocess hooks use dedicated console scripts instead of
|
||||
`python -m shellctl...` entrypoints so every job does not pay
|
||||
the shellctl client/server import cost just to sanitize PTY bytes or record
|
||||
an exit row. `sqlite_busy_timeout_ms` still applies to the out-of-process
|
||||
`runner-exit` callback, so non-default deployments keep one SQLite timeout
|
||||
policy across the service and tmux finalizer.
|
||||
|
||||
Bearer auth is opt-in: if the explicit `auth_token` and the fallback
|
||||
`SHELLCTL_AUTH_TOKEN` environment variable are both missing or empty,
|
||||
`shellctl serve` accepts requests without checking an Authorization header.
|
||||
"""
|
||||
|
||||
listen: str = "127.0.0.1:8765"
|
||||
auth_token: str | None = None
|
||||
state_dir: Path = field(default_factory=default_state_dir)
|
||||
runtime_dir: Path | None = None
|
||||
gc_interval_seconds: float = DEFAULT_GC_INTERVAL_SECONDS
|
||||
gc_finished_job_retention_seconds: float = DEFAULT_GC_FINISHED_JOB_RETENTION_SECONDS
|
||||
default_timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS
|
||||
max_wait_timeout_seconds: float = MAX_WAIT_TIMEOUT_SECONDS
|
||||
idle_flush_seconds: float = DEFAULT_IDLE_FLUSH_SECONDS
|
||||
default_cwd: Path = field(default_factory=Path.home)
|
||||
default_terminal_cols: int = DEFAULT_TERMINAL_COLS
|
||||
default_terminal_rows: int = DEFAULT_TERMINAL_ROWS
|
||||
default_list_limit: int = DEFAULT_LIST_LIMIT
|
||||
max_list_limit: int = MAX_LIST_LIMIT
|
||||
default_output_limit_bytes: int = DEFAULT_OUTPUT_LIMIT_BYTES
|
||||
max_output_limit_bytes: int = MAX_OUTPUT_LIMIT_BYTES
|
||||
default_terminate_grace_seconds: float = DEFAULT_TERMINATE_GRACE_SECONDS
|
||||
poll_interval_seconds: float = 0.05
|
||||
pipe_monitor_interval_seconds: float = 1.0
|
||||
pipe_ready_timeout_seconds: float = 10.0
|
||||
sqlite_busy_timeout_ms: int = DEFAULT_SQLITE_BUSY_TIMEOUT_MS
|
||||
sanitize_pty_command: tuple[str, ...] = ("shellctl-sanitize-pty",)
|
||||
runner_exit_command: tuple[str, ...] = ("shellctl-runner-exit",)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.runtime_dir is None:
|
||||
object.__setattr__(self, "runtime_dir", default_runtime_dir(self.state_dir))
|
||||
token = self.auth_token
|
||||
if token is None:
|
||||
token = os.environ.get(DEFAULT_AUTH_TOKEN_ENV)
|
||||
if not token:
|
||||
token = None
|
||||
object.__setattr__(self, "auth_token", token)
|
||||
|
||||
@property
|
||||
def jobs_dir(self) -> Path:
|
||||
return self.state_dir / "jobs"
|
||||
|
||||
@property
|
||||
def db_path(self) -> Path:
|
||||
return self.state_dir / "shellctl.db"
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
return f"sqlite+aiosqlite:///{self.db_path}"
|
||||
|
||||
@property
|
||||
def tmux_socket(self) -> Path:
|
||||
runtime_dir = cast(Path, self.runtime_dir)
|
||||
return runtime_dir / "tmux.sock"
|
||||
|
||||
@property
|
||||
def runner_path(self) -> Path:
|
||||
runtime_dir = cast(Path, self.runtime_dir)
|
||||
return runtime_dir / "bin" / "shellctl-runner"
|
||||
|
||||
|
||||
__all__ = ["ShellctlConfig"]
|
||||
@ -1,52 +0,0 @@
|
||||
"""SQLite models and engine helpers for shellctl."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, cast
|
||||
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
|
||||
class JobRow(SQLModel, table=True):
|
||||
"""SQLite source-of-truth row for shellctl jobs.
|
||||
|
||||
The table intentionally combines immutable metadata, mutable lifecycle state,
|
||||
and exit facts so state transitions can be expressed as single conditional
|
||||
`UPDATE` statements without synchronizing separate records.
|
||||
"""
|
||||
|
||||
__tablename__ = cast(Any, "jobs")
|
||||
|
||||
job_id: str = Field(primary_key=True)
|
||||
script_path: str
|
||||
output_path: str
|
||||
cwd: str
|
||||
terminal_cols: int
|
||||
terminal_rows: int
|
||||
status: str = Field(index=True)
|
||||
session_name: str
|
||||
pane_target: str
|
||||
exit_code: int | None = Field(default=None, nullable=True)
|
||||
reason: str | None = Field(default=None, nullable=True)
|
||||
message: str | None = Field(default=None, nullable=True)
|
||||
created_at: str = Field(index=True)
|
||||
started_at: str | None = Field(default=None, nullable=True)
|
||||
ended_at: str | None = Field(default=None, nullable=True, index=True)
|
||||
updated_at: str
|
||||
|
||||
|
||||
def configure_sqlite_engine(engine: AsyncEngine, *, busy_timeout_ms: int) -> None:
|
||||
"""Install SQLite pragmas required by the proposal's concurrency model."""
|
||||
|
||||
@event.listens_for(engine.sync_engine, "connect")
|
||||
def _set_pragmas(dbapi_connection: Any, _connection_record: Any) -> None:
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
cursor.execute(f"PRAGMA busy_timeout={busy_timeout_ms}")
|
||||
cursor.close()
|
||||
|
||||
|
||||
__all__ = ["JobRow", "configure_sqlite_engine"]
|
||||
@ -1,14 +0,0 @@
|
||||
"""Shared exception types for shellctl server-side modules."""
|
||||
|
||||
|
||||
class ShellctlServerError(RuntimeError):
|
||||
"""Structured server-side error that maps directly to API responses."""
|
||||
|
||||
def __init__(self, status_code: int, code: str, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
self.code = code
|
||||
self.message = message
|
||||
|
||||
|
||||
__all__ = ["ShellctlServerError"]
|
||||
@ -1,103 +0,0 @@
|
||||
"""Server-side `shellctl serve` implementation.
|
||||
|
||||
This module stays separate from the top-level CLI so ordinary client commands
|
||||
do not import the FastAPI or uvicorn stack. `serve_command()` performs those
|
||||
imports lazily because only the long-running server path needs them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import typer
|
||||
|
||||
from shellctl.shared.constants import (
|
||||
DEFAULT_AUTH_TOKEN_ENV,
|
||||
DEFAULT_GC_FINISHED_JOB_RETENTION_SECONDS,
|
||||
DEFAULT_GC_INTERVAL_SECONDS,
|
||||
)
|
||||
from shellctl.shared.runtime import (
|
||||
default_state_dir,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from shellctl.server.config import ShellctlConfig
|
||||
|
||||
|
||||
def serve_command(
|
||||
listen: str = "127.0.0.1:8765",
|
||||
auth_token: str | None = typer.Option(
|
||||
None,
|
||||
"--auth-token",
|
||||
envvar=DEFAULT_AUTH_TOKEN_ENV,
|
||||
help=(
|
||||
"Bearer token value. You can also set SHELLCTL_AUTH_TOKEN. "
|
||||
"Leave it unset or empty to disable HTTP bearer auth."
|
||||
),
|
||||
),
|
||||
state_dir: Path | None = None,
|
||||
runtime_dir: Path | None = None,
|
||||
gc_interval_seconds: float = DEFAULT_GC_INTERVAL_SECONDS,
|
||||
gc_finished_job_retention_seconds: float = DEFAULT_GC_FINISHED_JOB_RETENTION_SECONDS,
|
||||
) -> None:
|
||||
"""Build `ShellctlConfig` from CLI inputs and run the local HTTP server.
|
||||
|
||||
Args:
|
||||
listen: Host/port pair for the uvicorn listener in `host:port` form.
|
||||
auth_token: Optional bearer token value. An explicit empty string
|
||||
disables HTTP auth, an explicit non-empty token enables it, and an
|
||||
omitted/`None` value may still resolve from `SHELLCTL_AUTH_TOKEN`
|
||||
through the Typer env var binding or `ShellctlConfig` fallback.
|
||||
state_dir: Persistent shellctl state directory; defaults to the shared
|
||||
XDG-style state path when omitted.
|
||||
runtime_dir: Optional runtime directory override for tmux/runtime
|
||||
artifacts.
|
||||
gc_interval_seconds: Background GC wake-up cadence for finished jobs.
|
||||
gc_finished_job_retention_seconds: Retention window before finished jobs
|
||||
are eligible for GC.
|
||||
|
||||
This entrypoint is the only CLI path that should pull in the FastAPI and
|
||||
uvicorn stack. It parses the listener, constructs `ShellctlConfig`, and
|
||||
then hands the configured app to uvicorn.
|
||||
"""
|
||||
|
||||
from shellctl.server.config import ShellctlConfig
|
||||
|
||||
host, port = _parse_listen(listen)
|
||||
config = ShellctlConfig(
|
||||
listen=listen,
|
||||
auth_token=auth_token,
|
||||
state_dir=state_dir or default_state_dir(),
|
||||
runtime_dir=runtime_dir,
|
||||
gc_interval_seconds=gc_interval_seconds,
|
||||
gc_finished_job_retention_seconds=gc_finished_job_retention_seconds,
|
||||
)
|
||||
_uvicorn_run(_create_app(config), host=host, port=port, log_level="info")
|
||||
|
||||
|
||||
def _parse_listen(value: str) -> tuple[str, int]:
|
||||
if ":" not in value:
|
||||
raise typer.BadParameter("listen must use host:port format")
|
||||
host, raw_port = value.rsplit(":", 1)
|
||||
host = host.strip("[]")
|
||||
try:
|
||||
port = int(raw_port)
|
||||
except ValueError as exc:
|
||||
raise typer.BadParameter(f"invalid port: {raw_port}") from exc
|
||||
return host, port
|
||||
|
||||
|
||||
def _create_app(config: ShellctlConfig) -> Any:
|
||||
from shellctl.server.api import create_app
|
||||
|
||||
return create_app(config)
|
||||
|
||||
|
||||
def _uvicorn_run(app: Any, *, host: str, port: int, log_level: str) -> None:
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(app, host=host, port=port, log_level=log_level)
|
||||
|
||||
|
||||
__all__ = ["serve_command"]
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,361 +0,0 @@
|
||||
"""tmux control layer for shellctl jobs.
|
||||
|
||||
The rest of shellctl treats this module as the only place that knows tmux CLI
|
||||
command shapes. Tests can replace `TmuxControllerProtocol` with a fake to
|
||||
exercise SQLite/output semantics without depending on a local tmux daemon.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Protocol, cast
|
||||
|
||||
import anyio
|
||||
|
||||
from shellctl.server.artifacts import (
|
||||
pipe_drained_path,
|
||||
pipe_error_log_path,
|
||||
pipe_failed_path,
|
||||
runner_ended_at_path,
|
||||
runner_exit_code_path,
|
||||
)
|
||||
from shellctl.server.config import ShellctlConfig
|
||||
from shellctl.server.errors import ShellctlServerError
|
||||
from shellctl.shared.runtime import (
|
||||
job_pane_target,
|
||||
job_session_name,
|
||||
)
|
||||
from shellctl.shared.schemas import TerminalSize
|
||||
|
||||
|
||||
class TmuxControllerProtocol(Protocol):
|
||||
"""Protocol used by `ShellctlService` for tmux interactions."""
|
||||
|
||||
async def start_server(self) -> None: ...
|
||||
|
||||
async def list_sessions(self) -> set[str]: ...
|
||||
|
||||
async def session_exists(self, session_name: str) -> bool: ...
|
||||
|
||||
async def is_output_pipe_active(self, *, job_id: str) -> bool | None: ...
|
||||
|
||||
async def create_job_session(
|
||||
self,
|
||||
*,
|
||||
job_id: str,
|
||||
job_dir: Path,
|
||||
cwd: Path,
|
||||
terminal: TerminalSize,
|
||||
) -> None: ...
|
||||
|
||||
async def enable_output_pipe(
|
||||
self, *, job_id: str, job_dir: Path, ready_file: Path
|
||||
) -> None: ...
|
||||
|
||||
async def send_input(self, *, job_id: str, text: str) -> None: ...
|
||||
|
||||
async def send_interrupt(self, *, job_id: str) -> None: ...
|
||||
|
||||
async def cleanup_session(self, *, job_id: str) -> None: ...
|
||||
|
||||
|
||||
class TmuxController:
|
||||
"""Best-effort wrapper around a dedicated tmux socket.
|
||||
|
||||
The controller always clears `TMUX` from the child environment and always
|
||||
passes `-S <socket>` so shellctl sessions stay isolated from the user's
|
||||
default tmux server.
|
||||
"""
|
||||
|
||||
def __init__(self, config: ShellctlConfig) -> None:
|
||||
self._config = config
|
||||
|
||||
async def start_server(self) -> None:
|
||||
await self._run_tmux("start-server")
|
||||
|
||||
async def list_sessions(self) -> set[str]:
|
||||
result = await self._run_tmux(
|
||||
"list-sessions", "-F", "#{session_name}", check=False
|
||||
)
|
||||
if result.returncode != 0:
|
||||
stderr = result.stderr.decode("utf-8", errors="replace")
|
||||
if _tmux_target_missing(stderr):
|
||||
return set()
|
||||
raise ShellctlServerError(
|
||||
500, "tmux_error", stderr.strip() or "tmux list-sessions failed"
|
||||
)
|
||||
output = result.stdout.decode("utf-8", errors="replace")
|
||||
return {line.strip() for line in output.splitlines() if line.strip()}
|
||||
|
||||
async def session_exists(self, session_name: str) -> bool:
|
||||
return session_name in await self.list_sessions()
|
||||
|
||||
async def is_output_pipe_active(self, *, job_id: str) -> bool | None:
|
||||
result = await self._run_tmux(
|
||||
"display-message",
|
||||
"-p",
|
||||
"-t",
|
||||
job_pane_target(job_id),
|
||||
"#{pane_pipe}",
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
stderr = result.stderr.decode("utf-8", errors="replace")
|
||||
if _tmux_target_missing(stderr):
|
||||
return None
|
||||
raise ShellctlServerError(
|
||||
500,
|
||||
"tmux_error",
|
||||
stderr.strip() or f"Failed to inspect output pipe for {job_id}",
|
||||
)
|
||||
return result.stdout.decode("utf-8", errors="replace").strip() == "1"
|
||||
|
||||
async def create_job_session(
|
||||
self,
|
||||
*,
|
||||
job_id: str,
|
||||
job_dir: Path,
|
||||
cwd: Path,
|
||||
terminal: TerminalSize,
|
||||
) -> None:
|
||||
runner_command = self._shell_join(
|
||||
[
|
||||
str(self._config.runner_path),
|
||||
str(job_dir),
|
||||
job_id,
|
||||
str(cwd),
|
||||
]
|
||||
)
|
||||
result = await self._run_tmux(
|
||||
"-f",
|
||||
"/dev/null",
|
||||
"new-session",
|
||||
"-d",
|
||||
"-s",
|
||||
job_session_name(job_id),
|
||||
"-x",
|
||||
str(terminal.cols),
|
||||
"-y",
|
||||
str(terminal.rows),
|
||||
runner_command,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise ShellctlServerError(
|
||||
500,
|
||||
"tmux_new_session_failed",
|
||||
result.stderr.decode("utf-8", errors="replace").strip()
|
||||
or f"Failed to create tmux session for {job_id}",
|
||||
)
|
||||
|
||||
async def enable_output_pipe(
|
||||
self, *, job_id: str, job_dir: Path, ready_file: Path
|
||||
) -> None:
|
||||
output_command = self._pipe_command_source(
|
||||
job_id=job_id,
|
||||
job_dir=job_dir,
|
||||
ready_file=ready_file,
|
||||
)
|
||||
result = await self._run_tmux(
|
||||
"pipe-pane",
|
||||
"-o",
|
||||
"-t",
|
||||
job_pane_target(job_id),
|
||||
output_command,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise ShellctlServerError(
|
||||
500,
|
||||
"pipe_failed",
|
||||
result.stderr.decode("utf-8", errors="replace").strip()
|
||||
or f"Failed to attach output pipe for {job_id}",
|
||||
)
|
||||
|
||||
def _pipe_command_source(
|
||||
self, *, job_id: str, job_dir: Path, ready_file: Path
|
||||
) -> str:
|
||||
"""Build the tmux `pipe-pane` command that drains and finalizes output.
|
||||
|
||||
For normal exits, the runner now records completion metadata into job
|
||||
artifacts and the pipe finalizer commits `runner-exit` only after
|
||||
the lightweight sanitizer reaches EOF and flushes `output.log`
|
||||
successfully. Sanitizer stderr is captured into `pipe-error.log` so
|
||||
startup timeouts can distinguish slow imports from subprocess crashes.
|
||||
If the follow-up `runner-exit` write fails, the drain marker remains in
|
||||
place and stderr is appended to the same log with an explicit status
|
||||
line. The pipe still exits with the sanitizer status so a drained job is
|
||||
not misclassified as `pipe_failed` before reconciliation can recover the
|
||||
SQLite write from the drained artifacts.
|
||||
"""
|
||||
|
||||
sanitize_command = self._shell_join(
|
||||
(
|
||||
*self._config.sanitize_pty_command,
|
||||
"--ready-file",
|
||||
str(ready_file),
|
||||
)
|
||||
)
|
||||
runner_exit_command = self._shell_join(
|
||||
(
|
||||
*self._config.runner_exit_command,
|
||||
"--state-dir",
|
||||
str(self._config.state_dir),
|
||||
"--job-id",
|
||||
job_id,
|
||||
"--sqlite-busy-timeout-ms",
|
||||
str(self._config.sqlite_busy_timeout_ms),
|
||||
)
|
||||
)
|
||||
output_path = shlex.quote(str(job_dir / "output.log"))
|
||||
drained_path = shlex.quote(str(pipe_drained_path(job_dir)))
|
||||
error_log_path = shlex.quote(str(pipe_error_log_path(job_dir)))
|
||||
failed_path = shlex.quote(str(pipe_failed_path(job_dir)))
|
||||
exit_code_path = shlex.quote(str(runner_exit_code_path(job_dir)))
|
||||
ended_at_path = shlex.quote(str(runner_ended_at_path(job_dir)))
|
||||
return " ; ".join(
|
||||
[
|
||||
f"{sanitize_command} >> {output_path} 2> {error_log_path}",
|
||||
"sanitize_status=$?",
|
||||
"runner_exit_status=0",
|
||||
(
|
||||
'if [ "$sanitize_status" -eq 0 ]; then '
|
||||
f": > {drained_path}; "
|
||||
f"if [ -s {exit_code_path} ] && [ -s {ended_at_path} ]; then "
|
||||
f'{runner_exit_command} --exit-code "$(cat {exit_code_path})" '
|
||||
f'--ended-at "$(cat {ended_at_path})" 2>> {error_log_path}; '
|
||||
"runner_exit_status=$?; "
|
||||
'if [ "$runner_exit_status" -ne 0 ]; then '
|
||||
f"printf 'runner-exit failed with status %s\\n' \"$runner_exit_status\" >> {error_log_path}; "
|
||||
"fi; fi; "
|
||||
f"else : > {failed_path}; fi"
|
||||
),
|
||||
'if [ "$sanitize_status" -ne 0 ]; then exit "$sanitize_status"; fi',
|
||||
'exit "$sanitize_status"',
|
||||
]
|
||||
)
|
||||
|
||||
async def send_input(self, *, job_id: str, text: str) -> None:
|
||||
buffer_name = f"shellctl-in-{job_id}"
|
||||
runtime_dir = cast(Path, self._config.runtime_dir)
|
||||
runtime_dir.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp_name = tempfile.mkstemp(
|
||||
prefix=f"shellctl-input-{job_id}-", dir=runtime_dir
|
||||
)
|
||||
tmp_path = Path(tmp_name)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
handle.write(text)
|
||||
load_result = await self._run_tmux(
|
||||
"load-buffer",
|
||||
"-b",
|
||||
buffer_name,
|
||||
str(tmp_path),
|
||||
check=False,
|
||||
)
|
||||
if load_result.returncode != 0:
|
||||
stderr = load_result.stderr.decode("utf-8", errors="replace").strip()
|
||||
if _tmux_target_missing(stderr):
|
||||
raise ShellctlServerError(
|
||||
409,
|
||||
"tmux_target_missing",
|
||||
stderr or f"The tmux pane for {job_id} is no longer available",
|
||||
)
|
||||
raise ShellctlServerError(
|
||||
500,
|
||||
"tmux_input_failed",
|
||||
stderr or f"Failed to load input buffer for {job_id}",
|
||||
)
|
||||
paste_result = await self._run_tmux(
|
||||
"paste-buffer",
|
||||
"-t",
|
||||
job_pane_target(job_id),
|
||||
"-b",
|
||||
buffer_name,
|
||||
check=False,
|
||||
)
|
||||
if paste_result.returncode != 0:
|
||||
stderr = paste_result.stderr.decode("utf-8", errors="replace").strip()
|
||||
if _tmux_target_missing(stderr):
|
||||
raise ShellctlServerError(
|
||||
409,
|
||||
"tmux_target_missing",
|
||||
stderr or f"The tmux pane for {job_id} is no longer available",
|
||||
)
|
||||
raise ShellctlServerError(
|
||||
500,
|
||||
"tmux_input_failed",
|
||||
stderr or f"Failed to paste input buffer for {job_id}",
|
||||
)
|
||||
finally:
|
||||
await self._run_tmux("delete-buffer", "-b", buffer_name, check=False)
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
async def send_interrupt(self, *, job_id: str) -> None:
|
||||
await self._run_tmux(
|
||||
"send-keys",
|
||||
"-t",
|
||||
job_pane_target(job_id),
|
||||
"C-c",
|
||||
check=False,
|
||||
)
|
||||
|
||||
async def cleanup_session(self, *, job_id: str) -> None:
|
||||
await self._run_tmux(
|
||||
"kill-session",
|
||||
"-t",
|
||||
job_session_name(job_id),
|
||||
check=False,
|
||||
)
|
||||
|
||||
async def _run_tmux(
|
||||
self, *args: str, check: bool = True
|
||||
) -> subprocess.CompletedProcess[bytes]:
|
||||
env = dict(os.environ)
|
||||
env.pop("TMUX", None)
|
||||
try:
|
||||
result = await anyio.run_process(
|
||||
["tmux", "-S", str(self._config.tmux_socket), *args],
|
||||
env=env,
|
||||
check=False,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise ShellctlServerError(
|
||||
500, "tmux_not_installed", "tmux executable was not found"
|
||||
) from exc
|
||||
if check and result.returncode != 0:
|
||||
raise ShellctlServerError(
|
||||
500,
|
||||
"tmux_error",
|
||||
result.stderr.decode("utf-8", errors="replace").strip()
|
||||
or "tmux command failed",
|
||||
)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _shell_join(parts: tuple[str, ...] | list[str]) -> str:
|
||||
return " ".join(shlex.quote(part) for part in parts)
|
||||
|
||||
|
||||
def _tmux_target_missing(stderr: str) -> bool:
|
||||
normalized = stderr.lower()
|
||||
return (
|
||||
"can't find pane" in normalized
|
||||
or "can't find session" in normalized
|
||||
or "no server running" in normalized
|
||||
or "failed to connect" in normalized
|
||||
or "server exited unexpectedly" in normalized
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"TmuxController",
|
||||
"TmuxControllerProtocol",
|
||||
"_tmux_target_missing",
|
||||
]
|
||||
@ -1,182 +0,0 @@
|
||||
"""Shared shellctl transport/runtime helpers.
|
||||
|
||||
This package preserves the historical import surface while keeping the package
|
||||
root lazy. Lightweight callers can import concrete submodules such as
|
||||
`shared.runtime` without eagerly importing the pydantic schema layer, output
|
||||
or helpers outside the shared compatibility surface.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import import_module
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from shellctl.shared.constants import (
|
||||
DEFAULT_AUTH_TOKEN_ENV,
|
||||
DEFAULT_BASE_URL,
|
||||
DEFAULT_BASE_URL_ENV,
|
||||
DEFAULT_GC_FINISHED_JOB_RETENTION_SECONDS,
|
||||
DEFAULT_GC_INTERVAL_SECONDS,
|
||||
DEFAULT_HEALTH_STATUS,
|
||||
DEFAULT_IDLE_FLUSH_SECONDS,
|
||||
DEFAULT_LIST_LIMIT,
|
||||
DEFAULT_OUTPUT_LIMIT_BYTES,
|
||||
DEFAULT_TERMINAL_COLS,
|
||||
DEFAULT_TERMINAL_ROWS,
|
||||
DEFAULT_TERMINATE_GRACE_SECONDS,
|
||||
DEFAULT_TIMEOUT_SECONDS,
|
||||
JOB_ID_ALPHABET,
|
||||
JOB_ID_RANDOM_SUFFIX_LENGTH,
|
||||
MAX_LIST_LIMIT,
|
||||
MAX_OUTPUT_LIMIT_BYTES,
|
||||
MAX_WAIT_TIMEOUT_SECONDS,
|
||||
SESSION_NAME_PREFIX,
|
||||
)
|
||||
from shellctl.shared.output import (
|
||||
OutputWindow,
|
||||
read_output_window,
|
||||
tail_output_window,
|
||||
)
|
||||
from shellctl.shared.runtime import (
|
||||
default_runtime_dir,
|
||||
default_state_dir,
|
||||
format_timestamp,
|
||||
generate_job_id,
|
||||
is_terminal_status,
|
||||
job_pane_target,
|
||||
job_session_name,
|
||||
parse_timestamp,
|
||||
utc_now,
|
||||
)
|
||||
from shellctl.shared.schemas import (
|
||||
TERMINAL_JOB_STATUSES,
|
||||
DeleteJobResponse,
|
||||
ErrorDetail,
|
||||
ErrorResponse,
|
||||
HealthResponse,
|
||||
InputJobRequest,
|
||||
JobInfo,
|
||||
JobResult,
|
||||
JobStatusName,
|
||||
JobStatusView,
|
||||
ListJobsResponse,
|
||||
RunJobRequest,
|
||||
ShellctlModel,
|
||||
TerminalSize,
|
||||
TerminateJobRequest,
|
||||
WaitJobRequest,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_AUTH_TOKEN_ENV",
|
||||
"DEFAULT_BASE_URL",
|
||||
"DEFAULT_BASE_URL_ENV",
|
||||
"DEFAULT_GC_FINISHED_JOB_RETENTION_SECONDS",
|
||||
"DEFAULT_GC_INTERVAL_SECONDS",
|
||||
"DEFAULT_HEALTH_STATUS",
|
||||
"DEFAULT_IDLE_FLUSH_SECONDS",
|
||||
"DEFAULT_LIST_LIMIT",
|
||||
"DEFAULT_OUTPUT_LIMIT_BYTES",
|
||||
"DEFAULT_TERMINAL_COLS",
|
||||
"DEFAULT_TERMINAL_ROWS",
|
||||
"DEFAULT_TERMINATE_GRACE_SECONDS",
|
||||
"DEFAULT_TIMEOUT_SECONDS",
|
||||
"JOB_ID_ALPHABET",
|
||||
"JOB_ID_RANDOM_SUFFIX_LENGTH",
|
||||
"MAX_LIST_LIMIT",
|
||||
"MAX_OUTPUT_LIMIT_BYTES",
|
||||
"MAX_WAIT_TIMEOUT_SECONDS",
|
||||
"SESSION_NAME_PREFIX",
|
||||
"TERMINAL_JOB_STATUSES",
|
||||
"DeleteJobResponse",
|
||||
"ErrorDetail",
|
||||
"ErrorResponse",
|
||||
"HealthResponse",
|
||||
"InputJobRequest",
|
||||
"JobInfo",
|
||||
"JobResult",
|
||||
"JobStatusName",
|
||||
"JobStatusView",
|
||||
"ListJobsResponse",
|
||||
"OutputWindow",
|
||||
"RunJobRequest",
|
||||
"ShellctlModel",
|
||||
"TerminalSize",
|
||||
"TerminateJobRequest",
|
||||
"WaitJobRequest",
|
||||
"default_runtime_dir",
|
||||
"default_state_dir",
|
||||
"format_timestamp",
|
||||
"generate_job_id",
|
||||
"is_terminal_status",
|
||||
"job_pane_target",
|
||||
"job_session_name",
|
||||
"parse_timestamp",
|
||||
"read_output_window",
|
||||
"tail_output_window",
|
||||
"utc_now",
|
||||
]
|
||||
|
||||
_EXPORTS = {
|
||||
"DEFAULT_AUTH_TOKEN_ENV": "shellctl.shared.constants",
|
||||
"DEFAULT_BASE_URL": "shellctl.shared.constants",
|
||||
"DEFAULT_BASE_URL_ENV": "shellctl.shared.constants",
|
||||
"DEFAULT_GC_FINISHED_JOB_RETENTION_SECONDS": "shellctl.shared.constants",
|
||||
"DEFAULT_GC_INTERVAL_SECONDS": "shellctl.shared.constants",
|
||||
"DEFAULT_HEALTH_STATUS": "shellctl.shared.constants",
|
||||
"DEFAULT_IDLE_FLUSH_SECONDS": "shellctl.shared.constants",
|
||||
"DEFAULT_LIST_LIMIT": "shellctl.shared.constants",
|
||||
"DEFAULT_OUTPUT_LIMIT_BYTES": "shellctl.shared.constants",
|
||||
"DEFAULT_TERMINAL_COLS": "shellctl.shared.constants",
|
||||
"DEFAULT_TERMINAL_ROWS": "shellctl.shared.constants",
|
||||
"DEFAULT_TERMINATE_GRACE_SECONDS": "shellctl.shared.constants",
|
||||
"DEFAULT_TIMEOUT_SECONDS": "shellctl.shared.constants",
|
||||
"JOB_ID_ALPHABET": "shellctl.shared.constants",
|
||||
"JOB_ID_RANDOM_SUFFIX_LENGTH": "shellctl.shared.constants",
|
||||
"MAX_LIST_LIMIT": "shellctl.shared.constants",
|
||||
"MAX_OUTPUT_LIMIT_BYTES": "shellctl.shared.constants",
|
||||
"MAX_WAIT_TIMEOUT_SECONDS": "shellctl.shared.constants",
|
||||
"SESSION_NAME_PREFIX": "shellctl.shared.constants",
|
||||
"OutputWindow": "shellctl.shared.output",
|
||||
"read_output_window": "shellctl.shared.output",
|
||||
"tail_output_window": "shellctl.shared.output",
|
||||
"default_runtime_dir": "shellctl.shared.runtime",
|
||||
"default_state_dir": "shellctl.shared.runtime",
|
||||
"format_timestamp": "shellctl.shared.runtime",
|
||||
"generate_job_id": "shellctl.shared.runtime",
|
||||
"is_terminal_status": "shellctl.shared.runtime",
|
||||
"job_pane_target": "shellctl.shared.runtime",
|
||||
"job_session_name": "shellctl.shared.runtime",
|
||||
"parse_timestamp": "shellctl.shared.runtime",
|
||||
"utc_now": "shellctl.shared.runtime",
|
||||
"TERMINAL_JOB_STATUSES": "shellctl.shared.schemas",
|
||||
"DeleteJobResponse": "shellctl.shared.schemas",
|
||||
"ErrorDetail": "shellctl.shared.schemas",
|
||||
"ErrorResponse": "shellctl.shared.schemas",
|
||||
"HealthResponse": "shellctl.shared.schemas",
|
||||
"InputJobRequest": "shellctl.shared.schemas",
|
||||
"JobInfo": "shellctl.shared.schemas",
|
||||
"JobResult": "shellctl.shared.schemas",
|
||||
"JobStatusName": "shellctl.shared.schemas",
|
||||
"JobStatusView": "shellctl.shared.schemas",
|
||||
"ListJobsResponse": "shellctl.shared.schemas",
|
||||
"RunJobRequest": "shellctl.shared.schemas",
|
||||
"ShellctlModel": "shellctl.shared.schemas",
|
||||
"TerminalSize": "shellctl.shared.schemas",
|
||||
"TerminateJobRequest": "shellctl.shared.schemas",
|
||||
"WaitJobRequest": "shellctl.shared.schemas",
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
if name not in _EXPORTS:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
module = import_module(_EXPORTS[name])
|
||||
value = getattr(module, name)
|
||||
globals()[name] = value
|
||||
return value
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return sorted(set(globals()) | set(__all__))
|
||||
@ -1,49 +0,0 @@
|
||||
"""Shared shellctl constants.
|
||||
|
||||
This module intentionally contains only literal defaults and bounds so callers
|
||||
can import stable configuration values without pulling in the heavier DTO,
|
||||
sanitize, or server packages. The network CLI depends on this file for its
|
||||
base URL and auth-token environment contract, so keep it import-light.
|
||||
"""
|
||||
|
||||
DEFAULT_AUTH_TOKEN_ENV = "SHELLCTL_AUTH_TOKEN"
|
||||
DEFAULT_BASE_URL_ENV = "SHELLCTL_BASE_URL"
|
||||
DEFAULT_BASE_URL = "http://127.0.0.1:8765"
|
||||
DEFAULT_OUTPUT_LIMIT_BYTES = 1024 * 8
|
||||
MAX_OUTPUT_LIMIT_BYTES = 1024 * 1024
|
||||
DEFAULT_TIMEOUT_SECONDS = 30.0
|
||||
MAX_WAIT_TIMEOUT_SECONDS = 5.0 * 60.0
|
||||
DEFAULT_IDLE_FLUSH_SECONDS = 0.5
|
||||
DEFAULT_TERMINATE_GRACE_SECONDS = 5.0
|
||||
DEFAULT_TERMINAL_COLS = 120
|
||||
DEFAULT_TERMINAL_ROWS = 80
|
||||
DEFAULT_LIST_LIMIT = 100
|
||||
MAX_LIST_LIMIT = 1000
|
||||
DEFAULT_GC_INTERVAL_SECONDS = 10.0 * 60.0
|
||||
DEFAULT_GC_FINISHED_JOB_RETENTION_SECONDS = 24.0 * 60.0 * 60.0
|
||||
JOB_ID_ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz"
|
||||
JOB_ID_RANDOM_SUFFIX_LENGTH = 3
|
||||
SESSION_NAME_PREFIX = "shellctl-job-"
|
||||
DEFAULT_HEALTH_STATUS = "ok"
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_AUTH_TOKEN_ENV",
|
||||
"DEFAULT_BASE_URL",
|
||||
"DEFAULT_BASE_URL_ENV",
|
||||
"DEFAULT_GC_FINISHED_JOB_RETENTION_SECONDS",
|
||||
"DEFAULT_GC_INTERVAL_SECONDS",
|
||||
"DEFAULT_HEALTH_STATUS",
|
||||
"DEFAULT_IDLE_FLUSH_SECONDS",
|
||||
"DEFAULT_LIST_LIMIT",
|
||||
"DEFAULT_OUTPUT_LIMIT_BYTES",
|
||||
"DEFAULT_TERMINAL_COLS",
|
||||
"DEFAULT_TERMINAL_ROWS",
|
||||
"DEFAULT_TERMINATE_GRACE_SECONDS",
|
||||
"DEFAULT_TIMEOUT_SECONDS",
|
||||
"JOB_ID_ALPHABET",
|
||||
"JOB_ID_RANDOM_SUFFIX_LENGTH",
|
||||
"MAX_LIST_LIMIT",
|
||||
"MAX_OUTPUT_LIMIT_BYTES",
|
||||
"MAX_WAIT_TIMEOUT_SECONDS",
|
||||
"SESSION_NAME_PREFIX",
|
||||
]
|
||||
@ -1,120 +0,0 @@
|
||||
"""UTF-8-safe output slicing helpers for shellctl."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class OutputWindow:
|
||||
"""UTF-8-safe slice of `output.log`."""
|
||||
|
||||
output: str
|
||||
offset: int
|
||||
truncated: bool
|
||||
|
||||
|
||||
def read_output_window(path: Path, *, offset: int, limit: int) -> OutputWindow:
|
||||
"""Read a forward UTF-8-safe slice from `output.log`."""
|
||||
|
||||
if offset < 0:
|
||||
raise ValueError(f"offset must be >= 0; got {offset}")
|
||||
if limit <= 0:
|
||||
raise ValueError(f"limit must be > 0; got {limit}")
|
||||
|
||||
if not path.exists():
|
||||
if offset == 0:
|
||||
return OutputWindow(output="", offset=0, truncated=False)
|
||||
raise ValueError(f"offset {offset} exceeds current file size 0")
|
||||
|
||||
size = path.stat().st_size
|
||||
if offset > size:
|
||||
raise ValueError(f"offset {offset} exceeds current file size {size}")
|
||||
if offset == size:
|
||||
return OutputWindow(output="", offset=offset, truncated=False)
|
||||
|
||||
with path.open("rb") as handle:
|
||||
handle.seek(offset)
|
||||
raw = handle.read(limit + 4)
|
||||
|
||||
start_shift = _advance_to_utf8_boundary(raw, 0)
|
||||
data = raw[start_shift:]
|
||||
budget = max(0, limit - start_shift)
|
||||
consumed = _valid_utf8_prefix_length(data[:budget])
|
||||
if consumed == 0 and data:
|
||||
consumed = _first_complete_utf8_char_length(data)
|
||||
output_bytes = data[:consumed]
|
||||
new_offset = offset + start_shift + consumed
|
||||
truncated = new_offset < size
|
||||
return OutputWindow(
|
||||
output=output_bytes.decode("utf-8", errors="strict"),
|
||||
offset=new_offset,
|
||||
truncated=truncated,
|
||||
)
|
||||
|
||||
|
||||
def tail_output_window(path: Path, *, limit: int) -> OutputWindow:
|
||||
"""Read a UTF-8-safe tail snapshot from `output.log`."""
|
||||
|
||||
if limit <= 0:
|
||||
raise ValueError(f"limit must be > 0; got {limit}")
|
||||
if not path.exists():
|
||||
return OutputWindow(output="", offset=0, truncated=False)
|
||||
size = path.stat().st_size
|
||||
if size == 0:
|
||||
return OutputWindow(output="", offset=0, truncated=False)
|
||||
start = max(0, size - limit)
|
||||
padded_start = max(0, start - 4)
|
||||
with path.open("rb") as handle:
|
||||
handle.seek(padded_start)
|
||||
raw = handle.read(size - padded_start)
|
||||
relative_start = _advance_to_utf8_boundary(raw, start - padded_start)
|
||||
payload = raw[relative_start:]
|
||||
consumed = _valid_utf8_prefix_length(payload)
|
||||
output_bytes = payload[:consumed]
|
||||
return OutputWindow(
|
||||
output=output_bytes.decode("utf-8", errors="strict"),
|
||||
offset=padded_start + relative_start + consumed,
|
||||
truncated=False,
|
||||
)
|
||||
|
||||
|
||||
def _advance_to_utf8_boundary(data: bytes, start: int) -> int:
|
||||
while start < len(data) and _is_utf8_continuation_byte(data[start]):
|
||||
start += 1
|
||||
return start
|
||||
|
||||
|
||||
def _valid_utf8_prefix_length(data: bytes) -> int:
|
||||
end = len(data)
|
||||
while end >= 0:
|
||||
try:
|
||||
data[:end].decode("utf-8", errors="strict")
|
||||
return end
|
||||
except UnicodeDecodeError as exc:
|
||||
if exc.start < end - 4:
|
||||
end = exc.start
|
||||
else:
|
||||
end -= 1
|
||||
return 0
|
||||
|
||||
|
||||
def _is_utf8_continuation_byte(value: int) -> bool:
|
||||
return (value & 0b1100_0000) == 0b1000_0000
|
||||
|
||||
|
||||
def _first_complete_utf8_char_length(data: bytes) -> int:
|
||||
for end in range(1, len(data) + 1):
|
||||
try:
|
||||
decoded = data[:end].decode("utf-8", errors="strict")
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
if len(decoded) == 1:
|
||||
return end
|
||||
if decoded:
|
||||
return len(decoded[0].encode("utf-8"))
|
||||
return 0
|
||||
|
||||
|
||||
__all__ = ["OutputWindow", "read_output_window", "tail_output_window"]
|
||||
@ -1,97 +0,0 @@
|
||||
"""Stable runtime/naming helpers shared across shellctl modules."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import secrets
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from shellctl.shared.constants import (
|
||||
JOB_ID_ALPHABET,
|
||||
JOB_ID_RANDOM_SUFFIX_LENGTH,
|
||||
SESSION_NAME_PREFIX,
|
||||
)
|
||||
from shellctl.shared.schemas import (
|
||||
TERMINAL_JOB_STATUSES,
|
||||
JobStatusName,
|
||||
)
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
"""Return the current UTC time with timezone information."""
|
||||
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def format_timestamp(value: datetime | None = None) -> str:
|
||||
"""Format a UTC timestamp in the stable artifact/API representation."""
|
||||
|
||||
moment = (value or utc_now()).astimezone(UTC).replace(microsecond=0)
|
||||
return moment.isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def parse_timestamp(value: str) -> datetime:
|
||||
"""Parse an artifact/API timestamp back into a timezone-aware datetime."""
|
||||
|
||||
return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(UTC)
|
||||
|
||||
|
||||
def is_terminal_status(status: JobStatusName) -> bool:
|
||||
"""Check whether a lifecycle state is terminal."""
|
||||
|
||||
return status in TERMINAL_JOB_STATUSES
|
||||
|
||||
|
||||
def generate_job_id(*, now: datetime | None = None) -> str:
|
||||
"""Generate a short, human-readable job id."""
|
||||
|
||||
timestamp = (now or utc_now()).astimezone(UTC).strftime("%m%d%H%M")
|
||||
suffix = "".join(
|
||||
secrets.choice(JOB_ID_ALPHABET) for _ in range(JOB_ID_RANDOM_SUFFIX_LENGTH)
|
||||
)
|
||||
return f"{timestamp}-{suffix}"
|
||||
|
||||
|
||||
def job_session_name(job_id: str) -> str:
|
||||
"""Return the dedicated tmux session name for a job."""
|
||||
|
||||
return f"{SESSION_NAME_PREFIX}{job_id}"
|
||||
|
||||
|
||||
def job_pane_target(job_id: str) -> str:
|
||||
"""Return the canonical tmux pane target for a single-pane job session."""
|
||||
|
||||
return f"{job_session_name(job_id)}:0.0"
|
||||
|
||||
|
||||
def default_state_dir() -> Path:
|
||||
"""Resolve the XDG-style default shellctl state directory."""
|
||||
|
||||
xdg_state_home = os.environ.get("XDG_STATE_HOME")
|
||||
if xdg_state_home:
|
||||
return Path(xdg_state_home) / "shellctl"
|
||||
return Path.home() / ".local" / "state" / "shellctl"
|
||||
|
||||
|
||||
def default_runtime_dir(state_dir: Path | None = None) -> Path:
|
||||
"""Resolve the XDG-style default shellctl runtime directory."""
|
||||
|
||||
xdg_runtime_dir = os.environ.get("XDG_RUNTIME_DIR")
|
||||
if xdg_runtime_dir:
|
||||
return Path(xdg_runtime_dir) / "shellctl"
|
||||
base_state_dir = state_dir or default_state_dir()
|
||||
return base_state_dir / "run" / "shellctl"
|
||||
|
||||
|
||||
__all__ = [
|
||||
"default_runtime_dir",
|
||||
"default_state_dir",
|
||||
"format_timestamp",
|
||||
"generate_job_id",
|
||||
"is_terminal_status",
|
||||
"job_pane_target",
|
||||
"job_session_name",
|
||||
"parse_timestamp",
|
||||
"utc_now",
|
||||
]
|
||||
@ -1,225 +0,0 @@
|
||||
"""Shared pydantic transport models for shellctl."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from shellctl.shared.constants import (
|
||||
DEFAULT_HEALTH_STATUS,
|
||||
DEFAULT_IDLE_FLUSH_SECONDS,
|
||||
DEFAULT_OUTPUT_LIMIT_BYTES,
|
||||
DEFAULT_TERMINAL_COLS,
|
||||
DEFAULT_TERMINAL_ROWS,
|
||||
DEFAULT_TERMINATE_GRACE_SECONDS,
|
||||
DEFAULT_TIMEOUT_SECONDS,
|
||||
MAX_OUTPUT_LIMIT_BYTES,
|
||||
MAX_WAIT_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
|
||||
class ShellctlModel(BaseModel):
|
||||
"""Base pydantic model with strict extra-field handling.
|
||||
|
||||
shellctl uses these DTOs directly for HTTP request/response bodies, so
|
||||
silently accepting unknown fields would make it harder to detect schema
|
||||
drift.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class JobStatusName(StrEnum):
|
||||
"""Lifecycle states materialized into SQLite rows and API responses."""
|
||||
|
||||
CREATED = "created"
|
||||
STARTING = "starting"
|
||||
RUNNING = "running"
|
||||
EXITED = "exited"
|
||||
TERMINATED = "terminated"
|
||||
FAILED = "failed"
|
||||
LOST = "lost"
|
||||
|
||||
|
||||
TERMINAL_JOB_STATUSES = frozenset(
|
||||
{
|
||||
JobStatusName.EXITED,
|
||||
JobStatusName.TERMINATED,
|
||||
JobStatusName.FAILED,
|
||||
JobStatusName.LOST,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class TerminalSize(ShellctlModel):
|
||||
"""Requested initial PTY geometry for a job."""
|
||||
|
||||
cols: int = Field(default=DEFAULT_TERMINAL_COLS, ge=1, le=4096)
|
||||
rows: int = Field(default=DEFAULT_TERMINAL_ROWS, ge=1, le=4096)
|
||||
|
||||
|
||||
class JobResult(ShellctlModel):
|
||||
"""Unified response shape for output-oriented job APIs."""
|
||||
|
||||
job_id: str
|
||||
done: bool
|
||||
status: JobStatusName
|
||||
exit_code: int | None = None
|
||||
output_path: str
|
||||
output: str
|
||||
offset: int = Field(ge=0)
|
||||
truncated: bool
|
||||
|
||||
|
||||
class JobStatusView(ShellctlModel):
|
||||
"""Materialized lifecycle view returned by status-like APIs."""
|
||||
|
||||
job_id: str
|
||||
status: JobStatusName
|
||||
done: bool
|
||||
exit_code: int | None = None
|
||||
created_at: str
|
||||
started_at: str | None = None
|
||||
ended_at: str | None = None
|
||||
offset: int = Field(ge=0)
|
||||
|
||||
|
||||
class JobInfo(ShellctlModel):
|
||||
"""Compact job listing record."""
|
||||
|
||||
job_id: str
|
||||
status: JobStatusName
|
||||
created_at: str
|
||||
started_at: str | None = None
|
||||
ended_at: str | None = None
|
||||
|
||||
|
||||
class ListJobsResponse(ShellctlModel):
|
||||
"""Response body for `GET /v1/jobs`."""
|
||||
|
||||
jobs: list[JobInfo]
|
||||
|
||||
|
||||
class DeleteJobResponse(ShellctlModel):
|
||||
"""Response body for successful delete operations."""
|
||||
|
||||
job_id: str
|
||||
deleted: bool = True
|
||||
|
||||
|
||||
class HealthResponse(ShellctlModel):
|
||||
"""Public health check response."""
|
||||
|
||||
status: str = DEFAULT_HEALTH_STATUS
|
||||
|
||||
|
||||
class ErrorDetail(ShellctlModel):
|
||||
"""Machine-readable API error payload."""
|
||||
|
||||
code: str
|
||||
message: str
|
||||
|
||||
|
||||
class ErrorResponse(ShellctlModel):
|
||||
"""Envelope used by server-side exception handlers."""
|
||||
|
||||
error: ErrorDetail
|
||||
|
||||
|
||||
class RunJobRequest(ShellctlModel):
|
||||
"""HTTP request body for `POST /v1/jobs/run`.
|
||||
|
||||
`env` augments the runner's inherited process environment instead of
|
||||
replacing it, so callers can preset script-local variables without losing
|
||||
ambient values such as `PATH`.
|
||||
"""
|
||||
|
||||
script: str
|
||||
cwd: str | None = None
|
||||
env: dict[str, str] | None = None
|
||||
terminal: TerminalSize | None = None
|
||||
timeout: float = Field(
|
||||
default=DEFAULT_TIMEOUT_SECONDS, gt=0, le=MAX_WAIT_TIMEOUT_SECONDS
|
||||
)
|
||||
output_limit: int = Field(
|
||||
default=DEFAULT_OUTPUT_LIMIT_BYTES, ge=1, le=MAX_OUTPUT_LIMIT_BYTES
|
||||
)
|
||||
idle_flush_seconds: float = Field(default=DEFAULT_IDLE_FLUSH_SECONDS, ge=0, le=30)
|
||||
|
||||
@field_validator("env")
|
||||
@classmethod
|
||||
def _validate_env(cls, env: dict[str, str] | None) -> dict[str, str] | None:
|
||||
"""Reject env entries that cannot be represented in `execve`.
|
||||
|
||||
shellctl applies `env` as a process environment overlay, so validation
|
||||
follows the low-level `NAME=value` constraints instead of shell variable
|
||||
naming rules: names must be non-empty and cannot contain `=` or NUL,
|
||||
while values cannot contain NUL.
|
||||
"""
|
||||
|
||||
if env is None:
|
||||
return None
|
||||
for name, value in env.items():
|
||||
if not name:
|
||||
raise ValueError("env names must be non-empty")
|
||||
if "=" in name:
|
||||
raise ValueError(f"env name must not contain '=': {name!r}")
|
||||
if "\x00" in name:
|
||||
raise ValueError(f"env name must not contain NUL: {name!r}")
|
||||
if "\x00" in value:
|
||||
raise ValueError(f"env value must not contain NUL: {name!r}")
|
||||
return env
|
||||
|
||||
|
||||
class WaitJobRequest(ShellctlModel):
|
||||
"""HTTP request body for `POST /v1/jobs/{job_id}/wait`."""
|
||||
|
||||
timeout: float = Field(
|
||||
default=DEFAULT_TIMEOUT_SECONDS, ge=0, le=MAX_WAIT_TIMEOUT_SECONDS
|
||||
)
|
||||
offset: int = Field(ge=0)
|
||||
output_limit: int = Field(
|
||||
default=DEFAULT_OUTPUT_LIMIT_BYTES, ge=1, le=MAX_OUTPUT_LIMIT_BYTES
|
||||
)
|
||||
idle_flush_seconds: float = Field(default=DEFAULT_IDLE_FLUSH_SECONDS, ge=0, le=30)
|
||||
|
||||
|
||||
class InputJobRequest(ShellctlModel):
|
||||
"""HTTP request body for `POST /v1/jobs/{job_id}/input`."""
|
||||
|
||||
text: str
|
||||
timeout: float = Field(
|
||||
default=DEFAULT_TIMEOUT_SECONDS, gt=0, le=MAX_WAIT_TIMEOUT_SECONDS
|
||||
)
|
||||
offset: int = Field(ge=0)
|
||||
output_limit: int = Field(
|
||||
default=DEFAULT_OUTPUT_LIMIT_BYTES, ge=1, le=MAX_OUTPUT_LIMIT_BYTES
|
||||
)
|
||||
idle_flush_seconds: float = Field(default=DEFAULT_IDLE_FLUSH_SECONDS, ge=0, le=30)
|
||||
|
||||
|
||||
class TerminateJobRequest(ShellctlModel):
|
||||
"""HTTP request body for `POST /v1/jobs/{job_id}/terminate`."""
|
||||
|
||||
grace_seconds: float = Field(default=DEFAULT_TERMINATE_GRACE_SECONDS, ge=0, le=300)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"TERMINAL_JOB_STATUSES",
|
||||
"DeleteJobResponse",
|
||||
"ErrorDetail",
|
||||
"ErrorResponse",
|
||||
"HealthResponse",
|
||||
"InputJobRequest",
|
||||
"JobInfo",
|
||||
"JobResult",
|
||||
"JobStatusName",
|
||||
"JobStatusView",
|
||||
"ListJobsResponse",
|
||||
"RunJobRequest",
|
||||
"ShellctlModel",
|
||||
"TerminalSize",
|
||||
"TerminateJobRequest",
|
||||
"WaitJobRequest",
|
||||
]
|
||||
@ -1,7 +0,0 @@
|
||||
"""Stdlib-only shellctl subprocess entrypoints.
|
||||
|
||||
This package is reserved for tmux hot-path helpers that must start quickly for
|
||||
every job. Keep `__init__` import-free so `shellctl-sanitize-pty` and
|
||||
`shellctl-runner-exit` do not accidentally pull in the main shellctl client or
|
||||
server stacks.
|
||||
"""
|
||||
@ -1,16 +0,0 @@
|
||||
"""Stdlib-only filesystem helpers shared by shellctl runtime commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_SQLITE_BUSY_TIMEOUT_MS = 5000
|
||||
|
||||
|
||||
def db_path_from_state_dir(state_dir: Path) -> Path:
|
||||
"""Resolve the SQLite database path for a shellctl state directory."""
|
||||
|
||||
return state_dir / "shellctl.db"
|
||||
|
||||
|
||||
__all__ = ["DEFAULT_SQLITE_BUSY_TIMEOUT_MS", "db_path_from_state_dir"]
|
||||
@ -1 +0,0 @@
|
||||
|
||||
@ -1,158 +0,0 @@
|
||||
"""Lightweight SQLite updater for drained shellctl job exits.
|
||||
|
||||
This command runs after the tmux output pipe reaches EOF and flushes
|
||||
`output.log`. It must stay stdlib-only so exit-state materialization does not
|
||||
pay the FastAPI/SQLAlchemy import cost on every job completion. The CLI accepts
|
||||
the busy-timeout override from `ShellctlConfig` so non-default deployments keep
|
||||
the same SQLite locking behavior in and out of process.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from shellctl_runtime.paths import (
|
||||
DEFAULT_SQLITE_BUSY_TIMEOUT_MS,
|
||||
db_path_from_state_dir,
|
||||
)
|
||||
|
||||
TERMINAL_STATUSES = frozenset({"exited", "terminated", "failed", "lost"})
|
||||
NONTERMINAL_STATUSES = frozenset({"created", "starting", "running"})
|
||||
|
||||
|
||||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
"""Parse the tmux finalizer CLI contract."""
|
||||
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--state-dir", required=True, type=Path)
|
||||
parser.add_argument("--job-id", required=True)
|
||||
parser.add_argument("--exit-code", required=True, type=int)
|
||||
parser.add_argument("--ended-at", required=True)
|
||||
parser.add_argument(
|
||||
"--sqlite-busy-timeout-ms",
|
||||
type=int,
|
||||
default=DEFAULT_SQLITE_BUSY_TIMEOUT_MS,
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def record_runner_exit(
|
||||
*,
|
||||
state_dir: Path,
|
||||
job_id: str,
|
||||
exit_code: int,
|
||||
ended_at: str,
|
||||
busy_timeout_ms: int = DEFAULT_SQLITE_BUSY_TIMEOUT_MS,
|
||||
) -> None:
|
||||
"""Persist a drained runner exit directly into the shellctl SQLite DB.
|
||||
|
||||
The update is idempotent for terminal rows: once a job reaches a terminal
|
||||
state, this helper returns successfully without rewriting the row. The
|
||||
terminal/non-terminal decision is repeated inside the `UPDATE` itself so a
|
||||
concurrent writer cannot be clobbered by a stale pre-read.
|
||||
"""
|
||||
|
||||
db_path = db_path_from_state_dir(state_dir)
|
||||
if not db_path.exists():
|
||||
raise FileNotFoundError(f"shellctl database not found: {db_path}")
|
||||
|
||||
connection = sqlite3.connect(db_path, timeout=busy_timeout_ms / 1000)
|
||||
try:
|
||||
connection.execute(f"PRAGMA busy_timeout={busy_timeout_ms}")
|
||||
row = connection.execute(
|
||||
"SELECT status FROM jobs WHERE job_id = ?",
|
||||
(job_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise LookupError(f"Unknown job id: {job_id}")
|
||||
status = str(row[0])
|
||||
if status in TERMINAL_STATUSES:
|
||||
return
|
||||
if status not in NONTERMINAL_STATUSES:
|
||||
raise ValueError(f"Unsupported shellctl job status: {status}")
|
||||
nonterminal = tuple(NONTERMINAL_STATUSES)
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
UPDATE jobs
|
||||
SET status = CASE
|
||||
WHEN status IN (?, ?, ?) THEN ?
|
||||
ELSE status
|
||||
END,
|
||||
exit_code = CASE
|
||||
WHEN status IN (?, ?, ?) THEN ?
|
||||
ELSE exit_code
|
||||
END,
|
||||
ended_at = CASE
|
||||
WHEN status IN (?, ?, ?) THEN ?
|
||||
ELSE ended_at
|
||||
END,
|
||||
updated_at = CASE
|
||||
WHEN status IN (?, ?, ?) THEN ?
|
||||
ELSE updated_at
|
||||
END,
|
||||
reason = CASE
|
||||
WHEN status IN (?, ?, ?) THEN NULL
|
||||
ELSE reason
|
||||
END,
|
||||
message = CASE
|
||||
WHEN status IN (?, ?, ?) THEN NULL
|
||||
ELSE message
|
||||
END
|
||||
WHERE job_id = ?
|
||||
""",
|
||||
(
|
||||
*nonterminal,
|
||||
"exited",
|
||||
*nonterminal,
|
||||
exit_code,
|
||||
*nonterminal,
|
||||
ended_at,
|
||||
*nonterminal,
|
||||
ended_at,
|
||||
*nonterminal,
|
||||
*nonterminal,
|
||||
job_id,
|
||||
),
|
||||
)
|
||||
if cursor.rowcount == 0:
|
||||
raise LookupError(f"Unknown job id: {job_id}")
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> None:
|
||||
"""Run the standalone runner-exit command."""
|
||||
|
||||
args = parse_args(argv)
|
||||
try:
|
||||
record_runner_exit(
|
||||
state_dir=args.state_dir,
|
||||
job_id=args.job_id,
|
||||
exit_code=args.exit_code,
|
||||
ended_at=args.ended_at,
|
||||
busy_timeout_ms=args.sqlite_busy_timeout_ms,
|
||||
)
|
||||
except (
|
||||
FileNotFoundError,
|
||||
LookupError,
|
||||
OSError,
|
||||
sqlite3.DatabaseError,
|
||||
ValueError,
|
||||
) as exc:
|
||||
raise SystemExit(str(exc)) from exc
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"NONTERMINAL_STATUSES",
|
||||
"TERMINAL_STATUSES",
|
||||
"main",
|
||||
"parse_args",
|
||||
"record_runner_exit",
|
||||
]
|
||||
@ -1,186 +0,0 @@
|
||||
"""Lightweight PTY sanitizer used by tmux `pipe-pane`.
|
||||
|
||||
This module stays stdlib-only because shellctl starts it once per job to drain
|
||||
PTY output into `output.log`. The ready-file handshake must happen before any
|
||||
stdin reads so the server can distinguish slow startup from a stuck tmux pipe.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import codecs
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import BinaryIO
|
||||
|
||||
|
||||
class PtySanitizer:
|
||||
"""Incrementally convert PTY bytes into stable, readable UTF-8 text.
|
||||
|
||||
Responsibilities:
|
||||
- preserve UTF-8 decoder state across chunk boundaries
|
||||
- strip common ANSI control sequences without leaking partial escape state
|
||||
- normalize carriage-return progress updates into the final visible line
|
||||
|
||||
This adapter intentionally keeps only minimal terminal state. It aims for a
|
||||
practical log representation rather than a full terminal emulator.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._decoder = codecs.getincrementaldecoder("utf-8")("replace")
|
||||
self._line_buffer = ""
|
||||
self._pending_cr = False
|
||||
self._escape_state = "normal"
|
||||
|
||||
def feed(self, raw: bytes) -> str:
|
||||
"""Consume one PTY byte chunk and return newly stable text."""
|
||||
|
||||
return self._consume_text(self._decoder.decode(raw, final=False), final=False)
|
||||
|
||||
def flush(self) -> str:
|
||||
"""Flush buffered decoder/line state at end-of-stream."""
|
||||
|
||||
tail = self._consume_text(self._decoder.decode(b"", final=True), final=True)
|
||||
if self._line_buffer:
|
||||
tail += self._line_buffer
|
||||
self._line_buffer = ""
|
||||
self._pending_cr = False
|
||||
self._escape_state = "normal"
|
||||
return tail
|
||||
|
||||
def _consume_text(self, text: str, *, final: bool) -> str:
|
||||
parts: list[str] = []
|
||||
for char in text:
|
||||
self._consume_char(char, parts)
|
||||
if final and self._escape_state != "normal":
|
||||
self._escape_state = "normal"
|
||||
return "".join(parts)
|
||||
|
||||
def _consume_char(self, char: str, parts: list[str]) -> None:
|
||||
state = self._escape_state
|
||||
if state == "normal":
|
||||
if char == "\x1b":
|
||||
self._escape_state = "esc"
|
||||
return
|
||||
self._consume_visible_char(char, parts)
|
||||
return
|
||||
if state == "esc":
|
||||
if char == "[":
|
||||
self._escape_state = "csi"
|
||||
return
|
||||
if char == "]":
|
||||
self._escape_state = "osc"
|
||||
return
|
||||
self._escape_state = "normal"
|
||||
if char.isprintable() and char != "\x1b":
|
||||
self._consume_visible_char(char, parts)
|
||||
return
|
||||
if state == "csi":
|
||||
if "@" <= char <= "~":
|
||||
self._escape_state = "normal"
|
||||
return
|
||||
if state == "osc":
|
||||
if char == "\x07":
|
||||
self._escape_state = "normal"
|
||||
return
|
||||
if char == "\x1b":
|
||||
self._escape_state = "osc_esc"
|
||||
return
|
||||
if state == "osc_esc":
|
||||
self._escape_state = "normal" if char == "\\" else "osc"
|
||||
|
||||
def _consume_visible_char(self, char: str, parts: list[str]) -> None:
|
||||
if self._pending_cr:
|
||||
if char == "\n":
|
||||
parts.append(self._line_buffer)
|
||||
parts.append("\n")
|
||||
self._line_buffer = ""
|
||||
self._pending_cr = False
|
||||
return
|
||||
self._line_buffer = ""
|
||||
self._pending_cr = False
|
||||
if char == "\r":
|
||||
self._pending_cr = True
|
||||
return
|
||||
if char == "\n":
|
||||
parts.append(self._line_buffer)
|
||||
parts.append("\n")
|
||||
self._line_buffer = ""
|
||||
return
|
||||
self._line_buffer += char
|
||||
|
||||
|
||||
def sanitize_pty_output(raw: bytes) -> str:
|
||||
"""Sanitize a complete PTY byte string into readable UTF-8 text."""
|
||||
|
||||
sanitizer = PtySanitizer()
|
||||
return sanitizer.feed(raw) + sanitizer.flush()
|
||||
|
||||
|
||||
def sanitize_pty_stream(
|
||||
stdin: BinaryIO,
|
||||
stdout: BinaryIO,
|
||||
*,
|
||||
chunk_size: int = 65536,
|
||||
) -> None:
|
||||
"""Run the streaming PTY sanitizer as a Unix-style filter."""
|
||||
|
||||
sanitizer = PtySanitizer()
|
||||
while True:
|
||||
chunk = stdin.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
output = sanitizer.feed(chunk)
|
||||
if output:
|
||||
stdout.write(output.encode("utf-8"))
|
||||
if hasattr(stdout, "flush"):
|
||||
stdout.flush()
|
||||
tail = sanitizer.flush()
|
||||
if tail:
|
||||
stdout.write(tail.encode("utf-8"))
|
||||
if hasattr(stdout, "flush"):
|
||||
stdout.flush()
|
||||
if hasattr(stdout, "flush"):
|
||||
stdout.flush()
|
||||
|
||||
|
||||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
"""Parse the tiny CLI contract used by tmux `pipe-pane`."""
|
||||
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--ready-file", type=Path)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def run_sanitize_pty(
|
||||
ready_file: Path | None,
|
||||
*,
|
||||
stdin: BinaryIO,
|
||||
stdout: BinaryIO,
|
||||
) -> None:
|
||||
"""Touch the ready file, then sanitize stdin into stdout."""
|
||||
|
||||
if ready_file is not None:
|
||||
ready_file.touch()
|
||||
sanitize_pty_stream(stdin, stdout)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> None:
|
||||
"""Run the standalone PTY sanitizer module."""
|
||||
|
||||
args = parse_args(argv)
|
||||
run_sanitize_pty(args.ready_file, stdin=sys.stdin.buffer, stdout=sys.stdout.buffer)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PtySanitizer",
|
||||
"main",
|
||||
"parse_args",
|
||||
"run_sanitize_pty",
|
||||
"sanitize_pty_output",
|
||||
"sanitize_pty_stream",
|
||||
]
|
||||
@ -6,20 +6,15 @@ import asyncio
|
||||
import base64
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import cast
|
||||
|
||||
import httpx2 as httpx
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from dify_agent.adapters.shell import shellctl
|
||||
from dify_agent.adapters.shell.config import ShellAdapterSettings
|
||||
from dify_agent.adapters.shell.factory import create_shell_provider
|
||||
from dify_agent.adapters.shell.protocols import ShellCommandResult, ShellProviderError
|
||||
from dify_agent.adapters.shell.shellctl import (
|
||||
ShellctlClientProtocol,
|
||||
ShellFileTransferError,
|
||||
ShellctlProvider,
|
||||
)
|
||||
from dify_agent.adapters.shell.shellctl import ShellFileTransferError, ShellctlProvider
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@ -71,73 +66,45 @@ class FakeShellctlClient:
|
||||
delete_calls: list[tuple[str, bool, float | None]] = field(default_factory=list)
|
||||
closed: bool = False
|
||||
|
||||
async def run(
|
||||
self,
|
||||
script: str,
|
||||
*,
|
||||
cwd: str | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
timeout: float = 30.0,
|
||||
) -> _Job:
|
||||
async def run(self, script, *, cwd=None, env=None, timeout=30.0):
|
||||
self.run_calls.append(_RunCall(script=script, cwd=cwd, env=env, timeout=timeout))
|
||||
if self.run_handler is not None:
|
||||
return self.run_handler(script, cwd, env, timeout)
|
||||
return _Job(job_id="job", status="exited", done=True, exit_code=0)
|
||||
|
||||
async def wait(self, job_id: str, *, offset: int, timeout: float = 30.0) -> _Job:
|
||||
async def wait(self, job_id, *, offset, timeout=30.0):
|
||||
self.wait_calls.append((job_id, offset, timeout))
|
||||
if self.wait_handler is not None:
|
||||
return self.wait_handler(job_id, offset, timeout)
|
||||
return _Job(job_id=job_id, status="exited", done=True, offset=offset, exit_code=0)
|
||||
|
||||
async def input(
|
||||
self,
|
||||
job_id: str,
|
||||
text: str,
|
||||
*,
|
||||
offset: int,
|
||||
timeout: float = 30.0,
|
||||
) -> _Job:
|
||||
async def input(self, job_id, text, *, offset, timeout=30.0):
|
||||
self.input_calls.append((job_id, text, offset, timeout))
|
||||
if self.input_handler is not None:
|
||||
return self.input_handler(job_id, text, offset, timeout)
|
||||
return _Job(job_id=job_id, status="exited", done=True, offset=offset, exit_code=0)
|
||||
|
||||
async def tail(self, job_id: str) -> _Job:
|
||||
async def tail(self, job_id):
|
||||
if self.tail_handler is not None:
|
||||
return self.tail_handler(job_id)
|
||||
return _Job(job_id=job_id, status="exited", done=True, output="", exit_code=0)
|
||||
|
||||
async def terminate(self, job_id: str, grace_seconds: float = 10.0) -> _Status:
|
||||
async def terminate(self, job_id, grace_seconds=10.0):
|
||||
self.terminate_calls.append((job_id, grace_seconds))
|
||||
if self.terminate_handler is not None:
|
||||
return self.terminate_handler(job_id, grace_seconds)
|
||||
return _Status(job_id=job_id)
|
||||
|
||||
async def delete(
|
||||
self,
|
||||
job_id: str,
|
||||
*,
|
||||
force: bool = False,
|
||||
grace_seconds: float | None = None,
|
||||
) -> None:
|
||||
async def delete(self, job_id, *, force=False, grace_seconds=None):
|
||||
self.delete_calls.append((job_id, force, grace_seconds))
|
||||
return None
|
||||
|
||||
async def close(self) -> None:
|
||||
async def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
def _provider(client: FakeShellctlClient) -> ShellctlProvider:
|
||||
return ShellctlProvider(
|
||||
entrypoint="http://shellctl",
|
||||
token="",
|
||||
client_factory=lambda: _client_protocol(client),
|
||||
)
|
||||
|
||||
|
||||
def _client_protocol(client: FakeShellctlClient) -> ShellctlClientProtocol:
|
||||
return cast(ShellctlClientProtocol, cast(object, client))
|
||||
return ShellctlProvider(entrypoint="http://shellctl", token="", client_factory=lambda: client)
|
||||
|
||||
|
||||
def test_factory_unknown_provider_raises() -> None:
|
||||
@ -387,10 +354,7 @@ def test_file_transfer_timeout_is_an_end_to_end_budget(monkeypatch: pytest.Monke
|
||||
client = FakeShellctlClient(run_handler=run_handler, wait_handler=wait_handler)
|
||||
|
||||
async def scenario() -> None:
|
||||
transfer = shellctl.ShellctlFileTransfer(
|
||||
client=_client_protocol(client),
|
||||
timeout=5.0,
|
||||
)
|
||||
transfer = shellctl.ShellctlFileTransfer(client=client, timeout=5.0)
|
||||
await transfer.upload(content=b"payload", remote_path="out.bin")
|
||||
|
||||
asyncio.run(scenario())
|
||||
@ -416,10 +380,7 @@ def test_file_transfer_timeout_exhaustion_raises_timeout_and_still_deletes_job(
|
||||
client = FakeShellctlClient(run_handler=run_handler)
|
||||
|
||||
async def scenario() -> None:
|
||||
transfer = shellctl.ShellctlFileTransfer(
|
||||
client=_client_protocol(client),
|
||||
timeout=5.0,
|
||||
)
|
||||
transfer = shellctl.ShellctlFileTransfer(client=client, timeout=5.0)
|
||||
with pytest.raises(ShellProviderError, match="timed out") as exc_info:
|
||||
await transfer.upload(content=b"payload", remote_path="out.bin")
|
||||
assert exc_info.value.code == "timeout"
|
||||
|
||||
@ -64,7 +64,7 @@ from dify_agent.protocol.schemas import (
|
||||
from dify_agent.runtime.event_sink import InMemoryRunEventSink
|
||||
from dify_agent.runtime.compositor_factory import create_default_layer_providers
|
||||
from dify_agent.runtime.runner import AgentRunRunner, AgentRunValidationError
|
||||
from shellctl.shared import DeleteJobResponse, JobResult, JobStatusName, JobStatusView
|
||||
from shell_session_manager.shellctl.shared import DeleteJobResponse, JobResult, JobStatusName, JobStatusView
|
||||
|
||||
|
||||
class StaticToolsTestLayer(ToolsLayer):
|
||||
|
||||
@ -121,8 +121,8 @@ def test_protocol_and_dify_plugin_exports_do_not_import_server_only_modules() ->
|
||||
"openai",
|
||||
"pydantic_settings",
|
||||
"redis",
|
||||
"shellctl.client",
|
||||
"shellctl.server",
|
||||
"shell_session_manager.shellctl.client",
|
||||
"shell_session_manager.shellctl.server",
|
||||
],
|
||||
imports=[
|
||||
"dify_agent.protocol",
|
||||
@ -158,7 +158,7 @@ def test_agent_stub_cli_main_import_is_client_safe() -> None:
|
||||
"jwcrypto",
|
||||
"pydantic_settings",
|
||||
"redis",
|
||||
"shellctl.server",
|
||||
"shell_session_manager",
|
||||
],
|
||||
imports=[
|
||||
"dify_agent.agent_stub.client",
|
||||
@ -238,7 +238,7 @@ def test_agent_stub_cli_help_render_does_not_load_server_modules() -> None:
|
||||
"jwcrypto",
|
||||
"pydantic_settings",
|
||||
"redis",
|
||||
"shellctl.server",
|
||||
"shell_session_manager",
|
||||
]
|
||||
script = "\n".join(
|
||||
[
|
||||
@ -269,28 +269,6 @@ def test_agent_stub_cli_help_render_does_not_load_server_modules() -> None:
|
||||
_run_python_script(script)
|
||||
|
||||
|
||||
def test_shellctl_client_imports_do_not_import_server_modules() -> None:
|
||||
_run_import_check(
|
||||
blocked_imports=[
|
||||
"aiosqlite",
|
||||
"fastapi",
|
||||
"sqlalchemy",
|
||||
"sqlmodel",
|
||||
"shellctl.server.api",
|
||||
"shellctl.server.service",
|
||||
"shellctl.server.tmux",
|
||||
"uvicorn",
|
||||
],
|
||||
imports=["shellctl", "shellctl.client", "shellctl.shared", "shellctl.cli"],
|
||||
assertions=[
|
||||
"assert hasattr(shellctl, 'ShellctlClient')",
|
||||
"assert hasattr(shellctl_client, 'ShellctlClient')",
|
||||
"assert hasattr(shellctl_shared, 'JobResult')",
|
||||
"assert hasattr(shellctl_cli, 'cli')",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_server_settings_import_does_not_import_agent_stub_app() -> None:
|
||||
try:
|
||||
__import__("pydantic_settings")
|
||||
|
||||
@ -1,32 +0,0 @@
|
||||
[
|
||||
{
|
||||
"name": "utf8_passthrough",
|
||||
"chunks_hex": ["68656c6c6f20e4b896e7958cf09f99820a"],
|
||||
"expected": "hello 世界🙂\n"
|
||||
},
|
||||
{
|
||||
"name": "split_utf8_chunks",
|
||||
"chunks_hex": ["e4bda0e5a5bd20f09f", "99820a"],
|
||||
"expected": "你好 🙂\n"
|
||||
},
|
||||
{
|
||||
"name": "invalid_utf8_replacement",
|
||||
"chunks_hex": ["666f80ff6f0a"],
|
||||
"expected": "fo<66><6F>o\n"
|
||||
},
|
||||
{
|
||||
"name": "ansi_sequences_removed",
|
||||
"chunks_hex": ["1b5b33316d7265641b5b306d1b5b4b0a"],
|
||||
"expected": "red\n"
|
||||
},
|
||||
{
|
||||
"name": "carriage_return_progress",
|
||||
"chunks_hex": ["30250d3530250d313030250a"],
|
||||
"expected": "100%\n"
|
||||
},
|
||||
{
|
||||
"name": "crlf_normalized",
|
||||
"chunks_hex": ["6f6b0d0a6e6578740d0a"],
|
||||
"expected": "ok\nnext\n"
|
||||
}
|
||||
]
|
||||
@ -1,699 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import ClassVar, TypeVar, cast
|
||||
|
||||
import click
|
||||
import httpx2 as httpx
|
||||
import pytest
|
||||
import typer.main
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from shellctl.client import ShellctlClientError
|
||||
from shellctl.shared.constants import DEFAULT_BASE_URL
|
||||
from shellctl.shared.schemas import (
|
||||
DeleteJobResponse,
|
||||
HealthResponse,
|
||||
JobInfo,
|
||||
JobResult,
|
||||
JobStatusName,
|
||||
JobStatusView,
|
||||
TerminalSize,
|
||||
)
|
||||
|
||||
cli_module = importlib.import_module("shellctl.cli")
|
||||
cli = cli_module.cli
|
||||
runner = CliRunner()
|
||||
ResultT = TypeVar("ResultT")
|
||||
|
||||
|
||||
def _click_command(command_name: str) -> click.Command:
|
||||
return cast(click.Group, typer.main.get_command(cli)).commands[command_name]
|
||||
|
||||
|
||||
def _command_option_names(command_name: str) -> set[str]:
|
||||
command = _click_command(command_name)
|
||||
return {
|
||||
option
|
||||
for parameter in command.params
|
||||
for option in getattr(parameter, "opts", [])
|
||||
}
|
||||
|
||||
|
||||
def _command_option(command_name: str, option_name: str) -> click.Option:
|
||||
command = _click_command(command_name)
|
||||
for parameter in command.params:
|
||||
if isinstance(parameter, click.Option) and option_name in parameter.opts:
|
||||
return parameter
|
||||
raise AssertionError(f"{option_name} not found on {command_name}")
|
||||
|
||||
|
||||
class RecordingShellctlClient:
|
||||
init_calls: ClassVar[list[dict[str, object]]] = []
|
||||
calls: ClassVar[list[tuple[str, tuple[object, ...], dict[str, object]]]] = []
|
||||
results: ClassVar[dict[str, object]] = {}
|
||||
error: ClassVar[BaseException | None] = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
*,
|
||||
output_limit: int,
|
||||
idle_flush_seconds: float,
|
||||
token: str | None = None,
|
||||
client: object | None = None,
|
||||
transport: object | None = None,
|
||||
) -> None:
|
||||
del client, transport
|
||||
type(self).init_calls.append(
|
||||
{
|
||||
"base_url": base_url,
|
||||
"output_limit": output_limit,
|
||||
"idle_flush_seconds": idle_flush_seconds,
|
||||
"token": token,
|
||||
}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def reset(cls) -> None:
|
||||
cls.init_calls = []
|
||||
cls.calls = []
|
||||
cls.results = {}
|
||||
cls.error = None
|
||||
|
||||
async def __aenter__(self) -> RecordingShellctlClient:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None:
|
||||
return None
|
||||
|
||||
def _result(self, method: str, result_type: type[ResultT]) -> ResultT:
|
||||
del result_type
|
||||
error = type(self).error
|
||||
if error is not None:
|
||||
raise error
|
||||
return cast(ResultT, type(self).results[method])
|
||||
|
||||
async def health(self) -> HealthResponse:
|
||||
type(self).calls.append(("health", (), {}))
|
||||
return self._result("health", HealthResponse)
|
||||
|
||||
async def run(
|
||||
self,
|
||||
script: str,
|
||||
*,
|
||||
cwd: str | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
timeout: float,
|
||||
terminal: TerminalSize | None = None,
|
||||
) -> JobResult:
|
||||
type(self).calls.append(
|
||||
(
|
||||
"run",
|
||||
(script,),
|
||||
{
|
||||
"cwd": cwd,
|
||||
"env": env,
|
||||
"timeout": timeout,
|
||||
"terminal": terminal,
|
||||
},
|
||||
)
|
||||
)
|
||||
return self._result("run", JobResult)
|
||||
|
||||
async def wait(self, job_id: str, *, offset: int, timeout: float) -> JobResult:
|
||||
type(self).calls.append(
|
||||
("wait", (job_id,), {"offset": offset, "timeout": timeout})
|
||||
)
|
||||
return self._result("wait", JobResult)
|
||||
|
||||
async def status(self, job_id: str) -> JobStatusView:
|
||||
type(self).calls.append(("status", (job_id,), {}))
|
||||
return self._result("status", JobStatusView)
|
||||
|
||||
async def list_jobs(
|
||||
self,
|
||||
*,
|
||||
status: JobStatusName | None = None,
|
||||
limit: int,
|
||||
) -> list[JobInfo]:
|
||||
type(self).calls.append(("list_jobs", (), {"status": status, "limit": limit}))
|
||||
return cast(list[JobInfo], self._result("list_jobs", list))
|
||||
|
||||
async def input(
|
||||
self,
|
||||
job_id: str,
|
||||
text: str,
|
||||
*,
|
||||
offset: int,
|
||||
timeout: float,
|
||||
) -> JobResult:
|
||||
type(self).calls.append(
|
||||
(
|
||||
"input",
|
||||
(job_id, text),
|
||||
{"offset": offset, "timeout": timeout},
|
||||
)
|
||||
)
|
||||
return self._result("input", JobResult)
|
||||
|
||||
async def tail(self, job_id: str) -> JobResult:
|
||||
type(self).calls.append(("tail", (job_id,), {}))
|
||||
return self._result("tail", JobResult)
|
||||
|
||||
async def terminate(self, job_id: str, grace_seconds: float) -> JobStatusView:
|
||||
type(self).calls.append(
|
||||
("terminate", (job_id,), {"grace_seconds": grace_seconds})
|
||||
)
|
||||
return self._result("terminate", JobStatusView)
|
||||
|
||||
async def delete(
|
||||
self,
|
||||
job_id: str,
|
||||
*,
|
||||
force: bool = False,
|
||||
grace_seconds: float | None = None,
|
||||
) -> DeleteJobResponse:
|
||||
type(self).calls.append(
|
||||
(
|
||||
"delete",
|
||||
(job_id,),
|
||||
{"force": force, "grace_seconds": grace_seconds},
|
||||
)
|
||||
)
|
||||
return self._result("delete", DeleteJobResponse)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patched_client(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> type[RecordingShellctlClient]:
|
||||
RecordingShellctlClient.reset()
|
||||
monkeypatch.setattr(cli_module, "ShellctlClient", RecordingShellctlClient)
|
||||
return RecordingShellctlClient
|
||||
|
||||
|
||||
def _package_env() -> dict[str, str]:
|
||||
package_root = Path(__file__).resolve().parents[3]
|
||||
src_path = package_root / "src"
|
||||
env = dict(os.environ)
|
||||
current_pythonpath = env.get("PYTHONPATH")
|
||||
env["PYTHONPATH"] = (
|
||||
f"{src_path}{os.pathsep}{current_pythonpath}"
|
||||
if current_pythonpath
|
||||
else str(src_path)
|
||||
)
|
||||
return env
|
||||
|
||||
|
||||
def test_shellctl_help_lists_network_commands() -> None:
|
||||
result = runner.invoke(cli, ["--help"])
|
||||
|
||||
assert result.exit_code == 0, result.stderr
|
||||
assert "health" in result.stdout
|
||||
assert "run" in result.stdout
|
||||
assert "wait" in result.stdout
|
||||
assert "status" in result.stdout
|
||||
assert "list" in result.stdout
|
||||
assert "input" in result.stdout
|
||||
assert "tail" in result.stdout
|
||||
assert "terminate" in result.stdout
|
||||
assert "delete" in result.stdout
|
||||
assert "serve" in result.stdout
|
||||
|
||||
|
||||
def test_shellctl_run_and_serve_help_show_the_new_option_boundaries() -> None:
|
||||
run_result = runner.invoke(cli, ["run", "--help"])
|
||||
serve_result = runner.invoke(cli, ["serve", "--help"])
|
||||
|
||||
assert run_result.exit_code == 0, run_result.stderr
|
||||
assert "--base-url" in run_result.stdout
|
||||
assert "--auth-token" in run_result.stdout
|
||||
assert "--state-dir" not in run_result.stdout
|
||||
assert "--runtime-dir" not in run_result.stdout
|
||||
|
||||
assert serve_result.exit_code == 0, serve_result.stderr
|
||||
assert "--state-dir" in serve_result.stdout
|
||||
assert "--runtime-dir" in serve_result.stdout
|
||||
assert "--auth-token" in serve_result.stdout
|
||||
|
||||
|
||||
def test_shellctl_health_uses_sdk_health_and_ignores_auth_token_input(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
patched_client: type[RecordingShellctlClient],
|
||||
) -> None:
|
||||
patched_client.results["health"] = HealthResponse(status="ok")
|
||||
monkeypatch.setenv("SHELLCTL_AUTH_TOKEN", "from-env")
|
||||
|
||||
result = runner.invoke(cli, ["health", "--auth-token", "flag-token"])
|
||||
|
||||
assert result.exit_code == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {"status": "ok"}
|
||||
assert patched_client.calls == [("health", (), {})]
|
||||
assert patched_client.init_calls == [
|
||||
{
|
||||
"base_url": DEFAULT_BASE_URL,
|
||||
"output_limit": 8192,
|
||||
"idle_flush_seconds": 0.5,
|
||||
"token": None,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_shellctl_run_builds_sdk_request_and_emits_json(
|
||||
patched_client: type[RecordingShellctlClient],
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
patched_client.results["run"] = JobResult(
|
||||
job_id="job-run",
|
||||
done=False,
|
||||
status=JobStatusName.RUNNING,
|
||||
output_path="/tmp/output.log",
|
||||
output="hello\n",
|
||||
offset=6,
|
||||
truncated=False,
|
||||
)
|
||||
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"run",
|
||||
"printf hello\\n",
|
||||
"--cwd",
|
||||
str(tmp_path / "workspace"),
|
||||
"--env",
|
||||
"A=1",
|
||||
"--env",
|
||||
"EMPTY=",
|
||||
"--timeout",
|
||||
"12",
|
||||
"--output-limit",
|
||||
"4096",
|
||||
"--idle-flush-seconds",
|
||||
"0.25",
|
||||
"--cols",
|
||||
"90",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"job_id": "job-run",
|
||||
"done": False,
|
||||
"status": "running",
|
||||
"output_path": "/tmp/output.log",
|
||||
"output": "hello\n",
|
||||
"offset": 6,
|
||||
"truncated": False,
|
||||
}
|
||||
assert patched_client.init_calls == [
|
||||
{
|
||||
"base_url": DEFAULT_BASE_URL,
|
||||
"output_limit": 4096,
|
||||
"idle_flush_seconds": 0.25,
|
||||
"token": None,
|
||||
}
|
||||
]
|
||||
assert patched_client.calls[0][0] == "run"
|
||||
assert patched_client.calls[0][1] == ("printf hello\\n",)
|
||||
assert patched_client.calls[0][2]["cwd"] == str(tmp_path / "workspace")
|
||||
assert patched_client.calls[0][2]["env"] == {"A": "1", "EMPTY": ""}
|
||||
assert patched_client.calls[0][2]["timeout"] == 12.0
|
||||
terminal = patched_client.calls[0][2]["terminal"]
|
||||
assert isinstance(terminal, TerminalSize)
|
||||
assert terminal.cols == 90
|
||||
assert terminal.rows == 80
|
||||
|
||||
|
||||
def test_shellctl_wait_and_input_require_offset() -> None:
|
||||
assert _command_option("wait", "--offset").required is True
|
||||
assert _command_option("input", "--offset").required is True
|
||||
|
||||
|
||||
def test_shellctl_wait_and_input_map_requests(
|
||||
patched_client: type[RecordingShellctlClient],
|
||||
) -> None:
|
||||
patched_client.results["wait"] = JobResult(
|
||||
job_id="job-1",
|
||||
done=False,
|
||||
status=JobStatusName.RUNNING,
|
||||
output_path="/tmp/wait.log",
|
||||
output="chunk",
|
||||
offset=5,
|
||||
truncated=False,
|
||||
)
|
||||
|
||||
wait_result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"wait",
|
||||
"job-1",
|
||||
"--offset",
|
||||
"3",
|
||||
"--timeout",
|
||||
"9",
|
||||
"--output-limit",
|
||||
"2048",
|
||||
"--idle-flush-seconds",
|
||||
"0.1",
|
||||
],
|
||||
)
|
||||
|
||||
assert wait_result.exit_code == 0, wait_result.stderr
|
||||
assert patched_client.calls[0] == (
|
||||
"wait",
|
||||
("job-1",),
|
||||
{"offset": 3, "timeout": 9.0},
|
||||
)
|
||||
assert patched_client.init_calls[0]["output_limit"] == 2048
|
||||
assert patched_client.init_calls[0]["idle_flush_seconds"] == 0.1
|
||||
|
||||
RecordingShellctlClient.reset()
|
||||
patched_client.results["input"] = JobResult(
|
||||
job_id="job-1",
|
||||
done=False,
|
||||
status=JobStatusName.RUNNING,
|
||||
output_path="/tmp/input.log",
|
||||
output="reply",
|
||||
offset=8,
|
||||
truncated=False,
|
||||
)
|
||||
|
||||
input_result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"input",
|
||||
"job-1",
|
||||
"hello\n",
|
||||
"--offset",
|
||||
"5",
|
||||
"--timeout",
|
||||
"4",
|
||||
"--output-limit",
|
||||
"512",
|
||||
"--idle-flush-seconds",
|
||||
"0",
|
||||
],
|
||||
)
|
||||
|
||||
assert input_result.exit_code == 0, input_result.stderr
|
||||
assert patched_client.calls[0] == (
|
||||
"input",
|
||||
("job-1", "hello\n"),
|
||||
{"offset": 5, "timeout": 4.0},
|
||||
)
|
||||
assert patched_client.init_calls[0]["output_limit"] == 512
|
||||
assert patched_client.init_calls[0]["idle_flush_seconds"] == 0.0
|
||||
|
||||
|
||||
def test_shellctl_list_tail_status_terminate_and_delete_map_arguments(
|
||||
patched_client: type[RecordingShellctlClient],
|
||||
) -> None:
|
||||
patched_client.results["list_jobs"] = [
|
||||
JobInfo(
|
||||
job_id="job-2",
|
||||
status=JobStatusName.RUNNING,
|
||||
created_at="2026-05-21T15:30:12Z",
|
||||
)
|
||||
]
|
||||
|
||||
list_result = runner.invoke(cli, ["list", "--status", "running", "--limit", "5"])
|
||||
|
||||
assert list_result.exit_code == 0, list_result.stderr
|
||||
assert json.loads(list_result.stdout) == [
|
||||
{
|
||||
"job_id": "job-2",
|
||||
"status": "running",
|
||||
"created_at": "2026-05-21T15:30:12Z",
|
||||
}
|
||||
]
|
||||
assert patched_client.calls[0] == (
|
||||
"list_jobs",
|
||||
(),
|
||||
{"status": JobStatusName.RUNNING, "limit": 5},
|
||||
)
|
||||
|
||||
RecordingShellctlClient.reset()
|
||||
patched_client.results["tail"] = JobResult(
|
||||
job_id="job-2",
|
||||
done=False,
|
||||
status=JobStatusName.RUNNING,
|
||||
output_path="/tmp/tail.log",
|
||||
output="tail",
|
||||
offset=4,
|
||||
truncated=False,
|
||||
)
|
||||
tail_result = runner.invoke(cli, ["tail", "job-2", "--output-limit", "16"])
|
||||
assert tail_result.exit_code == 0, tail_result.stderr
|
||||
assert patched_client.calls[0] == ("tail", ("job-2",), {})
|
||||
assert patched_client.init_calls[0]["output_limit"] == 16
|
||||
|
||||
RecordingShellctlClient.reset()
|
||||
patched_client.results["status"] = JobStatusView(
|
||||
job_id="job-2",
|
||||
status=JobStatusName.RUNNING,
|
||||
done=False,
|
||||
created_at="2026-05-21T15:30:12Z",
|
||||
started_at="2026-05-21T15:30:13Z",
|
||||
offset=4,
|
||||
)
|
||||
status_result = runner.invoke(cli, ["status", "job-2"])
|
||||
assert status_result.exit_code == 0, status_result.stderr
|
||||
assert patched_client.calls[0] == ("status", ("job-2",), {})
|
||||
|
||||
RecordingShellctlClient.reset()
|
||||
patched_client.results["terminate"] = JobStatusView(
|
||||
job_id="job-2",
|
||||
status=JobStatusName.TERMINATED,
|
||||
done=True,
|
||||
created_at="2026-05-21T15:30:12Z",
|
||||
started_at="2026-05-21T15:30:13Z",
|
||||
ended_at="2026-05-21T15:30:18Z",
|
||||
offset=4,
|
||||
)
|
||||
terminate_result = runner.invoke(
|
||||
cli,
|
||||
["terminate", "job-2", "--grace-seconds", "0.25"],
|
||||
)
|
||||
assert terminate_result.exit_code == 0, terminate_result.stderr
|
||||
assert patched_client.calls[0] == (
|
||||
"terminate",
|
||||
("job-2",),
|
||||
{"grace_seconds": 0.25},
|
||||
)
|
||||
|
||||
RecordingShellctlClient.reset()
|
||||
patched_client.results["delete"] = DeleteJobResponse(job_id="job-2")
|
||||
delete_result = runner.invoke(
|
||||
cli,
|
||||
["delete", "job-2", "--force", "--grace-seconds", "0.5"],
|
||||
)
|
||||
assert delete_result.exit_code == 0, delete_result.stderr
|
||||
assert patched_client.calls[0] == (
|
||||
"delete",
|
||||
("job-2",),
|
||||
{"force": True, "grace_seconds": 0.5},
|
||||
)
|
||||
|
||||
|
||||
def test_shellctl_run_rejects_invalid_env_entry() -> None:
|
||||
result = runner.invoke(cli, ["run", "printf bad", "--env", "MISSING_EQUALS"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
assert "env entries must use NAME=VALUE format" in result.stderr
|
||||
|
||||
|
||||
def test_shellctl_commands_render_sdk_errors_as_json_stderr(
|
||||
patched_client: type[RecordingShellctlClient],
|
||||
) -> None:
|
||||
patched_client.error = ShellctlClientError(
|
||||
404,
|
||||
"job_not_found",
|
||||
"Job missing is not found",
|
||||
)
|
||||
|
||||
result = runner.invoke(cli, ["status", "missing"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert result.stdout == ""
|
||||
assert json.loads(result.stderr) == {
|
||||
"error": {
|
||||
"code": "job_not_found",
|
||||
"message": "Job missing is not found",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def test_shellctl_commands_render_transport_errors_as_json_stderr(
|
||||
patched_client: type[RecordingShellctlClient],
|
||||
) -> None:
|
||||
patched_client.error = httpx.TransportError("connection refused")
|
||||
|
||||
result = runner.invoke(cli, ["status", "missing"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert result.stdout == ""
|
||||
assert json.loads(result.stderr) == {
|
||||
"error": {
|
||||
"code": "connection_error",
|
||||
"message": "connection refused",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def test_shellctl_commands_render_timeouts_as_json_stderr(
|
||||
patched_client: type[RecordingShellctlClient],
|
||||
) -> None:
|
||||
patched_client.error = httpx.TimeoutException("slow server")
|
||||
|
||||
result = runner.invoke(cli, ["status", "missing"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert result.stdout == ""
|
||||
assert json.loads(result.stderr) == {
|
||||
"error": {
|
||||
"code": "request_timeout",
|
||||
"message": "request timed out",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def test_shellctl_base_url_and_auth_token_flags_override_environment(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
patched_client: type[RecordingShellctlClient],
|
||||
) -> None:
|
||||
patched_client.results["status"] = JobStatusView(
|
||||
job_id="job-2",
|
||||
status=JobStatusName.RUNNING,
|
||||
done=False,
|
||||
created_at="2026-05-21T15:30:12Z",
|
||||
started_at="2026-05-21T15:30:13Z",
|
||||
offset=4,
|
||||
)
|
||||
monkeypatch.setenv("SHELLCTL_BASE_URL", "http://from-env:9999")
|
||||
monkeypatch.setenv("SHELLCTL_AUTH_TOKEN", "from-env-token")
|
||||
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"status",
|
||||
"job-2",
|
||||
"--base-url",
|
||||
"http://override:8765",
|
||||
"--auth-token",
|
||||
"flag-token",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.stderr
|
||||
assert patched_client.init_calls == [
|
||||
{
|
||||
"base_url": "http://override:8765",
|
||||
"output_limit": 8192,
|
||||
"idle_flush_seconds": 0.5,
|
||||
"token": "flag-token",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_shellctl_cli_controller_module_is_removed() -> None:
|
||||
assert (
|
||||
importlib.util.find_spec("shellctl.server.cli_controller")
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_importing_shellctl_cli_for_run_help_skips_server_stack() -> None:
|
||||
package_root = Path(__file__).resolve().parents[3]
|
||||
command = """
|
||||
import json
|
||||
import sys
|
||||
from typer.testing import CliRunner
|
||||
from shellctl.cli import cli
|
||||
|
||||
result = CliRunner().invoke(cli, ["run", "--help"])
|
||||
print(json.dumps({
|
||||
"exit_code": result.exit_code,
|
||||
"stdout": result.stdout,
|
||||
"modules": {
|
||||
"fastapi": "fastapi" in sys.modules,
|
||||
"uvicorn": "uvicorn" in sys.modules,
|
||||
"sqlalchemy": "sqlalchemy" in sys.modules,
|
||||
"sqlmodel": "sqlmodel" in sys.modules,
|
||||
"service": "shellctl.server.service" in sys.modules,
|
||||
"api": "shellctl.server.api" in sys.modules,
|
||||
"tmux": "shellctl.server.tmux" in sys.modules,
|
||||
"shared_runtime": "shellctl.shared.runtime" in sys.modules,
|
||||
"shared_output": "shellctl.shared.output" in sys.modules,
|
||||
"shared_sanitize": "shellctl.shared.sanitize" in sys.modules,
|
||||
},
|
||||
}))
|
||||
"""
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", command],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
cwd=package_root,
|
||||
env=_package_env(),
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["exit_code"] == 0
|
||||
assert "--base-url" in payload["stdout"]
|
||||
assert payload["modules"] == {
|
||||
"fastapi": False,
|
||||
"uvicorn": False,
|
||||
"sqlalchemy": False,
|
||||
"sqlmodel": False,
|
||||
"service": False,
|
||||
"api": False,
|
||||
"tmux": False,
|
||||
"shared_runtime": False,
|
||||
"shared_output": False,
|
||||
"shared_sanitize": False,
|
||||
}
|
||||
|
||||
|
||||
def test_importing_server_serve_command_skips_cli_and_sdk_modules() -> None:
|
||||
package_root = Path(__file__).resolve().parents[3]
|
||||
command = """
|
||||
import json
|
||||
import sys
|
||||
from shellctl.server import serve_command
|
||||
|
||||
print(json.dumps({
|
||||
"callable": callable(serve_command),
|
||||
"modules": {
|
||||
"cli": "shellctl.cli" in sys.modules,
|
||||
"sdk": "shellctl.client.sdk" in sys.modules,
|
||||
"client": "shellctl.client" in sys.modules,
|
||||
},
|
||||
}))
|
||||
"""
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", command],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
cwd=package_root,
|
||||
env=_package_env(),
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["callable"] is True
|
||||
assert payload["modules"] == {
|
||||
"cli": False,
|
||||
"sdk": False,
|
||||
"client": False,
|
||||
}
|
||||
@ -1,576 +0,0 @@
|
||||
import json
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import ClassVar, TypedDict, cast
|
||||
|
||||
import httpx2 as httpx
|
||||
import pytest
|
||||
|
||||
from shellctl.client import ShellctlClient, ShellctlClientError
|
||||
from shellctl.client import sdk as shellctl_sdk
|
||||
from shellctl.shared import (
|
||||
DEFAULT_TERMINATE_GRACE_SECONDS,
|
||||
HealthResponse,
|
||||
JobStatusName,
|
||||
)
|
||||
|
||||
|
||||
class ForcedDeleteKwargs(TypedDict, total=False):
|
||||
force: bool
|
||||
grace_seconds: float
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
("call", "expected_path", "expected_json", "wait_timeout"),
|
||||
[
|
||||
(
|
||||
lambda client: client.run(
|
||||
"printf ready\\n",
|
||||
cwd="/tmp",
|
||||
env={"HELLO": "world"},
|
||||
timeout=12,
|
||||
),
|
||||
"/v1/jobs/run",
|
||||
{
|
||||
"script": "printf ready\\n",
|
||||
"cwd": "/tmp",
|
||||
"env": {"HELLO": "world"},
|
||||
"timeout": 12.0,
|
||||
"output_limit": 4096,
|
||||
"idle_flush_seconds": 0.25,
|
||||
},
|
||||
22.0,
|
||||
),
|
||||
(
|
||||
lambda client: client.wait("job-1", offset=3, timeout=7),
|
||||
"/v1/jobs/job-1/wait",
|
||||
{
|
||||
"offset": 3,
|
||||
"timeout": 7,
|
||||
"output_limit": 4096,
|
||||
"idle_flush_seconds": 0.25,
|
||||
},
|
||||
17.0,
|
||||
),
|
||||
(
|
||||
lambda client: client.input("job-1", "ls\\n", offset=5, timeout=9),
|
||||
"/v1/jobs/job-1/input",
|
||||
{
|
||||
"text": "ls\\n",
|
||||
"offset": 5,
|
||||
"timeout": 9,
|
||||
"output_limit": 4096,
|
||||
"idle_flush_seconds": 0.25,
|
||||
},
|
||||
19.0,
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_shellctl_client_blocking_calls_use_grace_read_timeout(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
call: Callable[[ShellctlClient], Awaitable[object]],
|
||||
expected_path: str,
|
||||
expected_json: dict[str, object],
|
||||
wait_timeout: float,
|
||||
) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured["method"] = request.method
|
||||
captured["path"] = request.url.path
|
||||
captured["headers"] = dict(request.headers)
|
||||
captured["json"] = json.loads(request.content.decode("utf-8"))
|
||||
captured["timeout"] = request.extensions["timeout"]
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"job_id": "05211530-k7p",
|
||||
"done": False,
|
||||
"status": "running",
|
||||
"exit_code": None,
|
||||
"output_path": "/tmp/output.log",
|
||||
"output": "",
|
||||
"offset": 0,
|
||||
"truncated": False,
|
||||
},
|
||||
)
|
||||
|
||||
monkeypatch.setenv("SHELLCTL_AUTH_TOKEN", "from-env")
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with ShellctlClient(
|
||||
"http://127.0.0.1:8765",
|
||||
output_limit=4096,
|
||||
idle_flush_seconds=0.25,
|
||||
transport=transport,
|
||||
) as client:
|
||||
await call(client)
|
||||
|
||||
headers = cast(dict[str, str], captured["headers"])
|
||||
assert captured["method"] == "POST"
|
||||
assert captured["path"] == expected_path
|
||||
assert headers["authorization"] == "Bearer from-env"
|
||||
assert captured["json"] == expected_json
|
||||
assert captured["timeout"] == {
|
||||
"connect": 30.0,
|
||||
"read": wait_timeout,
|
||||
"write": 30.0,
|
||||
"pool": 30.0,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
("call", "expected_method", "expected_path", "response_json", "assert_result"),
|
||||
[
|
||||
(
|
||||
lambda client: client.tail("job-1"),
|
||||
"GET",
|
||||
"/v1/jobs/job-1/log/tail",
|
||||
{
|
||||
"job_id": "job-1",
|
||||
"done": False,
|
||||
"status": "running",
|
||||
"exit_code": None,
|
||||
"output_path": "/tmp/output.log",
|
||||
"output": "tail",
|
||||
"offset": 99,
|
||||
"truncated": False,
|
||||
},
|
||||
lambda result: (result.output, result.offset) == ("tail", 99),
|
||||
),
|
||||
(
|
||||
lambda client: client.status("job-1"),
|
||||
"GET",
|
||||
"/v1/jobs/job-1",
|
||||
{
|
||||
"job_id": "job-1",
|
||||
"status": "running",
|
||||
"done": False,
|
||||
"exit_code": None,
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
"started_at": "2026-01-01T00:00:01Z",
|
||||
"ended_at": None,
|
||||
"offset": 99,
|
||||
},
|
||||
lambda result: (result.status, result.offset) == ("running", 99),
|
||||
),
|
||||
(
|
||||
lambda client: client.delete("job-1", force=False),
|
||||
"DELETE",
|
||||
"/v1/jobs/job-1",
|
||||
{"job_id": "job-1", "deleted": True},
|
||||
lambda result: (result.job_id, result.deleted) == ("job-1", True),
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_shellctl_client_control_calls_use_default_timeout(
|
||||
call: Callable[[ShellctlClient], Awaitable[object]],
|
||||
expected_method: str,
|
||||
expected_path: str,
|
||||
response_json: dict[str, object],
|
||||
assert_result: Callable[[object], bool],
|
||||
) -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.method == expected_method
|
||||
assert request.url.path == expected_path
|
||||
assert request.extensions["timeout"] == {
|
||||
"connect": 30.0,
|
||||
"read": 30.0,
|
||||
"write": 30.0,
|
||||
"pool": 30.0,
|
||||
}
|
||||
return httpx.Response(200, json=response_json)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with ShellctlClient(
|
||||
"http://127.0.0.1:8765",
|
||||
output_limit=1234,
|
||||
token="secret",
|
||||
transport=transport,
|
||||
) as client:
|
||||
result = await call(client)
|
||||
|
||||
assert assert_result(result)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_shellctl_client_terminate_uses_grace_read_timeout() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.method == "POST"
|
||||
assert request.url.path == "/v1/jobs/job-1/terminate"
|
||||
assert json.loads(request.content.decode("utf-8")) == {"grace_seconds": 7.0}
|
||||
assert request.extensions["timeout"] == {
|
||||
"connect": 30.0,
|
||||
"read": 17.0,
|
||||
"write": 30.0,
|
||||
"pool": 30.0,
|
||||
}
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"job_id": "job-1",
|
||||
"status": "terminated",
|
||||
"done": True,
|
||||
"exit_code": 130,
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
"started_at": "2026-01-01T00:00:01Z",
|
||||
"ended_at": "2026-01-01T00:00:08Z",
|
||||
"offset": 99,
|
||||
},
|
||||
)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with ShellctlClient(
|
||||
"http://127.0.0.1:8765",
|
||||
token="secret",
|
||||
transport=transport,
|
||||
) as client:
|
||||
result = await client.terminate("job-1", grace_seconds=7.0)
|
||||
|
||||
assert (result.status, result.exit_code) == ("terminated", 130)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
("kwargs", "expected_params", "expected_read_timeout"),
|
||||
[
|
||||
(
|
||||
{"force": True, "grace_seconds": 3.5},
|
||||
{"force": "true", "grace_seconds": "3.5"},
|
||||
13.5,
|
||||
),
|
||||
(
|
||||
{"force": True},
|
||||
{"force": "true"},
|
||||
DEFAULT_TERMINATE_GRACE_SECONDS + 10.0,
|
||||
),
|
||||
(
|
||||
{"force": True, "grace_seconds": 0.0},
|
||||
{"force": "true", "grace_seconds": "0.0"},
|
||||
10.0,
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_shellctl_client_forced_delete_uses_terminate_grace_timeout(
|
||||
kwargs: ForcedDeleteKwargs,
|
||||
expected_params: dict[str, str],
|
||||
expected_read_timeout: float,
|
||||
) -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.method == "DELETE"
|
||||
assert request.url.path == "/v1/jobs/job-1"
|
||||
assert dict(request.url.params) == expected_params
|
||||
assert request.extensions["timeout"] == {
|
||||
"connect": 30.0,
|
||||
"read": expected_read_timeout,
|
||||
"write": 30.0,
|
||||
"pool": 30.0,
|
||||
}
|
||||
return httpx.Response(200, json={"job_id": "job-1", "deleted": True})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with ShellctlClient(
|
||||
"http://127.0.0.1:8765",
|
||||
token="secret",
|
||||
transport=transport,
|
||||
) as client:
|
||||
result = await client.delete("job-1", **kwargs)
|
||||
|
||||
assert (result.job_id, result.deleted) == ("job-1", True)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_shellctl_client_closes_owned_client_on_close_and_context_exit(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
async def handler(_request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"status": "ok"})
|
||||
|
||||
class TrackingAsyncClient(httpx.AsyncClient):
|
||||
created_clients: ClassVar[list["TrackingAsyncClient"]] = []
|
||||
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.close_calls = 0
|
||||
self.__class__.created_clients.append(self)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
self.close_calls += 1
|
||||
await super().aclose()
|
||||
|
||||
monkeypatch.setattr(shellctl_sdk.httpx, "AsyncClient", TrackingAsyncClient)
|
||||
close_client = ShellctlClient(
|
||||
"http://127.0.0.1:8765",
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
direct_owned_client = TrackingAsyncClient.created_clients[-1]
|
||||
await close_client.close()
|
||||
assert direct_owned_client.close_calls == 1
|
||||
|
||||
async with ShellctlClient(
|
||||
"http://127.0.0.1:8765",
|
||||
transport=httpx.MockTransport(handler),
|
||||
) as context_client:
|
||||
context_owned_client = TrackingAsyncClient.created_clients[-1]
|
||||
await context_client.healthz()
|
||||
|
||||
assert context_owned_client.close_calls == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_shellctl_client_list_jobs_uses_query_params_and_auth() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.method == "GET"
|
||||
assert request.url.path == "/v1/jobs"
|
||||
assert request.url.params["status"] == "running"
|
||||
assert request.url.params["limit"] == "5"
|
||||
assert request.headers["authorization"] == "Bearer secret"
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"jobs": [
|
||||
{
|
||||
"job_id": "job-1",
|
||||
"status": "running",
|
||||
"created_at": "2026-05-21T15:30:12Z",
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with ShellctlClient(
|
||||
"http://127.0.0.1:8765", token="secret", transport=transport
|
||||
) as client:
|
||||
result = await client.list_jobs(status="running", limit=5)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].job_id == "job-1"
|
||||
assert result[0].status == JobStatusName.RUNNING
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_shellctl_client_wait_uses_body_and_omits_auth_without_token() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.method == "POST"
|
||||
assert request.url.path == "/v1/jobs/job-1/wait"
|
||||
assert "authorization" not in request.headers
|
||||
assert json.loads(request.content.decode("utf-8")) == {
|
||||
"offset": 3,
|
||||
"timeout": 9.0,
|
||||
"output_limit": 2048,
|
||||
"idle_flush_seconds": 0.1,
|
||||
}
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"job_id": "job-1",
|
||||
"done": False,
|
||||
"status": "running",
|
||||
"exit_code": None,
|
||||
"output_path": "/tmp/wait.log",
|
||||
"output": "chunk",
|
||||
"offset": 8,
|
||||
"truncated": False,
|
||||
},
|
||||
)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with ShellctlClient(
|
||||
"http://127.0.0.1:8765",
|
||||
output_limit=2048,
|
||||
idle_flush_seconds=0.1,
|
||||
transport=transport,
|
||||
) as client:
|
||||
result = await client.wait("job-1", offset=3, timeout=9)
|
||||
|
||||
assert result.job_id == "job-1"
|
||||
assert result.offset == 8
|
||||
assert result.output == "chunk"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_shellctl_client_input_uses_body_and_auth() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.method == "POST"
|
||||
assert request.url.path == "/v1/jobs/job-1/input"
|
||||
assert request.headers["authorization"] == "Bearer secret"
|
||||
assert json.loads(request.content.decode("utf-8")) == {
|
||||
"text": "ls\n",
|
||||
"offset": 5,
|
||||
"timeout": 4.0,
|
||||
"output_limit": 512,
|
||||
"idle_flush_seconds": 0.0,
|
||||
}
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"job_id": "job-1",
|
||||
"done": False,
|
||||
"status": "running",
|
||||
"exit_code": None,
|
||||
"output_path": "/tmp/input.log",
|
||||
"output": "reply",
|
||||
"offset": 10,
|
||||
"truncated": False,
|
||||
},
|
||||
)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with ShellctlClient(
|
||||
"http://127.0.0.1:8765",
|
||||
output_limit=512,
|
||||
idle_flush_seconds=0.0,
|
||||
token="secret",
|
||||
transport=transport,
|
||||
) as client:
|
||||
result = await client.input("job-1", "ls\n", offset=5, timeout=4)
|
||||
|
||||
assert result.output == "reply"
|
||||
assert result.offset == 10
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_shellctl_client_terminate_uses_body_and_auth() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.method == "POST"
|
||||
assert request.url.path == "/v1/jobs/job-1/terminate"
|
||||
assert request.headers["authorization"] == "Bearer secret"
|
||||
assert json.loads(request.content.decode("utf-8")) == {"grace_seconds": 0.25}
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"job_id": "job-1",
|
||||
"status": "terminated",
|
||||
"done": True,
|
||||
"created_at": "2026-05-21T15:30:12Z",
|
||||
"started_at": "2026-05-21T15:30:13Z",
|
||||
"ended_at": "2026-05-21T15:30:18Z",
|
||||
"offset": 12,
|
||||
},
|
||||
)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with ShellctlClient(
|
||||
"http://127.0.0.1:8765", token="secret", transport=transport
|
||||
) as client:
|
||||
result = await client.terminate("job-1", 0.25)
|
||||
|
||||
assert result.status == JobStatusName.TERMINATED
|
||||
assert result.done is True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_shellctl_client_delete_uses_query_params_and_auth() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.method == "DELETE"
|
||||
assert request.url.path == "/v1/jobs/job-1"
|
||||
assert request.url.params["force"] == "true"
|
||||
assert request.url.params["grace_seconds"] == "0.5"
|
||||
assert request.headers["authorization"] == "Bearer secret"
|
||||
return httpx.Response(200, json={"job_id": "job-1", "deleted": True})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with ShellctlClient(
|
||||
"http://127.0.0.1:8765", token="secret", transport=transport
|
||||
) as client:
|
||||
result = await client.delete("job-1", force=True, grace_seconds=0.5)
|
||||
|
||||
assert result.job_id == "job-1"
|
||||
assert result.deleted is True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_shellctl_client_raises_structured_errors() -> None:
|
||||
def handler(_request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
409,
|
||||
json={"error": {"code": "job_not_running", "message": "already done"}},
|
||||
)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with ShellctlClient(
|
||||
"http://127.0.0.1:8765", token="secret", transport=transport
|
||||
) as client:
|
||||
with pytest.raises(ShellctlClientError, match="job_not_running"):
|
||||
await client.input("job-1", "ls\n", offset=0)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_shellctl_client_does_not_close_injected_client() -> None:
|
||||
async def handler(_request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"status": "ok"})
|
||||
|
||||
class TrackingAsyncClient(httpx.AsyncClient):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
base_url="http://127.0.0.1:8765",
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
self.close_calls = 0
|
||||
|
||||
async def aclose(self) -> None:
|
||||
self.close_calls += 1
|
||||
await super().aclose()
|
||||
|
||||
injected_client = TrackingAsyncClient()
|
||||
|
||||
async with ShellctlClient(
|
||||
"http://127.0.0.1:8765",
|
||||
client=injected_client,
|
||||
) as client:
|
||||
await client.healthz()
|
||||
|
||||
assert injected_client.close_calls == 0
|
||||
await injected_client.aclose()
|
||||
assert injected_client.close_calls == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_shellctl_client_raises_invalid_json_errors() -> None:
|
||||
def handler(_request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"content-type": "application/json"},
|
||||
content=b"{",
|
||||
)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with ShellctlClient("http://127.0.0.1:8765", transport=transport) as client:
|
||||
with pytest.raises(ShellctlClientError) as exc_info:
|
||||
await client.status("job-1")
|
||||
|
||||
assert exc_info.value.code == "invalid_json"
|
||||
assert exc_info.value.message == "{"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_shellctl_client_raises_invalid_payload_errors() -> None:
|
||||
def handler(_request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json=["not", "a", "dict"])
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with ShellctlClient("http://127.0.0.1:8765", transport=transport) as client:
|
||||
with pytest.raises(ShellctlClientError) as exc_info:
|
||||
await client.healthz()
|
||||
|
||||
assert exc_info.value.code == "invalid_payload"
|
||||
assert exc_info.value.message == '["not","a","dict"]'
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_shellctl_client_health_returns_model_and_healthz_stays_compatible() -> (
|
||||
None
|
||||
):
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.method == "GET"
|
||||
assert request.url.path == "/healthz"
|
||||
return httpx.Response(200, json={"status": "ok"})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with ShellctlClient("http://127.0.0.1:8765", transport=transport) as client:
|
||||
health = await client.health()
|
||||
healthz = await client.healthz()
|
||||
|
||||
assert health == HealthResponse(status="ok")
|
||||
assert healthz == {"status": "ok"}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,137 +0,0 @@
|
||||
import json
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import BinaryIO, cast
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from shellctl.shared import (
|
||||
JOB_ID_ALPHABET,
|
||||
RunJobRequest,
|
||||
generate_job_id,
|
||||
read_output_window,
|
||||
tail_output_window,
|
||||
)
|
||||
from shellctl_runtime.sanitize import (
|
||||
PtySanitizer,
|
||||
sanitize_pty_output,
|
||||
sanitize_pty_stream,
|
||||
)
|
||||
|
||||
|
||||
def test_generate_job_id_matches_proposal_format() -> None:
|
||||
job_id = generate_job_id(now=datetime(2026, 5, 21, 15, 30, tzinfo=UTC))
|
||||
|
||||
assert re.fullmatch(r"05211530-[0-9abcdefghjkmnpqrstvwxyz]{3}", job_id)
|
||||
assert all(char in f"{JOB_ID_ALPHABET}-" for char in job_id[9:])
|
||||
|
||||
|
||||
def test_sanitize_pty_golden_cases() -> None:
|
||||
fixture_path = Path(__file__).with_name("golden_shellctl_sanitize.json")
|
||||
cases = json.loads(fixture_path.read_text(encoding="utf-8"))
|
||||
|
||||
for case in cases:
|
||||
chunks = [bytes.fromhex(chunk) for chunk in case["chunks_hex"]]
|
||||
sanitizer = PtySanitizer()
|
||||
streamed = (
|
||||
"".join(sanitizer.feed(chunk) for chunk in chunks) + sanitizer.flush()
|
||||
)
|
||||
batch = sanitize_pty_output(b"".join(chunks))
|
||||
|
||||
assert streamed == case["expected"], case["name"]
|
||||
assert batch == case["expected"], case["name"]
|
||||
|
||||
|
||||
def test_read_output_window_preserves_utf8_boundaries(tmp_path: Path) -> None:
|
||||
output_path = tmp_path / "output.log"
|
||||
output_path.write_text("A🙂B", encoding="utf-8")
|
||||
|
||||
first = read_output_window(output_path, offset=0, limit=3)
|
||||
second = read_output_window(output_path, offset=first.offset, limit=4)
|
||||
third = read_output_window(output_path, offset=second.offset, limit=8)
|
||||
|
||||
assert first.output == "A"
|
||||
assert first.offset == 1
|
||||
assert first.truncated is True
|
||||
assert second.output == "🙂"
|
||||
assert second.offset == 5
|
||||
assert second.truncated is True
|
||||
assert third.output == "B"
|
||||
assert third.offset == output_path.stat().st_size
|
||||
assert third.truncated is False
|
||||
|
||||
|
||||
def test_read_output_window_advances_past_wide_char_even_when_limit_is_smaller(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
output_path = tmp_path / "output.log"
|
||||
output_path.write_text("🙂B", encoding="utf-8")
|
||||
|
||||
first = read_output_window(output_path, offset=0, limit=1)
|
||||
second = read_output_window(output_path, offset=first.offset, limit=1)
|
||||
|
||||
assert first.output == "🙂"
|
||||
assert first.offset == len("🙂".encode())
|
||||
assert first.truncated is True
|
||||
assert second.output == "B"
|
||||
assert second.truncated is False
|
||||
|
||||
|
||||
def test_tail_output_window_skips_partial_utf8_prefix(tmp_path: Path) -> None:
|
||||
output_path = tmp_path / "output.log"
|
||||
output_path.write_text("a🙂b", encoding="utf-8")
|
||||
|
||||
tail = tail_output_window(output_path, limit=3)
|
||||
|
||||
assert tail.output == "b"
|
||||
assert tail.offset == output_path.stat().st_size
|
||||
assert tail.truncated is False
|
||||
|
||||
|
||||
def test_sanitize_pty_stream_flushes_incrementally() -> None:
|
||||
class ChunkedInput:
|
||||
def __init__(self, chunks: list[bytes]) -> None:
|
||||
self._chunks = chunks
|
||||
|
||||
def read(self, _size: int) -> bytes:
|
||||
if not self._chunks:
|
||||
return b""
|
||||
return self._chunks.pop(0)
|
||||
|
||||
class FlushCountingOutput(BytesIO):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.flush_count = 0
|
||||
|
||||
def flush(self) -> None:
|
||||
self.flush_count += 1
|
||||
super().flush()
|
||||
|
||||
stdout = FlushCountingOutput()
|
||||
sanitize_pty_stream(
|
||||
cast(BinaryIO, cast(object, ChunkedInput([b"ready\n", b"next\n"]))),
|
||||
stdout,
|
||||
chunk_size=5,
|
||||
)
|
||||
|
||||
assert stdout.getvalue() == b"ready\nnext\n"
|
||||
assert stdout.flush_count >= 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("env", "message"),
|
||||
[
|
||||
({"": "x"}, "non-empty"),
|
||||
({"A=B": "x"}, "must not contain '='"),
|
||||
({"A\x00B": "x"}, "must not contain NUL"),
|
||||
({"A": "x\x00y"}, "must not contain NUL"),
|
||||
],
|
||||
)
|
||||
def test_run_job_request_rejects_invalid_env_entries(
|
||||
env: dict[str, str], message: str
|
||||
) -> None:
|
||||
with pytest.raises(ValidationError, match=message):
|
||||
RunJobRequest(script="printf ready\n", env=env)
|
||||
@ -1,32 +0,0 @@
|
||||
[
|
||||
{
|
||||
"name": "utf8_passthrough",
|
||||
"chunks_hex": ["68656c6c6f20e4b896e7958cf09f99820a"],
|
||||
"expected": "hello 世界🙂\n"
|
||||
},
|
||||
{
|
||||
"name": "split_utf8_chunks",
|
||||
"chunks_hex": ["e4bda0e5a5bd20f09f", "99820a"],
|
||||
"expected": "你好 🙂\n"
|
||||
},
|
||||
{
|
||||
"name": "invalid_utf8_replacement",
|
||||
"chunks_hex": ["666f80ff6f0a"],
|
||||
"expected": "fo<66><6F>o\n"
|
||||
},
|
||||
{
|
||||
"name": "ansi_sequences_removed",
|
||||
"chunks_hex": ["1b5b33316d7265641b5b306d1b5b4b0a"],
|
||||
"expected": "red\n"
|
||||
},
|
||||
{
|
||||
"name": "carriage_return_progress",
|
||||
"chunks_hex": ["30250d3530250d313030250a"],
|
||||
"expected": "100%\n"
|
||||
},
|
||||
{
|
||||
"name": "crlf_normalized",
|
||||
"chunks_hex": ["6f6b0d0a6e6578740d0a"],
|
||||
"expected": "ok\nnext\n"
|
||||
}
|
||||
]
|
||||
@ -1,556 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import importlib.metadata
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
from types import TracebackType
|
||||
from typing import BinaryIO, TypedDict, cast
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
|
||||
from shellctl.server import ShellctlConfig, ShellctlService
|
||||
from shellctl_runtime.runner_exit import record_runner_exit
|
||||
from shellctl_runtime.sanitize import run_sanitize_pty
|
||||
|
||||
PACKAGE_ROOT = Path(__file__).resolve().parents[3]
|
||||
SRC_PATH = PACKAGE_ROOT / "src"
|
||||
HEAVY_IMPORT_ROOTS = {
|
||||
"fastapi",
|
||||
"httpx",
|
||||
"pydantic",
|
||||
"pygments",
|
||||
"rich",
|
||||
"sqlalchemy",
|
||||
"shellctl",
|
||||
"typer",
|
||||
"uvicorn",
|
||||
}
|
||||
|
||||
|
||||
class ModuleHelpStats(TypedDict):
|
||||
code: int
|
||||
elapsed: float
|
||||
deny: list[str]
|
||||
|
||||
|
||||
def _pythonpath_env() -> dict[str, str]:
|
||||
env = dict(os.environ)
|
||||
current_pythonpath = env.get("PYTHONPATH")
|
||||
env["PYTHONPATH"] = (
|
||||
f"{SRC_PATH}{os.pathsep}{current_pythonpath}"
|
||||
if current_pythonpath
|
||||
else str(SRC_PATH)
|
||||
)
|
||||
return env
|
||||
|
||||
|
||||
async def _initialize_database(state_dir: Path) -> None:
|
||||
service = ShellctlService(
|
||||
ShellctlConfig(state_dir=state_dir, runtime_dir=state_dir / "run")
|
||||
)
|
||||
await service.initialize_database()
|
||||
await service.shutdown()
|
||||
|
||||
|
||||
def _insert_job_row(
|
||||
*,
|
||||
state_dir: Path,
|
||||
job_id: str,
|
||||
status: str,
|
||||
exit_code: int | None = None,
|
||||
ended_at: str | None = None,
|
||||
reason: str | None = None,
|
||||
message: str | None = None,
|
||||
updated_at: str = "2026-05-21T15:30:13Z",
|
||||
) -> None:
|
||||
db_path = state_dir / "shellctl.db"
|
||||
with sqlite3.connect(db_path) as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO jobs (
|
||||
job_id,
|
||||
script_path,
|
||||
output_path,
|
||||
cwd,
|
||||
terminal_cols,
|
||||
terminal_rows,
|
||||
status,
|
||||
session_name,
|
||||
pane_target,
|
||||
exit_code,
|
||||
reason,
|
||||
message,
|
||||
created_at,
|
||||
started_at,
|
||||
ended_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
job_id,
|
||||
f"jobs/{job_id}/script",
|
||||
f"jobs/{job_id}/output.log",
|
||||
"/tmp",
|
||||
80,
|
||||
24,
|
||||
status,
|
||||
f"shellctl-{job_id}",
|
||||
f"shellctl-{job_id}:0.0",
|
||||
exit_code,
|
||||
reason,
|
||||
message,
|
||||
"2026-05-21T15:30:12Z",
|
||||
"2026-05-21T15:30:13Z",
|
||||
ended_at,
|
||||
updated_at,
|
||||
),
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
|
||||
def _fetch_job_row(state_dir: Path, job_id: str) -> tuple[object, ...]:
|
||||
with sqlite3.connect(state_dir / "shellctl.db") as connection:
|
||||
row = connection.execute(
|
||||
"""
|
||||
SELECT status, exit_code, ended_at, updated_at, reason, message
|
||||
FROM jobs
|
||||
WHERE job_id = ?
|
||||
""",
|
||||
(job_id,),
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
return row
|
||||
|
||||
|
||||
def _measure_module_help(module_name: str) -> ModuleHelpStats:
|
||||
script = textwrap.dedent(
|
||||
f"""
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import runpy
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, {str(SRC_PATH)!r})
|
||||
sys.argv = [{module_name!r}, "--help"]
|
||||
start = time.perf_counter()
|
||||
stdout = io.StringIO()
|
||||
stderr = io.StringIO()
|
||||
code = 0
|
||||
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
|
||||
try:
|
||||
runpy.run_module({module_name!r}, run_name="__main__")
|
||||
except SystemExit as exc:
|
||||
if isinstance(exc.code, int):
|
||||
code = exc.code
|
||||
elapsed = time.perf_counter() - start
|
||||
deny = sorted(
|
||||
name
|
||||
for name in sys.modules
|
||||
if name.split(".", 1)[0] in {sorted(HEAVY_IMPORT_ROOTS)!r}
|
||||
)
|
||||
print(json.dumps({{"code": code, "elapsed": elapsed, "deny": deny}}))
|
||||
"""
|
||||
)
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", script],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
cwd=PACKAGE_ROOT,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return cast(ModuleHelpStats, json.loads(result.stdout))
|
||||
|
||||
|
||||
def test_run_sanitize_pty_touches_ready_file_before_reading(tmp_path: Path) -> None:
|
||||
ready_file = tmp_path / "ready"
|
||||
output = io.BytesIO()
|
||||
|
||||
class ReadyCheckingInput:
|
||||
def __init__(self) -> None:
|
||||
self._reads = 0
|
||||
|
||||
def read(self, _size: int) -> bytes:
|
||||
self._reads += 1
|
||||
assert ready_file.exists()
|
||||
return b"before\rafter\n" if self._reads == 1 else b""
|
||||
|
||||
run_sanitize_pty(
|
||||
ready_file,
|
||||
stdin=cast(BinaryIO, cast(object, ReadyCheckingInput())),
|
||||
stdout=output,
|
||||
)
|
||||
|
||||
assert output.getvalue() == b"after\n"
|
||||
|
||||
|
||||
def test_removed_sanitizer_modules_are_not_importable() -> None:
|
||||
assert (
|
||||
importlib.util.find_spec("shellctl.sanitize_pty") is None
|
||||
)
|
||||
assert (
|
||||
importlib.util.find_spec("shellctl.shared.sanitize")
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_removed_sanitizer_helpers_are_not_shellctl_exports() -> None:
|
||||
import shellctl as shellctl
|
||||
import shellctl.shared as shared
|
||||
|
||||
for module in (shellctl, shared):
|
||||
for name in ("PtySanitizer", "sanitize_pty_output", "sanitize_pty_stream"):
|
||||
assert not hasattr(module, name)
|
||||
|
||||
|
||||
def test_runner_exit_matches_service_state_for_running_job(tmp_path: Path) -> None:
|
||||
runtime_state_dir = tmp_path / "runtime-state"
|
||||
service_state_dir = tmp_path / "service-state"
|
||||
anyio.run(_initialize_database, runtime_state_dir)
|
||||
anyio.run(_initialize_database, service_state_dir)
|
||||
_insert_job_row(state_dir=runtime_state_dir, job_id="job-1", status="running")
|
||||
_insert_job_row(state_dir=service_state_dir, job_id="job-1", status="running")
|
||||
|
||||
record_runner_exit(
|
||||
state_dir=runtime_state_dir,
|
||||
job_id="job-1",
|
||||
exit_code=7,
|
||||
ended_at="2026-05-21T15:30:20Z",
|
||||
)
|
||||
|
||||
async def _record_with_service() -> None:
|
||||
service = ShellctlService(
|
||||
ShellctlConfig(
|
||||
state_dir=service_state_dir,
|
||||
runtime_dir=service_state_dir / "run",
|
||||
)
|
||||
)
|
||||
try:
|
||||
await service.record_runner_exit("job-1", 7, "2026-05-21T15:30:20Z")
|
||||
finally:
|
||||
await service.shutdown()
|
||||
|
||||
anyio.run(_record_with_service)
|
||||
|
||||
assert _fetch_job_row(runtime_state_dir, "job-1") == _fetch_job_row(
|
||||
service_state_dir,
|
||||
"job-1",
|
||||
)
|
||||
|
||||
|
||||
def test_runner_exit_is_idempotent_for_terminal_job(tmp_path: Path) -> None:
|
||||
state_dir = tmp_path / "state"
|
||||
anyio.run(_initialize_database, state_dir)
|
||||
_insert_job_row(
|
||||
state_dir=state_dir,
|
||||
job_id="job-2",
|
||||
status="failed",
|
||||
exit_code=99,
|
||||
ended_at="2026-05-21T15:30:18Z",
|
||||
reason="runner_failed",
|
||||
message="boom",
|
||||
updated_at="2026-05-21T15:30:18Z",
|
||||
)
|
||||
|
||||
before = _fetch_job_row(state_dir, "job-2")
|
||||
record_runner_exit(
|
||||
state_dir=state_dir,
|
||||
job_id="job-2",
|
||||
exit_code=7,
|
||||
ended_at="2026-05-21T15:30:20Z",
|
||||
)
|
||||
|
||||
assert _fetch_job_row(state_dir, "job-2") == before
|
||||
|
||||
|
||||
def test_runner_exit_uses_explicit_busy_timeout_override(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
state_dir = tmp_path / "state"
|
||||
anyio.run(_initialize_database, state_dir)
|
||||
_insert_job_row(state_dir=state_dir, job_id="job-3", status="running")
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
real_connect = sqlite3.connect
|
||||
|
||||
class RecordingConnection:
|
||||
def __init__(self, connection: sqlite3.Connection) -> None:
|
||||
self._connection = connection
|
||||
|
||||
def execute(
|
||||
self, sql: str, parameters: tuple[object, ...] = ()
|
||||
) -> sqlite3.Cursor:
|
||||
if sql.startswith("PRAGMA busy_timeout="):
|
||||
captured["pragma"] = sql
|
||||
return self._connection.execute(sql, parameters)
|
||||
|
||||
def commit(self) -> None:
|
||||
self._connection.commit()
|
||||
|
||||
def close(self) -> None:
|
||||
self._connection.close()
|
||||
|
||||
def fake_connect(path: str | Path, timeout: float) -> RecordingConnection:
|
||||
captured["path"] = str(path)
|
||||
captured["timeout"] = timeout
|
||||
return RecordingConnection(real_connect(path, timeout=timeout))
|
||||
|
||||
monkeypatch.setattr("shellctl_runtime.runner_exit.sqlite3.connect", fake_connect)
|
||||
|
||||
record_runner_exit(
|
||||
state_dir=state_dir,
|
||||
job_id="job-3",
|
||||
exit_code=5,
|
||||
ended_at="2026-05-21T15:30:20Z",
|
||||
busy_timeout_ms=6789,
|
||||
)
|
||||
|
||||
assert captured["path"] == str(state_dir / "shellctl.db")
|
||||
assert captured["timeout"] == pytest.approx(6.789)
|
||||
assert captured["pragma"] == "PRAGMA busy_timeout=6789"
|
||||
|
||||
|
||||
def test_runner_exit_does_not_clobber_newer_terminal_state(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
state_dir = tmp_path / "state"
|
||||
anyio.run(_initialize_database, state_dir)
|
||||
_insert_job_row(state_dir=state_dir, job_id="job-4", status="running")
|
||||
|
||||
real_connect = sqlite3.connect
|
||||
|
||||
class RacingConnection:
|
||||
def __init__(self, connection: sqlite3.Connection) -> None:
|
||||
self._connection = connection
|
||||
self._raced = False
|
||||
|
||||
def __enter__(self) -> RacingConnection:
|
||||
self._connection.__enter__()
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc: BaseException | None,
|
||||
tb: TracebackType | None,
|
||||
) -> bool | None:
|
||||
return self._connection.__exit__(exc_type, exc, tb)
|
||||
|
||||
def execute(
|
||||
self, sql: str, parameters: tuple[object, ...] = ()
|
||||
) -> sqlite3.Cursor:
|
||||
if sql.lstrip().startswith("UPDATE jobs") and not self._raced:
|
||||
self._raced = True
|
||||
with real_connect(state_dir / "shellctl.db") as racing_connection:
|
||||
racing_connection.execute(
|
||||
"""
|
||||
UPDATE jobs
|
||||
SET status = ?, exit_code = ?, ended_at = ?, updated_at = ?,
|
||||
reason = ?, message = ?
|
||||
WHERE job_id = ?
|
||||
""",
|
||||
(
|
||||
"failed",
|
||||
91,
|
||||
"2026-05-21T15:30:19Z",
|
||||
"2026-05-21T15:30:19Z",
|
||||
"pipe_failed",
|
||||
"concurrent winner",
|
||||
"job-4",
|
||||
),
|
||||
)
|
||||
racing_connection.commit()
|
||||
return self._connection.execute(sql, parameters)
|
||||
|
||||
def commit(self) -> None:
|
||||
self._connection.commit()
|
||||
|
||||
def close(self) -> None:
|
||||
self._connection.close()
|
||||
|
||||
def fake_connect(
|
||||
path: str | Path, timeout: float = 5.0, *args: object, **kwargs: object
|
||||
) -> RacingConnection:
|
||||
return RacingConnection(real_connect(path, timeout=timeout))
|
||||
|
||||
monkeypatch.setattr("shellctl_runtime.runner_exit.sqlite3.connect", fake_connect)
|
||||
|
||||
record_runner_exit(
|
||||
state_dir=state_dir,
|
||||
job_id="job-4",
|
||||
exit_code=7,
|
||||
ended_at="2026-05-21T15:30:20Z",
|
||||
)
|
||||
|
||||
assert _fetch_job_row(state_dir, "job-4") == (
|
||||
"failed",
|
||||
91,
|
||||
"2026-05-21T15:30:19Z",
|
||||
"2026-05-21T15:30:19Z",
|
||||
"pipe_failed",
|
||||
"concurrent winner",
|
||||
)
|
||||
|
||||
|
||||
def test_python_m_shellctl_runtime_runner_exit_updates_row(tmp_path: Path) -> None:
|
||||
state_dir = tmp_path / "state"
|
||||
anyio.run(_initialize_database, state_dir)
|
||||
_insert_job_row(state_dir=state_dir, job_id="job-cli", status="running")
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"shellctl_runtime.runner_exit",
|
||||
"--state-dir",
|
||||
str(state_dir),
|
||||
"--job-id",
|
||||
"job-cli",
|
||||
"--exit-code",
|
||||
"7",
|
||||
"--ended-at",
|
||||
"2026-05-21T15:30:20Z",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
cwd=PACKAGE_ROOT,
|
||||
env=_pythonpath_env(),
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert _fetch_job_row(state_dir, "job-cli") == (
|
||||
"exited",
|
||||
7,
|
||||
"2026-05-21T15:30:20Z",
|
||||
"2026-05-21T15:30:20Z",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def test_python_m_shellctl_runtime_runner_exit_reports_missing_job(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
state_dir = tmp_path / "state"
|
||||
anyio.run(_initialize_database, state_dir)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"shellctl_runtime.runner_exit",
|
||||
"--state-dir",
|
||||
str(state_dir),
|
||||
"--job-id",
|
||||
"missing-job",
|
||||
"--exit-code",
|
||||
"7",
|
||||
"--ended-at",
|
||||
"2026-05-21T15:30:20Z",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
cwd=PACKAGE_ROOT,
|
||||
env=_pythonpath_env(),
|
||||
)
|
||||
|
||||
assert result.returncode != 0
|
||||
assert (
|
||||
"Unknown job id: missing-job" in result.stderr
|
||||
or "Unknown job id: missing-job" in result.stdout
|
||||
)
|
||||
|
||||
|
||||
def test_runtime_console_scripts_are_installed() -> None:
|
||||
entry_points = {
|
||||
entry_point.name: entry_point.value
|
||||
for entry_point in importlib.metadata.entry_points(group="console_scripts")
|
||||
}
|
||||
|
||||
assert entry_points["shellctl-sanitize-pty"] == "shellctl_runtime.sanitize:main"
|
||||
assert entry_points["shellctl-runner-exit"] == "shellctl_runtime.runner_exit:main"
|
||||
assert shutil.which("shellctl-sanitize-pty") is not None
|
||||
assert shutil.which("shellctl-runner-exit") is not None
|
||||
|
||||
|
||||
def test_runtime_modules_only_import_stdlib_or_runtime_helpers() -> None:
|
||||
forbidden_imports = {
|
||||
"anyio",
|
||||
"fastapi",
|
||||
"httpx",
|
||||
"pydantic",
|
||||
"pygments",
|
||||
"rich",
|
||||
"shellctl",
|
||||
"sqlalchemy",
|
||||
"sqlmodel",
|
||||
"typer",
|
||||
"uvicorn",
|
||||
}
|
||||
|
||||
for filename in ("sanitize.py", "runner_exit.py"):
|
||||
tree = ast.parse((SRC_PATH / "shellctl_runtime" / filename).read_text("utf-8"))
|
||||
imports: set[str] = set()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
imports.update(alias.name for alias in node.names)
|
||||
elif isinstance(node, ast.ImportFrom) and node.module is not None:
|
||||
imports.add(node.module)
|
||||
assert not any(
|
||||
imported == forbidden or imported.startswith(f"{forbidden}.")
|
||||
for imported in imports
|
||||
for forbidden in forbidden_imports
|
||||
), (filename, sorted(imports))
|
||||
|
||||
|
||||
def test_python_m_shellctl_runtime_sanitize_module_stays_lightweight() -> None:
|
||||
stats = _measure_module_help("shellctl_runtime.sanitize")
|
||||
|
||||
assert stats["code"] == 0
|
||||
assert stats["elapsed"] < 0.35
|
||||
assert stats["deny"] == []
|
||||
|
||||
|
||||
def test_python_m_shellctl_runtime_runner_exit_module_stays_lightweight() -> None:
|
||||
stats = _measure_module_help("shellctl_runtime.runner_exit")
|
||||
|
||||
assert stats["code"] == 0
|
||||
assert stats["elapsed"] < 0.35
|
||||
assert stats["deny"] == []
|
||||
|
||||
|
||||
def test_python_m_shellctl_runtime_sanitize_sanitizes_output(tmp_path: Path) -> None:
|
||||
ready_file = tmp_path / "ready"
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"shellctl_runtime.sanitize",
|
||||
"--ready-file",
|
||||
str(ready_file),
|
||||
],
|
||||
input=b"before\rafter\n",
|
||||
capture_output=True,
|
||||
check=False,
|
||||
cwd=PACKAGE_ROOT,
|
||||
env=_pythonpath_env(),
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr.decode("utf-8", errors="replace")
|
||||
assert ready_file.exists()
|
||||
assert result.stdout.decode("utf-8") == "after\n"
|
||||
@ -7,9 +7,7 @@ from pathlib import Path
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
CLIENT_SHARED_DTO_DEPENDENCIES = {
|
||||
"anyio>=4.12.1,<5.0.0",
|
||||
"httpx==0.28.1",
|
||||
"httpx2>=2.5.0,<3.0.0",
|
||||
"pydantic>=2.12.5,<2.13",
|
||||
"pydantic-ai-slim>=1.102.0,<2.0.0",
|
||||
"typer>=0.16.1,<0.17",
|
||||
@ -25,13 +23,7 @@ SERVER_RUNTIME_DEPENDENCIES = {
|
||||
"pydantic-ai-slim[anthropic,google,openai]>=1.85.1,<2.0.0",
|
||||
"pydantic-settings>=2.12.0,<3.0.0",
|
||||
"redis>=7.4.0,<8.0.0",
|
||||
"uvicorn[standard]==0.46.0",
|
||||
}
|
||||
|
||||
SHELLCTL_SERVER_DEPENDENCIES = {
|
||||
"aiosqlite>=0.21.0,<1.0.0",
|
||||
"fastapi==0.136.0",
|
||||
"sqlmodel>=0.0.24,<0.1.0",
|
||||
"shell-session-manager==2.3.1",
|
||||
"uvicorn[standard]==0.46.0",
|
||||
}
|
||||
|
||||
@ -62,7 +54,6 @@ def test_project_dependencies_split_client_and_server_requirements() -> None:
|
||||
assert set(project["dependencies"]) == CLIENT_SHARED_DTO_DEPENDENCIES
|
||||
assert set(project["optional-dependencies"]["grpc"]) == GRPC_RUNTIME_DEPENDENCIES
|
||||
assert set(project["optional-dependencies"]["server"]) == SERVER_RUNTIME_DEPENDENCIES
|
||||
assert set(project["optional-dependencies"]["shellctl-server"]) == SHELLCTL_SERVER_DEPENDENCIES
|
||||
assert set(pyproject["dependency-groups"]["dev"]) == DEV_DEPENDENCIES
|
||||
|
||||
|
||||
@ -71,8 +62,6 @@ def test_default_package_discovery_excludes_example_packages() -> None:
|
||||
find_config = pyproject["tool"]["setuptools"]["packages"]["find"]
|
||||
|
||||
assert find_config["where"] == ["src"]
|
||||
assert "shellctl*" in find_config["include"]
|
||||
assert "shellctl_runtime*" in find_config["include"]
|
||||
assert "agenton_examples*" not in find_config["include"]
|
||||
assert "dify_agent_examples*" not in find_config["include"]
|
||||
|
||||
@ -83,9 +72,6 @@ def test_project_declares_console_scripts() -> None:
|
||||
|
||||
assert scripts["dify-agent"] == "dify_agent.agent_stub.cli.main:main"
|
||||
assert scripts["dify-agent-stub-server"] == "dify_agent.agent_stub.server.cli:main"
|
||||
assert scripts["shellctl"] == "shellctl.cli:main"
|
||||
assert scripts["shellctl-sanitize-pty"] == "shellctl_runtime.sanitize:main"
|
||||
assert scripts["shellctl-runner-exit"] == "shellctl_runtime.runner_exit:main"
|
||||
|
||||
|
||||
def test_local_sandbox_dockerfile_installs_stub_client_and_shellctl() -> None:
|
||||
@ -94,11 +80,10 @@ def test_local_sandbox_dockerfile_installs_stub_client_and_shellctl() -> None:
|
||||
assert "ARG NODE_VERSION=22.22.1" in dockerfile
|
||||
assert "ARG PNPM_VERSION=11.9.0" in dockerfile
|
||||
assert "ARG UV_VERSION=0.8.9" in dockerfile
|
||||
assert "ARG DIFY_AGENT_TOOL_SPEC=.[grpc,shellctl-server]" in dockerfile
|
||||
assert "SHELL_SESSION_MANAGER_TOOL_SPEC" not in dockerfile
|
||||
assert "ARG DIFY_AGENT_TOOL_SPEC=.[grpc]" in dockerfile
|
||||
assert "ARG SHELL_SESSION_MANAGER_TOOL_SPEC=shell-session-manager" in dockerfile
|
||||
assert "UV_TOOL_DIR=/opt/dify-agent-tools/envs" in dockerfile
|
||||
assert "UV_TOOL_BIN_DIR=/opt/dify-agent-tools/bin" in dockerfile
|
||||
assert "bash" in dockerfile
|
||||
assert "git" in dockerfile
|
||||
assert "jq" in dockerfile
|
||||
assert "openssh-client" in dockerfile
|
||||
@ -110,6 +95,10 @@ def test_local_sandbox_dockerfile_installs_stub_client_and_shellctl() -> None:
|
||||
assert "uv export --frozen --no-dev --all-extras --no-emit-project --no-hashes" in dockerfile
|
||||
assert "> /tmp/dify-agent-constraints.txt" in dockerfile
|
||||
assert '--constraints /tmp/dify-agent-constraints.txt --link-mode=copy "${DIFY_AGENT_TOOL_SPEC}"' in dockerfile
|
||||
assert (
|
||||
'--constraints /tmp/dify-agent-constraints.txt --link-mode=copy "${SHELL_SESSION_MANAGER_TOOL_SPEC}"'
|
||||
in dockerfile
|
||||
)
|
||||
assert "DIFY_AGENT_STUB_DRIVE_BASE=/mnt/drive" in dockerfile
|
||||
assert "COPY --from=tools ${UV_TOOL_DIR} ${UV_TOOL_DIR}" in dockerfile
|
||||
assert "COPY --from=tools ${UV_TOOL_BIN_DIR} ${UV_TOOL_BIN_DIR}" in dockerfile
|
||||
|
||||
81
dify-agent/uv.lock
generated
81
dify-agent/uv.lock
generated
@ -593,9 +593,7 @@ name = "dify-agent"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "httpx" },
|
||||
{ name = "httpx2" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-ai-slim" },
|
||||
{ name = "typer" },
|
||||
@ -616,12 +614,7 @@ server = [
|
||||
{ name = "pydantic-ai-slim", extra = ["anthropic", "google", "openai"] },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "redis" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
shellctl-server = [
|
||||
{ name = "aiosqlite" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "sqlmodel" },
|
||||
{ name = "shell-session-manager" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
|
||||
@ -644,14 +637,10 @@ docs = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "aiosqlite", marker = "extra == 'shellctl-server'", specifier = ">=0.21.0,<1.0.0" },
|
||||
{ name = "anyio", specifier = ">=4.12.1,<5.0.0" },
|
||||
{ name = "fastapi", marker = "extra == 'server'", specifier = "==0.136.0" },
|
||||
{ name = "fastapi", marker = "extra == 'shellctl-server'", specifier = "==0.136.0" },
|
||||
{ name = "graphon", marker = "extra == 'server'", specifier = "==0.5.2" },
|
||||
{ name = "grpclib", extras = ["protobuf"], marker = "extra == 'grpc'", specifier = ">=0.4.9,<0.5.0" },
|
||||
{ name = "httpx", specifier = "==0.28.1" },
|
||||
{ name = "httpx2", specifier = ">=2.5.0,<3.0.0" },
|
||||
{ name = "jsonschema", marker = "extra == 'server'", specifier = ">=4.23.0,<5.0.0" },
|
||||
{ name = "jwcrypto", marker = "extra == 'server'", specifier = ">=1.5.6,<2" },
|
||||
{ name = "logfire", extras = ["fastapi", "httpx", "redis"], marker = "extra == 'server'", specifier = ">=4.37.0,<5.0.0" },
|
||||
@ -661,13 +650,12 @@ requires-dist = [
|
||||
{ name = "pydantic-ai-slim", extras = ["anthropic", "google", "openai"], marker = "extra == 'server'", specifier = ">=1.85.1,<2.0.0" },
|
||||
{ name = "pydantic-settings", marker = "extra == 'server'", specifier = ">=2.12.0,<3.0.0" },
|
||||
{ name = "redis", marker = "extra == 'server'", specifier = ">=7.4.0,<8.0.0" },
|
||||
{ name = "sqlmodel", marker = "extra == 'shellctl-server'", specifier = ">=0.0.24,<0.1.0" },
|
||||
{ name = "shell-session-manager", marker = "extra == 'server'", specifier = "==2.4.0" },
|
||||
{ name = "typer", specifier = ">=0.16.1,<0.17" },
|
||||
{ name = "typing-extensions", specifier = ">=4.12.2,<5.0.0" },
|
||||
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'server'", specifier = "==0.46.0" },
|
||||
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'shellctl-server'", specifier = "==0.46.0" },
|
||||
]
|
||||
provides-extras = ["grpc", "server", "shellctl-server"]
|
||||
provides-extras = ["grpc", "server"]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
@ -1133,19 +1121,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore2"
|
||||
version = "2.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "h11" },
|
||||
{ name = "truststore" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/47/06/5c12df521b5322fb1114a83d46911b2fbcb8855ddb3a635f11c01a214af5/httpcore2-2.5.0.tar.gz", hash = "sha256:88aa170137c17328d5ac44234f9fd10706466d5fb347f3edac4d39b91137b09d", size = 64808, upload-time = "2026-06-25T14:16:56.472Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/a1/7564199d1a8728fe737b0a72e5b3f8d92dfe085a74ddf7cdd83bce5f206d/httpcore2-2.5.0-py3-none-any.whl", hash = "sha256:5ce35188de461d31e8d000bfb8ef8bf22c6c16587a211e5571deaa5e9bdf842a", size = 80330, upload-time = "2026-06-25T14:16:53.634Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httptools"
|
||||
version = "0.7.1"
|
||||
@ -1190,22 +1165,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx2"
|
||||
version = "2.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "httpcore2" },
|
||||
{ name = "idna" },
|
||||
{ name = "truststore" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d0/e2/b5dedc0cf35aa65de5f541ccd30d2bc1fd7f1d43c9ab09f8ed9a7342317b/httpx2-2.5.0.tar.gz", hash = "sha256:e2df9cb4611021527ff8a675b1c320b610a2ec397acc8d6fe6e91df2d9b33c29", size = 83121, upload-time = "2026-06-25T14:16:57.491Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/31/22/859d8252dad9bc9adee34b52e62cde621ece07b042ccb2ab4da1be46695f/httpx2-2.5.0-py3-none-any.whl", hash = "sha256:3d2d4d9cf4b61f1a1f46a95947cfdb47e80cb56a2f91c6256ac8f58e4891df41", size = 76652, upload-time = "2026-06-25T14:16:55.23Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "huggingface-hub"
|
||||
version = "1.11.0"
|
||||
@ -1237,11 +1196,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.18"
|
||||
version = "3.12"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/22/12/2948fbe5513d062169bd91f7d7b1cd97bc8894f32946b71fa39f6e63ca0c/idna-3.12.tar.gz", hash = "sha256:724e9952cc9e2bd7550ea784adb098d837ab5267ef67a1ab9cf7846bdbdd8254", size = 194350, upload-time = "2026-04-21T13:32:48.916Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/b2/acc33950394b3becb2b664741a0c0889c7ef9f9ffbfa8d47eddb53a50abd/idna-3.12-py3-none-any.whl", hash = "sha256:60ffaa1858fac94c9c124728c24fcde8160f3fb4a7f79aa8cdd33a9d1af60a67", size = 68634, upload-time = "2026-04-21T13:32:47.403Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -3542,6 +3501,25 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shell-session-manager"
|
||||
version = "2.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiosqlite" },
|
||||
{ name = "anyio" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "httpx" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "sqlmodel" },
|
||||
{ name = "typer" },
|
||||
{ name = "uvicorn" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/46/c4/5ad9b6a3f8a4ca78acd4fa7a44f55b3f41ce7d539d648d9beee30789dd5b/shell_session_manager-2.4.0.tar.gz", hash = "sha256:cffeff439a149fa0626dd6fe940cd2d010005d97b429769e607a2b09ae64cb84", size = 64571, upload-time = "2026-07-03T19:11:18.931Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/be/8c/d2f6bb7d461810c22d10b775aeec3b4374eec7fb9fa503d00653d45cd8b3/shell_session_manager-2.4.0-py3-none-any.whl", hash = "sha256:5bb2bb43def96b354817ba6a45ab88fd236897a07c7f6c94b53f3f0cf30fabe6", size = 56513, upload-time = "2026-07-03T19:11:17.506Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shellingham"
|
||||
version = "1.5.4"
|
||||
@ -3922,15 +3900,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/95/0b0218149b0d6f14df35f5b8f676fa83df4f19ed253c3cc447107ef86eca/transformers-5.6.2-py3-none-any.whl", hash = "sha256:f8d3a1bb96778fed9b8aabfd0dd6e19843e4b0f2bb6b59f32b8a92051b0f348f", size = 10364898, upload-time = "2026-04-23T18:33:26.081Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "truststore"
|
||||
version = "0.10.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typer"
|
||||
version = "0.16.1"
|
||||
|
||||
@ -154,7 +154,7 @@ NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX=false
|
||||
# Enable preview features still in development (currently the /create and
|
||||
# /refine slash commands in the "Go to Anything" command palette).
|
||||
NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW=true
|
||||
NEXT_PUBLIC_ENABLE_AGENT_V2=true
|
||||
ENABLE_AGENT_V2=false
|
||||
EXPERIMENTAL_ENABLE_VINEXT=false
|
||||
|
||||
# Storage and default vector store
|
||||
@ -245,25 +245,6 @@ MARKETPLACE_ENABLED=true
|
||||
MARKETPLACE_API_URL=https://marketplace.dify.ai
|
||||
MARKETPLACE_URL=
|
||||
|
||||
# Dify Agent backend
|
||||
AGENT_BACKEND_BASE_URL=http://agent_backend:5050
|
||||
# Leave empty to derive from REDIS_PASSWORD.
|
||||
DIFY_AGENT_REDIS_URL=
|
||||
DIFY_AGENT_REDIS_PREFIX=dify-agent
|
||||
DIFY_AGENT_SHUTDOWN_GRACE_SECONDS=30
|
||||
DIFY_AGENT_RUN_RETENTION_SECONDS=259200
|
||||
# Leave empty to derive from PLUGIN_DAEMON_URL and PLUGIN_DAEMON_KEY.
|
||||
DIFY_AGENT_PLUGIN_DAEMON_URL=
|
||||
DIFY_AGENT_PLUGIN_DAEMON_API_KEY=
|
||||
# Leave empty to derive from PLUGIN_DIFY_INNER_API_URL and PLUGIN_DIFY_INNER_API_KEY.
|
||||
# DIFY_AGENT_INNER_API_KEY must match API/worker INNER_API_KEY_FOR_PLUGIN, not INNER_API_KEY.
|
||||
DIFY_AGENT_INNER_API_URL=
|
||||
DIFY_AGENT_INNER_API_KEY=
|
||||
DIFY_AGENT_SHELLCTL_ENTRYPOINT=http://local_sandbox:5004
|
||||
DIFY_AGENT_SHELLCTL_AUTH_TOKEN=
|
||||
DIFY_AGENT_STUB_API_BASE_URL=http://agent_backend:5050/agent-stub
|
||||
DIFY_AGENT_SERVER_SECRET_KEY=replace-with-base64url-32-byte-secret
|
||||
|
||||
# Nginx and Docker Compose
|
||||
NGINX_SERVER_NAME=_
|
||||
NGINX_HTTPS_ENABLED=false
|
||||
|
||||
@ -231,7 +231,6 @@ services:
|
||||
PLUGIN_MAX_PACKAGE_SIZE: ${PLUGIN_MAX_PACKAGE_SIZE:-52428800}
|
||||
PLUGIN_DAEMON_TIMEOUT: ${PLUGIN_DAEMON_TIMEOUT:-600.0}
|
||||
INNER_API_KEY_FOR_PLUGIN: ${PLUGIN_DIFY_INNER_API_KEY:-QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1}
|
||||
AGENT_BACKEND_BASE_URL: ${AGENT_BACKEND_BASE_URL:-http://agent_backend:5050}
|
||||
depends_on:
|
||||
init_permissions:
|
||||
condition: service_completed_successfully
|
||||
@ -249,8 +248,6 @@ services:
|
||||
required: false
|
||||
redis:
|
||||
condition: service_started
|
||||
agent_backend:
|
||||
condition: service_started
|
||||
volumes:
|
||||
# Mount the storage directory to the container, for storing user files.
|
||||
- ./volumes/app/storage:/app/api/storage
|
||||
@ -301,7 +298,6 @@ services:
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${API_SENTRY_PROFILES_SAMPLE_RATE:-1.0}
|
||||
PLUGIN_MAX_PACKAGE_SIZE: ${PLUGIN_MAX_PACKAGE_SIZE:-52428800}
|
||||
INNER_API_KEY_FOR_PLUGIN: ${PLUGIN_DIFY_INNER_API_KEY:-QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1}
|
||||
AGENT_BACKEND_BASE_URL: ${AGENT_BACKEND_BASE_URL:-http://agent_backend:5050}
|
||||
depends_on:
|
||||
init_permissions:
|
||||
condition: service_completed_successfully
|
||||
@ -319,8 +315,6 @@ services:
|
||||
required: false
|
||||
redis:
|
||||
condition: service_started
|
||||
agent_backend:
|
||||
condition: service_started
|
||||
volumes:
|
||||
# Mount the storage directory to the container, for storing user files.
|
||||
- ./volumes/app/storage:/app/api/storage
|
||||
@ -522,21 +516,6 @@ services:
|
||||
networks:
|
||||
- ssrf_proxy_network
|
||||
|
||||
# Local sandbox for Dify Agent shell workspaces.
|
||||
local_sandbox:
|
||||
image: langgenius/dify-agent-local-sandbox:1.15.0
|
||||
restart: always
|
||||
environment:
|
||||
SHELLCTL_AUTH_TOKEN: ${DIFY_AGENT_SHELLCTL_AUTH_TOKEN:-}
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:5004/healthz"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
networks:
|
||||
- default
|
||||
|
||||
# plugin daemon
|
||||
plugin_daemon:
|
||||
image: langgenius/dify-plugin-daemon:0.6.3-local
|
||||
@ -624,41 +603,6 @@ services:
|
||||
condition: service_healthy
|
||||
required: false
|
||||
|
||||
# Dify Agent backend service.
|
||||
agent_backend:
|
||||
image: langgenius/dify-agent-backend:1.15.0
|
||||
restart: always
|
||||
env_file:
|
||||
- path: ./envs/core-services/dify-agent.env
|
||||
required: false
|
||||
- path: ./envs/security.env
|
||||
required: false
|
||||
- path: ./envs/databases/redis.env
|
||||
required: false
|
||||
- ./.env
|
||||
environment:
|
||||
DIFY_AGENT_REDIS_URL: ${DIFY_AGENT_REDIS_URL:-redis://:${REDIS_PASSWORD:-difyai123456}@redis:6379/2}
|
||||
DIFY_AGENT_REDIS_PREFIX: ${DIFY_AGENT_REDIS_PREFIX:-dify-agent}
|
||||
DIFY_AGENT_PLUGIN_DAEMON_URL: ${DIFY_AGENT_PLUGIN_DAEMON_URL:-${PLUGIN_DAEMON_URL:-http://plugin_daemon:5002}}
|
||||
DIFY_AGENT_PLUGIN_DAEMON_API_KEY: ${DIFY_AGENT_PLUGIN_DAEMON_API_KEY:-${PLUGIN_DAEMON_KEY:-lYkiYYT6owG+71oLerGzA7GXCgOT++6ovaezWAjpCjf+Sjc3ZtU+qUEi}}
|
||||
DIFY_AGENT_INNER_API_URL: ${DIFY_AGENT_INNER_API_URL:-${PLUGIN_DIFY_INNER_API_URL:-http://api:5001}}
|
||||
DIFY_AGENT_INNER_API_KEY: ${DIFY_AGENT_INNER_API_KEY:-${PLUGIN_DIFY_INNER_API_KEY:-QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1}}
|
||||
DIFY_AGENT_SHELLCTL_ENTRYPOINT: ${DIFY_AGENT_SHELLCTL_ENTRYPOINT:-http://local_sandbox:5004}
|
||||
DIFY_AGENT_SHELLCTL_AUTH_TOKEN: ${DIFY_AGENT_SHELLCTL_AUTH_TOKEN:-}
|
||||
DIFY_AGENT_STUB_API_BASE_URL: ${DIFY_AGENT_STUB_API_BASE_URL:-http://agent_backend:5050/agent-stub}
|
||||
DIFY_AGENT_SERVER_SECRET_KEY: ${DIFY_AGENT_SERVER_SECRET_KEY:-replace-with-base64url-32-byte-secret}
|
||||
DIFY_AGENT_SHUTDOWN_GRACE_SECONDS: ${DIFY_AGENT_SHUTDOWN_GRACE_SECONDS:-30}
|
||||
DIFY_AGENT_RUN_RETENTION_SECONDS: ${DIFY_AGENT_RUN_RETENTION_SECONDS:-259200}
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_started
|
||||
plugin_daemon:
|
||||
condition: service_started
|
||||
local_sandbox:
|
||||
condition: service_started
|
||||
networks:
|
||||
- default
|
||||
|
||||
# ssrf_proxy server
|
||||
# for more information, please refer to
|
||||
# https://docs.dify.ai/learn-more/faq/install-faq#18-why-is-ssrf-proxy-needed%3F
|
||||
|
||||
@ -237,7 +237,6 @@ services:
|
||||
PLUGIN_MAX_PACKAGE_SIZE: ${PLUGIN_MAX_PACKAGE_SIZE:-52428800}
|
||||
PLUGIN_DAEMON_TIMEOUT: ${PLUGIN_DAEMON_TIMEOUT:-600.0}
|
||||
INNER_API_KEY_FOR_PLUGIN: ${PLUGIN_DIFY_INNER_API_KEY:-QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1}
|
||||
AGENT_BACKEND_BASE_URL: ${AGENT_BACKEND_BASE_URL:-http://agent_backend:5050}
|
||||
depends_on:
|
||||
init_permissions:
|
||||
condition: service_completed_successfully
|
||||
@ -255,8 +254,6 @@ services:
|
||||
required: false
|
||||
redis:
|
||||
condition: service_started
|
||||
agent_backend:
|
||||
condition: service_started
|
||||
volumes:
|
||||
# Mount the storage directory to the container, for storing user files.
|
||||
- ./volumes/app/storage:/app/api/storage
|
||||
@ -307,7 +304,6 @@ services:
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${API_SENTRY_PROFILES_SAMPLE_RATE:-1.0}
|
||||
PLUGIN_MAX_PACKAGE_SIZE: ${PLUGIN_MAX_PACKAGE_SIZE:-52428800}
|
||||
INNER_API_KEY_FOR_PLUGIN: ${PLUGIN_DIFY_INNER_API_KEY:-QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1}
|
||||
AGENT_BACKEND_BASE_URL: ${AGENT_BACKEND_BASE_URL:-http://agent_backend:5050}
|
||||
depends_on:
|
||||
init_permissions:
|
||||
condition: service_completed_successfully
|
||||
@ -325,8 +321,6 @@ services:
|
||||
required: false
|
||||
redis:
|
||||
condition: service_started
|
||||
agent_backend:
|
||||
condition: service_started
|
||||
volumes:
|
||||
# Mount the storage directory to the container, for storing user files.
|
||||
- ./volumes/app/storage:/app/api/storage
|
||||
@ -528,21 +522,6 @@ services:
|
||||
networks:
|
||||
- ssrf_proxy_network
|
||||
|
||||
# Local sandbox for Dify Agent shell workspaces.
|
||||
local_sandbox:
|
||||
image: langgenius/dify-agent-local-sandbox:1.15.0
|
||||
restart: always
|
||||
environment:
|
||||
SHELLCTL_AUTH_TOKEN: ${DIFY_AGENT_SHELLCTL_AUTH_TOKEN:-}
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:5004/healthz"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
networks:
|
||||
- default
|
||||
|
||||
# plugin daemon
|
||||
plugin_daemon:
|
||||
image: langgenius/dify-plugin-daemon:0.6.3-local
|
||||
@ -630,41 +609,6 @@ services:
|
||||
condition: service_healthy
|
||||
required: false
|
||||
|
||||
# Dify Agent backend service.
|
||||
agent_backend:
|
||||
image: langgenius/dify-agent-backend:1.15.0
|
||||
restart: always
|
||||
env_file:
|
||||
- path: ./envs/core-services/dify-agent.env
|
||||
required: false
|
||||
- path: ./envs/security.env
|
||||
required: false
|
||||
- path: ./envs/databases/redis.env
|
||||
required: false
|
||||
- ./.env
|
||||
environment:
|
||||
DIFY_AGENT_REDIS_URL: ${DIFY_AGENT_REDIS_URL:-redis://:${REDIS_PASSWORD:-difyai123456}@redis:6379/2}
|
||||
DIFY_AGENT_REDIS_PREFIX: ${DIFY_AGENT_REDIS_PREFIX:-dify-agent}
|
||||
DIFY_AGENT_PLUGIN_DAEMON_URL: ${DIFY_AGENT_PLUGIN_DAEMON_URL:-${PLUGIN_DAEMON_URL:-http://plugin_daemon:5002}}
|
||||
DIFY_AGENT_PLUGIN_DAEMON_API_KEY: ${DIFY_AGENT_PLUGIN_DAEMON_API_KEY:-${PLUGIN_DAEMON_KEY:-lYkiYYT6owG+71oLerGzA7GXCgOT++6ovaezWAjpCjf+Sjc3ZtU+qUEi}}
|
||||
DIFY_AGENT_INNER_API_URL: ${DIFY_AGENT_INNER_API_URL:-${PLUGIN_DIFY_INNER_API_URL:-http://api:5001}}
|
||||
DIFY_AGENT_INNER_API_KEY: ${DIFY_AGENT_INNER_API_KEY:-${PLUGIN_DIFY_INNER_API_KEY:-QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1}}
|
||||
DIFY_AGENT_SHELLCTL_ENTRYPOINT: ${DIFY_AGENT_SHELLCTL_ENTRYPOINT:-http://local_sandbox:5004}
|
||||
DIFY_AGENT_SHELLCTL_AUTH_TOKEN: ${DIFY_AGENT_SHELLCTL_AUTH_TOKEN:-}
|
||||
DIFY_AGENT_STUB_API_BASE_URL: ${DIFY_AGENT_STUB_API_BASE_URL:-http://agent_backend:5050/agent-stub}
|
||||
DIFY_AGENT_SERVER_SECRET_KEY: ${DIFY_AGENT_SERVER_SECRET_KEY:-replace-with-base64url-32-byte-secret}
|
||||
DIFY_AGENT_SHUTDOWN_GRACE_SECONDS: ${DIFY_AGENT_SHUTDOWN_GRACE_SECONDS:-30}
|
||||
DIFY_AGENT_RUN_RETENTION_SECONDS: ${DIFY_AGENT_RUN_RETENTION_SECONDS:-259200}
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_started
|
||||
plugin_daemon:
|
||||
condition: service_started
|
||||
local_sandbox:
|
||||
condition: service_started
|
||||
networks:
|
||||
- default
|
||||
|
||||
# ssrf_proxy server
|
||||
# for more information, please refer to
|
||||
# https://docs.dify.ai/learn-more/faq/install-faq#18-why-is-ssrf-proxy-needed%3F
|
||||
|
||||
@ -1,25 +0,0 @@
|
||||
# ------------------------------
|
||||
# Dify Agent Backend Configuration
|
||||
# ------------------------------
|
||||
|
||||
AGENT_BACKEND_BASE_URL=http://agent_backend:5050
|
||||
|
||||
# Leave empty to derive from REDIS_PASSWORD in Docker Compose.
|
||||
DIFY_AGENT_REDIS_URL=
|
||||
DIFY_AGENT_REDIS_PREFIX=dify-agent
|
||||
DIFY_AGENT_SHUTDOWN_GRACE_SECONDS=30
|
||||
DIFY_AGENT_RUN_RETENTION_SECONDS=259200
|
||||
|
||||
# Leave empty to derive from PLUGIN_DAEMON_URL and PLUGIN_DAEMON_KEY in Docker Compose.
|
||||
DIFY_AGENT_PLUGIN_DAEMON_URL=
|
||||
DIFY_AGENT_PLUGIN_DAEMON_API_KEY=
|
||||
|
||||
# Leave empty to derive from PLUGIN_DIFY_INNER_API_URL and PLUGIN_DIFY_INNER_API_KEY in Docker Compose.
|
||||
# DIFY_AGENT_INNER_API_KEY must match API/worker INNER_API_KEY_FOR_PLUGIN, not INNER_API_KEY.
|
||||
DIFY_AGENT_INNER_API_URL=
|
||||
DIFY_AGENT_INNER_API_KEY=
|
||||
|
||||
DIFY_AGENT_SHELLCTL_ENTRYPOINT=http://local_sandbox:5004
|
||||
DIFY_AGENT_SHELLCTL_AUTH_TOKEN=
|
||||
DIFY_AGENT_STUB_API_BASE_URL=http://agent_backend:5050/agent-stub
|
||||
DIFY_AGENT_SERVER_SECRET_KEY=replace-with-base64url-32-byte-secret
|
||||
@ -25,7 +25,7 @@ ENABLE_WEBSITE_FIRECRAWL=true
|
||||
ENABLE_WEBSITE_WATERCRAWL=true
|
||||
NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX=false
|
||||
NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW=true
|
||||
NEXT_PUBLIC_ENABLE_AGENT_V2=true
|
||||
ENABLE_AGENT_V2=false
|
||||
NEXT_PUBLIC_COOKIE_DOMAIN=
|
||||
NEXT_PUBLIC_BATCH_CONCURRENCY=5
|
||||
CSP_WHITELIST=
|
||||
|
||||
@ -14,7 +14,7 @@
|
||||
"e2e:middleware:up": "tsx ./scripts/setup.ts middleware-up",
|
||||
"e2e:reset": "tsx ./scripts/setup.ts reset",
|
||||
"seed": "tsx ./scripts/seed.ts",
|
||||
"type-check": "tsgo"
|
||||
"type-check": "tsc"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cucumber/cucumber": "catalog:",
|
||||
@ -23,7 +23,7 @@
|
||||
"@playwright/test": "catalog:",
|
||||
"@t3-oss/env-core": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"@typescript/native": "catalog:",
|
||||
"tsx": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
|
||||
@ -967,11 +967,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/base/amplitude/utils.ts": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/base/app-icon-picker/ImageInput.tsx": {
|
||||
"react/no-create-ref": {
|
||||
"count": 1
|
||||
@ -6910,11 +6905,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/scripts/component-analyzer.js": {
|
||||
"regexp/no-unused-capturing-group": {
|
||||
"count": 6
|
||||
}
|
||||
},
|
||||
"web/service/__tests__/base.spec.ts": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
|
||||
@ -24,11 +24,13 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "catalog:",
|
||||
"@typescript/native": "catalog:",
|
||||
"concurrently": "catalog:",
|
||||
"eslint": "catalog:",
|
||||
"eslint-markdown": "catalog:",
|
||||
"eslint-plugin-markdown-preferences": "catalog:",
|
||||
"eslint-plugin-no-barrel-files": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vite-plus": "catalog:"
|
||||
}
|
||||
|
||||
@ -22,7 +22,7 @@
|
||||
"gen-api-contract-from-openapi": "node -e \"fs.rmSync('generated/api', { recursive: true, force: true })\" && openapi-ts -f openapi-ts.api.config.ts && vp fmt generated/api && eslint --fix generated/api",
|
||||
"gen-api-openapi": "uv run --project ../../api ../../api/dev/generate_swagger_specs.py --output-dir openapi",
|
||||
"gen-enterprise-contract": "openapi-ts -f openapi-ts.enterprise.config.ts",
|
||||
"type-check": "tsgo"
|
||||
"type-check": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@orpc/contract": "catalog:",
|
||||
@ -33,7 +33,7 @@
|
||||
"@hey-api/openapi-ts": "catalog:",
|
||||
"@types/js-yaml": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"@typescript/native": "catalog:",
|
||||
"eslint": "catalog:",
|
||||
"js-yaml": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
|
||||
@ -24,7 +24,7 @@
|
||||
"build": "vp pack",
|
||||
"prepare": "pnpm run build",
|
||||
"test": "vp test",
|
||||
"type-check": "tsgo",
|
||||
"type-check": "tsc",
|
||||
"prepublish": "pnpm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
@ -36,7 +36,7 @@
|
||||
"devDependencies": {
|
||||
"@dify/tsconfig": "workspace:*",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"@typescript/native": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vite-plus": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
|
||||
@ -156,7 +156,7 @@
|
||||
"test": "vp test --project unit",
|
||||
"test:storybook": "vp test --project storybook --run",
|
||||
"test:watch": "vp test --project unit --watch",
|
||||
"type-check": "tsgo"
|
||||
"type-check": "tsc"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@base-ui/react": "catalog:",
|
||||
@ -186,7 +186,7 @@
|
||||
"@tanstack/react-virtual": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"@typescript/native": "catalog:",
|
||||
"@vitejs/plugin-react": "catalog:",
|
||||
"@vitest/browser": "catalog:",
|
||||
"@vitest/browser-playwright": "catalog:",
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vp test",
|
||||
"type-check": "tsgo"
|
||||
"type-check": "tsc"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tanstack/form-core": "catalog:",
|
||||
@ -20,7 +20,7 @@
|
||||
"devDependencies": {
|
||||
"@dify/tsconfig": "workspace:*",
|
||||
"@tanstack/form-core": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"@typescript/native": "catalog:",
|
||||
"jotai": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
|
||||
@ -8,10 +8,10 @@
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vp pack",
|
||||
"type-check": "tsgo"
|
||||
"type-check": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"@typescript/native": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
2122
pnpm-lock.yaml
generated
2122
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -131,7 +131,7 @@ catalog:
|
||||
'@types/sortablejs': 1.15.9
|
||||
'@typescript-eslint/eslint-plugin': 8.62.0
|
||||
'@typescript-eslint/parser': 8.62.0
|
||||
'@typescript/native-preview': 7.0.0-dev.20260627.2
|
||||
'@typescript/native': npm:typescript@7.0.2
|
||||
'@vitejs/plugin-react': 6.0.3
|
||||
'@vitejs/plugin-rsc': 0.5.27
|
||||
'@vitest/browser': 4.1.9
|
||||
@ -169,7 +169,6 @@ catalog:
|
||||
eslint-plugin-markdown-preferences: 0.41.1
|
||||
eslint-plugin-no-barrel-files: 1.3.1
|
||||
eslint-plugin-react-refresh: 0.5.3
|
||||
eslint-plugin-sonarjs: 4.1.0
|
||||
eslint-plugin-storybook: 10.4.6
|
||||
eventsource-parser: 3.1.0
|
||||
fast-deep-equal: 3.1.3
|
||||
@ -242,7 +241,7 @@ catalog:
|
||||
tailwindcss: 4.3.1
|
||||
tldts: 7.4.4
|
||||
tsx: 4.22.4
|
||||
typescript: 6.0.3
|
||||
typescript: npm:@typescript/typescript6@6.0.2
|
||||
uglify-js: 3.19.3
|
||||
undici: 7.28.0
|
||||
unist-util-visit: 5.1.0
|
||||
|
||||
@ -48,7 +48,7 @@
|
||||
"build": "vp pack",
|
||||
"lint": "eslint",
|
||||
"lint:fix": "eslint --fix",
|
||||
"type-check": "tsgo",
|
||||
"type-check": "tsc",
|
||||
"test": "vp test",
|
||||
"test:coverage": "vp test --coverage",
|
||||
"publish:check": "./scripts/publish.sh --dry-run",
|
||||
@ -60,7 +60,7 @@
|
||||
"@types/node": "catalog:",
|
||||
"@typescript-eslint/eslint-plugin": "catalog:",
|
||||
"@typescript-eslint/parser": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"@typescript/native": "catalog:",
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"eslint": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
|
||||
@ -91,7 +91,7 @@ NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX=false
|
||||
NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW=true
|
||||
|
||||
# Enable Agent v2 frontend entry points.
|
||||
NEXT_PUBLIC_ENABLE_AGENT_V2=true
|
||||
NEXT_PUBLIC_ENABLE_AGENT_V2=false
|
||||
|
||||
# The maximum number of tree node depth for workflow
|
||||
NEXT_PUBLIC_MAX_TREE_DEPTH=50
|
||||
|
||||
@ -140,16 +140,6 @@ If you are not familiar with writing tests, refer to:
|
||||
|
||||
- [index.spec.tsx] - Component test example
|
||||
|
||||
### Analyze Component Complexity
|
||||
|
||||
Before writing tests, use the script to analyze component complexity:
|
||||
|
||||
```bash
|
||||
pnpm analyze-component app/components/your-component/index.tsx
|
||||
```
|
||||
|
||||
This will help you determine the testing strategy. See [web/docs/test.md] for details.
|
||||
|
||||
## Documentation
|
||||
|
||||
Visit <https://docs.dify.ai> to view the full documentation.
|
||||
|
||||
@ -14,7 +14,7 @@ describe('proxy frame options', () => {
|
||||
expect(canEmbedPath('/agents')).toBe(false)
|
||||
expect(canEmbedPath('/agent-settings')).toBe(false)
|
||||
expect(canEmbedPath('/agentic')).toBe(false)
|
||||
expect(canEmbedPath('/agents/agent-1/access')).toBe(false)
|
||||
expect(canEmbedPath('/roster/agent/agent-1/access')).toBe(false)
|
||||
expect(canEmbedPath('/apps')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@ -9,5 +9,5 @@ export default async function Page({
|
||||
}: PageProps) {
|
||||
const { agentId } = await params
|
||||
|
||||
redirect(`/agents/${agentId}/configure`)
|
||||
redirect(`/roster/agent/${agentId}/configure`)
|
||||
}
|
||||
@ -58,6 +58,23 @@ describe('ExternalAttributionRecorder', () => {
|
||||
expect(firstArg?.searchParams?.get('slug')).toBe('get-started-with-dify')
|
||||
})
|
||||
|
||||
it('seeds attribution from the redirect_url when auth redirects away from the landing url', async () => {
|
||||
setSearchParams(`redirect_url=${encodeURIComponent('/apps?utm_source=dify_blog&slug=buildaisupportassistantwithdify')}`)
|
||||
|
||||
render(<ExternalAttributionRecorder />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getUtmInfoCookie()).toEqual({
|
||||
utm_source: 'dify_blog',
|
||||
slug: 'buildaisupportassistantwithdify',
|
||||
})
|
||||
})
|
||||
expect(mockRememberCreateAppExternalAttribution).toHaveBeenCalledTimes(1)
|
||||
const firstArg = mockRememberCreateAppExternalAttribution.mock.calls[0]?.[0]
|
||||
expect(firstArg?.searchParams?.get('utm_source')).toBe('dify_blog')
|
||||
expect(firstArg?.searchParams?.get('slug')).toBe('buildaisupportassistantwithdify')
|
||||
})
|
||||
|
||||
it('does nothing without a utm_source', () => {
|
||||
setSearchParams('slug=get-started-with-dify')
|
||||
|
||||
@ -67,6 +84,15 @@ describe('ExternalAttributionRecorder', () => {
|
||||
expect(mockRememberCreateAppExternalAttribution).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing for cross-origin redirect_url attribution params', () => {
|
||||
setSearchParams(`redirect_url=${encodeURIComponent('https://example.com/apps?utm_source=dify_blog&slug=get-started-with-dify')}`)
|
||||
|
||||
render(<ExternalAttributionRecorder />)
|
||||
|
||||
expect(getUtmInfoCookie()).toBeNull()
|
||||
expect(mockRememberCreateAppExternalAttribution).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('overwrites a stale utm_info cookie with the latest campaign params', async () => {
|
||||
Cookies.set('utm_info', JSON.stringify({ utm_source: 'newsletter', slug: 'launch-week' }))
|
||||
setSearchParams('utm_source=dify_blog&slug=get-started-with-dify')
|
||||
|
||||
@ -301,7 +301,7 @@ describe('ConfigurationView', () => {
|
||||
fireEvent.click(badge)
|
||||
|
||||
expect(await screen.findByText('appDebug.legacyAgentBadge.description')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /appDebug\.legacyAgentBadge\.action/ })).toHaveAttribute('href', '/agents')
|
||||
expect(screen.getByRole('link', { name: /appDebug\.legacyAgentBadge\.action/ })).toHaveAttribute('href', '/roster')
|
||||
expect(screen.getByRole('link', { name: /appDebug\.legacyAgentBadge\.action/ })).toHaveAttribute('target', '_blank')
|
||||
expect(screen.getByRole('link', { name: /appDebug\.legacyAgentBadge\.action/ })).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
})
|
||||
|
||||
@ -70,7 +70,7 @@ function LegacyAgentBadge() {
|
||||
>
|
||||
<div>{description}</div>
|
||||
<Link
|
||||
href="/agents"
|
||||
href="/roster"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-1 inline-flex items-center gap-0.5 rounded-md system-xs-medium text-text-accent outline-hidden hover:underline focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
|
||||
@ -195,6 +195,32 @@ describe('CreateAppModal', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('waits for create_app tracking before redirecting after blank app creation', async () => {
|
||||
const mockApp: Partial<App> = { id: 'app-1', mode: AppModeEnum.ADVANCED_CHAT, maintainer: 'user-1' }
|
||||
let resolveTracking: (() => void) | undefined
|
||||
mockCreateApp.mockResolvedValue(mockApp as App)
|
||||
mockTrackCreateApp.mockReturnValue(new Promise<void>((resolve) => {
|
||||
resolveTracking = resolve
|
||||
}))
|
||||
renderModal()
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('app.newApp.appNamePlaceholder'), {
|
||||
target: { value: 'Tracked App' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: /app\.newApp\.Create/ }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTrackCreateApp).toHaveBeenCalledWith({ source: 'studio_blank', appMode: AppModeEnum.ADVANCED_CHAT })
|
||||
})
|
||||
expect(mockGetRedirection).not.toHaveBeenCalled()
|
||||
|
||||
resolveTracking?.()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetRedirection).toHaveBeenCalledWith(mockApp, mockPush, expect.any(Object))
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error toast when creation fails', async () => {
|
||||
mockCreateApp.mockRejectedValue(new Error('boom'))
|
||||
const { onClose } = renderModal()
|
||||
|
||||
@ -95,7 +95,12 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
|
||||
mode: appMode,
|
||||
})
|
||||
|
||||
trackCreateApp({ source: 'studio_blank', appMode: app.mode })
|
||||
try {
|
||||
await trackCreateApp({ source: 'studio_blank', appMode: app.mode })
|
||||
}
|
||||
catch {
|
||||
// Analytics should not turn a successful app creation into a failed flow.
|
||||
}
|
||||
|
||||
toast.success(t('newApp.appCreated', { ns: 'app' }))
|
||||
onSuccess()
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import { resetUser, setUserId, setUserProperties, trackEvent } from '../utils'
|
||||
import { flushEvents, resetUser, setUserId, setUserProperties, trackEvent } from '../utils'
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
enabled: true,
|
||||
}))
|
||||
|
||||
const mockTrack = vi.hoisted(() => vi.fn())
|
||||
const mockFlush = vi.hoisted(() => vi.fn())
|
||||
const mockSetUserId = vi.hoisted(() => vi.fn())
|
||||
const mockIdentify = vi.hoisted(() => vi.fn())
|
||||
const mockReset = vi.hoisted(() => vi.fn())
|
||||
@ -28,6 +29,7 @@ vi.mock('@/config', () => ({
|
||||
|
||||
vi.mock('@amplitude/analytics-browser', () => ({
|
||||
track: (...args: unknown[]) => mockTrack(...args),
|
||||
flush: (...args: unknown[]) => mockFlush(...args),
|
||||
setUserId: (...args: unknown[]) => mockSetUserId(...args),
|
||||
identify: (...args: unknown[]) => mockIdentify(...args),
|
||||
reset: (...args: unknown[]) => mockReset(...args),
|
||||
@ -41,11 +43,15 @@ describe('amplitude utils', () => {
|
||||
})
|
||||
|
||||
describe('trackEvent', () => {
|
||||
it('should call amplitude.track when amplitude is enabled', () => {
|
||||
trackEvent('dataset_created', { source: 'wizard' })
|
||||
it('should call amplitude.track and return its result when amplitude is enabled', () => {
|
||||
const trackResult = { promise: Promise.resolve({}) }
|
||||
mockTrack.mockReturnValue(trackResult)
|
||||
|
||||
const result = trackEvent('dataset_created', { source: 'wizard' })
|
||||
|
||||
expect(mockTrack).toHaveBeenCalledTimes(1)
|
||||
expect(mockTrack).toHaveBeenCalledWith('dataset_created', { source: 'wizard' })
|
||||
expect(result).toBe(trackResult)
|
||||
})
|
||||
|
||||
it('should not call amplitude.track when amplitude is disabled', () => {
|
||||
@ -57,6 +63,26 @@ describe('amplitude utils', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('flushEvents', () => {
|
||||
it('should call amplitude.flush and return its result when amplitude is enabled', () => {
|
||||
const flushResult = { promise: Promise.resolve() }
|
||||
mockFlush.mockReturnValue(flushResult)
|
||||
|
||||
const result = flushEvents()
|
||||
|
||||
expect(mockFlush).toHaveBeenCalledTimes(1)
|
||||
expect(result).toBe(flushResult)
|
||||
})
|
||||
|
||||
it('should not call amplitude.flush when amplitude is disabled', () => {
|
||||
mockState.enabled = false
|
||||
|
||||
flushEvents()
|
||||
|
||||
expect(mockFlush).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('setUserId', () => {
|
||||
it('should call amplitude.setUserId when amplitude is enabled', () => {
|
||||
setUserId('user-123')
|
||||
@ -76,7 +102,7 @@ describe('amplitude utils', () => {
|
||||
|
||||
describe('setUserProperties', () => {
|
||||
it('should build identify event and call amplitude.identify when amplitude is enabled', () => {
|
||||
const properties: Record<string, unknown> = {
|
||||
const properties = {
|
||||
role: 'owner',
|
||||
seats: 3,
|
||||
verified: true,
|
||||
|
||||
@ -6,10 +6,16 @@ import { isAmplitudeEnabled } from '@/config'
|
||||
* @param eventName Event name
|
||||
* @param eventProperties Event properties (optional)
|
||||
*/
|
||||
export const trackEvent = (eventName: string, eventProperties?: Record<string, any>) => {
|
||||
export const trackEvent = (eventName: string, eventProperties?: Record<string, unknown>) => {
|
||||
if (!isAmplitudeEnabled)
|
||||
return
|
||||
amplitude.track(eventName, eventProperties)
|
||||
return amplitude.track(eventName, eventProperties)
|
||||
}
|
||||
|
||||
export const flushEvents = () => {
|
||||
if (!isAmplitudeEnabled)
|
||||
return
|
||||
return amplitude.flush()
|
||||
}
|
||||
|
||||
/**
|
||||
@ -26,7 +32,7 @@ export const setUserId = (userId: string) => {
|
||||
* Set user properties
|
||||
* @param properties User properties
|
||||
*/
|
||||
export const setUserProperties = (properties: Record<string, any>) => {
|
||||
export const setUserProperties = (properties: Record<string, amplitude.Types.ValidPropertyType>) => {
|
||||
if (!isAmplitudeEnabled)
|
||||
return
|
||||
const identifyEvent = new amplitude.Identify()
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { ChatItem } from '../../../types'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { AgentRosterResponseContent } from '../agent-roster-response-content'
|
||||
|
||||
describe('AgentRosterResponseContent', () => {
|
||||
@ -25,11 +25,6 @@ describe('AgentRosterResponseContent', () => {
|
||||
|
||||
render(<AgentRosterResponseContent item={item} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: /workFinished/i })).toBeInTheDocument()
|
||||
expect(screen.queryByText('history answer')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /workFinished/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('agent-roster-response-content')).toHaveTextContent('history answer')
|
||||
})
|
||||
|
||||
@ -92,6 +92,10 @@ function getCompletedTitle(latency: NonNullable<ChatItem['more']>['latency'] | u
|
||||
return t('agentDetail.configure.answer.workFinished')
|
||||
}
|
||||
|
||||
function hasThoughtAnswer(thought: ThoughtItem) {
|
||||
return !!thought.answer?.trim()
|
||||
}
|
||||
|
||||
function useWorkingDuration(enabled?: boolean) {
|
||||
const startedAtRef = useRef<number | null>(null)
|
||||
const [now, setNow] = useState(0)
|
||||
@ -398,6 +402,7 @@ export function AgentRosterResponseContent({
|
||||
annotation,
|
||||
agent_thoughts,
|
||||
} = item
|
||||
const hasAgentThoughtAnswer = agent_thoughts?.some(hasThoughtAnswer)
|
||||
|
||||
if (annotation?.logAnnotation) {
|
||||
return (
|
||||
@ -418,7 +423,13 @@ export function AgentRosterResponseContent({
|
||||
)}
|
||||
{!item.agent_response_parts?.length && (
|
||||
<>
|
||||
{!!agent_thoughts?.length && (
|
||||
{!!agent_thoughts?.length && hasAgentThoughtAnswer && (
|
||||
<AgentThoughtsProcessList
|
||||
item={item}
|
||||
responding={responding}
|
||||
/>
|
||||
)}
|
||||
{!!agent_thoughts?.length && !hasAgentThoughtAnswer && (
|
||||
<AgentThoughtsProcessGroup
|
||||
item={item}
|
||||
responding={responding}
|
||||
|
||||
@ -10,6 +10,44 @@ const UTM_INFO_COOKIE = 'utm_info'
|
||||
const UTM_INFO_COOKIE_EXPIRES_DAYS = 1
|
||||
const UTM_INFO_QUERY_KEYS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term', 'slug'] as const
|
||||
|
||||
type SearchParamReader = {
|
||||
get: (name: string) => string | null
|
||||
}
|
||||
|
||||
const normalizeString = (value?: string | null) => {
|
||||
const trimmed = value?.trim()
|
||||
return trimmed || undefined
|
||||
}
|
||||
|
||||
const getSearchParamValue = (searchParams: SearchParamReader, key: string) =>
|
||||
normalizeString(searchParams.get(key))
|
||||
|
||||
const parseRedirectUrlSearchParams = (redirectUrl: string) => {
|
||||
const baseUrl = window.location.origin
|
||||
|
||||
try {
|
||||
const url = new URL(redirectUrl, baseUrl)
|
||||
if (url.origin !== baseUrl)
|
||||
return null
|
||||
|
||||
return url.searchParams
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const resolveAttributionSearchParams = (searchParams: SearchParamReader): SearchParamReader | null => {
|
||||
if (getSearchParamValue(searchParams, 'utm_source'))
|
||||
return searchParams
|
||||
|
||||
const redirectUrl = getSearchParamValue(searchParams, 'redirect_url')
|
||||
if (!redirectUrl)
|
||||
return null
|
||||
|
||||
return parseRedirectUrlSearchParams(redirectUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures external-campaign params (utm_* + blog `slug`) from the landing URL.
|
||||
*
|
||||
@ -32,12 +70,16 @@ const ExternalAttributionRecorder = () => {
|
||||
if (!IS_CLOUD_EDITION)
|
||||
return
|
||||
|
||||
const utmSource = searchParams.get('utm_source')?.trim()
|
||||
const attributionSearchParams = resolveAttributionSearchParams(searchParams)
|
||||
if (!attributionSearchParams)
|
||||
return
|
||||
|
||||
const utmSource = getSearchParamValue(attributionSearchParams, 'utm_source')
|
||||
if (!utmSource)
|
||||
return
|
||||
|
||||
// create_app conversion attribution (utm_source + slug).
|
||||
rememberCreateAppExternalAttribution({ searchParams })
|
||||
rememberCreateAppExternalAttribution({ searchParams: attributionSearchParams })
|
||||
|
||||
// Seed the utm_info cookie the registration trackers read. A campaign click always
|
||||
// overwrites any previous value, so the most recent blog link wins (last-touch) and
|
||||
@ -45,7 +87,7 @@ const ExternalAttributionRecorder = () => {
|
||||
// mirrors the create_app attribution refreshed just above.
|
||||
const utmInfo: Record<string, string> = {}
|
||||
UTM_INFO_QUERY_KEYS.forEach((key) => {
|
||||
const value = searchParams.get(key)?.trim()
|
||||
const value = getSearchParamValue(attributionSearchParams, key)
|
||||
if (value)
|
||||
utmInfo[key] = value
|
||||
})
|
||||
|
||||
@ -324,8 +324,8 @@ describe('MainNav', () => {
|
||||
expect(screen.getByRole('button', { name: 'common.account.account' })).not.toHaveTextContent(Plan.team)
|
||||
expect(screen.getByRole('link', { name: /common.mainNav.home/ })).toHaveAttribute('href', '/')
|
||||
expect(screen.getByRole('link', { name: /common.menus.apps/ })).toHaveAttribute('href', '/apps')
|
||||
expect(screen.getByRole('link', { name: /Agents/ })).toHaveAttribute('href', '/agents')
|
||||
expect(screen.getByRole('link', { name: /Agents common.menus.status/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /common.menus.roster/ })).toHaveAttribute('href', '/roster')
|
||||
expect(screen.getByRole('link', { name: /common.menus.roster common.menus.status/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /common.menus.datasets/ })).toHaveAttribute('href', '/datasets')
|
||||
expect(screen.getByRole('link', { name: /common.mainNav.integrations/ })).toHaveAttribute('href', '/integrations/model-provider')
|
||||
expect(screen.getByRole('link', { name: /common.mainNav.marketplace/ })).toHaveAttribute('href', '/marketplace')
|
||||
@ -336,7 +336,7 @@ describe('MainNav', () => {
|
||||
|
||||
renderMainNav()
|
||||
|
||||
expect(screen.queryByRole('link', { name: /Agents/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: /common.menus.roster/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the marketplace entry when marketplace is disabled', () => {
|
||||
@ -459,7 +459,7 @@ describe('MainNav', () => {
|
||||
|
||||
expect(screen.getByRole('link', { name: /common.mainNav.home/ })).toHaveAttribute('href', '/')
|
||||
expect(screen.getByRole('link', { name: /common.menus.apps/ })).toHaveAttribute('href', '/apps')
|
||||
expect(screen.queryByRole('link', { name: /Agents/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: /common.menus.roster/ })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /common.menus.datasets/ })).toHaveAttribute('href', '/datasets')
|
||||
expect(screen.getByRole('link', { name: /common.mainNav.integrations/ })).toHaveAttribute('href', '/integrations/model-provider')
|
||||
expect(screen.getByRole('link', { name: /common.mainNav.marketplace/ })).toHaveAttribute('href', '/marketplace')
|
||||
@ -485,7 +485,7 @@ describe('MainNav', () => {
|
||||
|
||||
expect(screen.getByRole('link', { name: /common.mainNav.home/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /common.menus.apps/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /Agents/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /common.menus.roster/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /common.menus.datasets/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /common.mainNav.integrations/ })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: /common.menus.deployments/ })).not.toBeInTheDocument()
|
||||
@ -516,13 +516,13 @@ describe('MainNav', () => {
|
||||
|
||||
it('keeps roster detail navigation hidden when Agent v2 is disabled', () => {
|
||||
mockIsAgentV2Enabled.mockReturnValue(false)
|
||||
mockPathname = '/agents/agent-1/configure'
|
||||
mockPathname = '/roster/agent/agent-1/configure'
|
||||
|
||||
renderMainNav()
|
||||
|
||||
expect(screen.queryByTestId('agent-detail-top')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('agent-detail-section')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: /Agents/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: /common.menus.roster/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
@ -566,11 +566,11 @@ describe('MainNav', () => {
|
||||
})
|
||||
|
||||
it('marks roster active on roster routes', () => {
|
||||
mockPathname = '/agents'
|
||||
mockPathname = '/roster'
|
||||
|
||||
renderMainNav()
|
||||
|
||||
const rosterLink = screen.getByRole('link', { name: /Agents/ })
|
||||
const rosterLink = screen.getByRole('link', { name: /common.menus.roster/ })
|
||||
expect(rosterLink).toHaveClass(activeGradientMaskClassName)
|
||||
expect(rosterLink).toHaveAttribute('aria-current', 'page')
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user