shell-session-manager 1.0.0__tar.gz → 2.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {shell_session_manager-1.0.0 → shell_session_manager-2.0.0}/PKG-INFO +9 -2
- {shell_session_manager-1.0.0 → shell_session_manager-2.0.0}/pyproject.toml +12 -2
- {shell_session_manager-1.0.0 → shell_session_manager-2.0.0}/src/shell_session_manager/__init__.py +4 -0
- shell_session_manager-2.0.0/src/shell_session_manager/shellctl/__init__.py +69 -0
- shell_session_manager-2.0.0/src/shell_session_manager/shellctl/client/__init__.py +12 -0
- shell_session_manager-2.0.0/src/shell_session_manager/shellctl/client/sdk.py +264 -0
- shell_session_manager-2.0.0/src/shell_session_manager/shellctl/server/__init__.py +33 -0
- shell_session_manager-2.0.0/src/shell_session_manager/shellctl/server/__main__.py +6 -0
- shell_session_manager-2.0.0/src/shell_session_manager/shellctl/server/api.py +201 -0
- shell_session_manager-2.0.0/src/shell_session_manager/shellctl/server/cli.py +104 -0
- shell_session_manager-2.0.0/src/shell_session_manager/shellctl/server/config.py +103 -0
- shell_session_manager-2.0.0/src/shell_session_manager/shellctl/server/db.py +52 -0
- shell_session_manager-2.0.0/src/shell_session_manager/shellctl/server/errors.py +14 -0
- shell_session_manager-2.0.0/src/shell_session_manager/shellctl/server/service.py +1018 -0
- shell_session_manager-2.0.0/src/shell_session_manager/shellctl/server/tmux.py +296 -0
- shell_session_manager-2.0.0/src/shell_session_manager/shellctl/shared/__init__.py +117 -0
- shell_session_manager-2.0.0/src/shell_session_manager/shellctl/shared/constants.py +44 -0
- shell_session_manager-2.0.0/src/shell_session_manager/shellctl/shared/output.py +120 -0
- shell_session_manager-2.0.0/src/shell_session_manager/shellctl/shared/runtime.py +97 -0
- shell_session_manager-2.0.0/src/shell_session_manager/shellctl/shared/sanitize.py +139 -0
- shell_session_manager-2.0.0/src/shell_session_manager/shellctl/shared/schemas.py +195 -0
- shell_session_manager-2.0.0/tests/golden_shellctl_sanitize.json +32 -0
- shell_session_manager-2.0.0/tests/test_shellctl_client.py +102 -0
- shell_session_manager-2.0.0/tests/test_shellctl_service.py +874 -0
- shell_session_manager-2.0.0/tests/test_shellctl_shared.py +114 -0
- {shell_session_manager-1.0.0 → shell_session_manager-2.0.0}/LICENSE +0 -0
- {shell_session_manager-1.0.0 → shell_session_manager-2.0.0}/README.md +0 -0
- {shell_session_manager-1.0.0 → shell_session_manager-2.0.0}/src/shell_session_manager/py.typed +0 -0
- {shell_session_manager-1.0.0 → shell_session_manager-2.0.0}/src/shell_session_manager/session.py +0 -0
- {shell_session_manager-1.0.0 → shell_session_manager-2.0.0}/tests/test_shell_session_autoclose.py +0 -0
- {shell_session_manager-1.0.0 → shell_session_manager-2.0.0}/tests/test_shell_session_manager.py +0 -0
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: shell-session-manager
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.0.0
|
|
4
4
|
Summary: Async subprocess session manager with incremental stdin/stdout/stderr support
|
|
5
5
|
Author-Email: =?utf-8?b?WWFubGkg55uQ57KS?= <yanli@mail.one>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
7
7
|
License-File: LICENSE
|
|
8
|
-
Requires-Python:
|
|
8
|
+
Requires-Python: >=3.12
|
|
9
|
+
Requires-Dist: aiosqlite>=0.21.0
|
|
9
10
|
Requires-Dist: anyio>=4.12.1
|
|
11
|
+
Requires-Dist: fastapi>=0.116.1
|
|
12
|
+
Requires-Dist: httpx>=0.28.1
|
|
13
|
+
Requires-Dist: pydantic>=2.12.5
|
|
14
|
+
Requires-Dist: sqlmodel>=0.0.24
|
|
15
|
+
Requires-Dist: typer>=0.16.1
|
|
16
|
+
Requires-Dist: uvicorn>=0.35.0
|
|
10
17
|
Description-Content-Type: text/markdown
|
|
11
18
|
|
|
12
19
|
# shell-session-manager
|
|
@@ -6,21 +6,31 @@ build-backend = "pdm.backend"
|
|
|
6
6
|
|
|
7
7
|
[project]
|
|
8
8
|
name = "shell-session-manager"
|
|
9
|
-
version = "
|
|
9
|
+
version = "2.0.0"
|
|
10
10
|
description = "Async subprocess session manager with incremental stdin/stdout/stderr support"
|
|
11
11
|
authors = [
|
|
12
12
|
{ name = "Yanli 盐粒", email = "yanli@mail.one" },
|
|
13
13
|
]
|
|
14
14
|
dependencies = [
|
|
15
|
+
"aiosqlite>=0.21.0",
|
|
15
16
|
"anyio>=4.12.1",
|
|
17
|
+
"fastapi>=0.116.1",
|
|
18
|
+
"httpx>=0.28.1",
|
|
19
|
+
"pydantic>=2.12.5",
|
|
20
|
+
"sqlmodel>=0.0.24",
|
|
21
|
+
"typer>=0.16.1",
|
|
22
|
+
"uvicorn>=0.35.0",
|
|
16
23
|
]
|
|
17
|
-
requires-python = "
|
|
24
|
+
requires-python = ">=3.12"
|
|
18
25
|
readme = "README.md"
|
|
19
26
|
license = "Apache-2.0"
|
|
20
27
|
license-files = [
|
|
21
28
|
"LICENSE",
|
|
22
29
|
]
|
|
23
30
|
|
|
31
|
+
[project.scripts]
|
|
32
|
+
shellctl = "shell_session_manager.shellctl.server:main"
|
|
33
|
+
|
|
24
34
|
[dependency-groups]
|
|
25
35
|
dev = [
|
|
26
36
|
"pytest>=9.0.2",
|
{shell_session_manager-1.0.0 → shell_session_manager-2.0.0}/src/shell_session_manager/__init__.py
RENAMED
|
@@ -4,6 +4,10 @@ The package exports the session primitives from `shell_session_manager.session`.
|
|
|
4
4
|
Callers are responsible for constructing the command string; this package only
|
|
5
5
|
owns process lifetime, incremental stdin writes, stdout/stderr draining, and
|
|
6
6
|
session registry management.
|
|
7
|
+
|
|
8
|
+
The higher-level HTTP/tmux shellctl implementation lives under
|
|
9
|
+
`shell_session_manager.shellctl` so projects that only need in-process shell
|
|
10
|
+
sessions do not need to import the networked manager.
|
|
7
11
|
"""
|
|
8
12
|
|
|
9
13
|
from shell_session_manager.session import (
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Public shellctl package exports.
|
|
2
|
+
|
|
3
|
+
The client/server/shared entry points now live in package subfolders so each can
|
|
4
|
+
contain smaller responsibility-focused files while preserving the original
|
|
5
|
+
import paths through package `__init__` re-exports.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from shell_session_manager.shellctl.client import ShellctlClient, ShellctlClientError
|
|
9
|
+
from shell_session_manager.shellctl.shared import (
|
|
10
|
+
DEFAULT_AUTH_TOKEN_ENV,
|
|
11
|
+
DEFAULT_GC_FINISHED_JOB_RETENTION_SECONDS,
|
|
12
|
+
DEFAULT_GC_INTERVAL_SECONDS,
|
|
13
|
+
DEFAULT_IDLE_FLUSH_SECONDS,
|
|
14
|
+
DEFAULT_LIST_LIMIT,
|
|
15
|
+
DEFAULT_OUTPUT_LIMIT_BYTES,
|
|
16
|
+
DEFAULT_TERMINAL_COLS,
|
|
17
|
+
DEFAULT_TERMINAL_ROWS,
|
|
18
|
+
DEFAULT_TERMINATE_GRACE_SECONDS,
|
|
19
|
+
DEFAULT_TIMEOUT_SECONDS,
|
|
20
|
+
DeleteJobResponse,
|
|
21
|
+
HealthResponse,
|
|
22
|
+
InputJobRequest,
|
|
23
|
+
JobInfo,
|
|
24
|
+
JobResult,
|
|
25
|
+
JobStatusName,
|
|
26
|
+
JobStatusView,
|
|
27
|
+
ListJobsResponse,
|
|
28
|
+
PtySanitizer,
|
|
29
|
+
RunJobRequest,
|
|
30
|
+
TerminalSize,
|
|
31
|
+
TerminateJobRequest,
|
|
32
|
+
WaitJobRequest,
|
|
33
|
+
generate_job_id,
|
|
34
|
+
read_output_window,
|
|
35
|
+
sanitize_pty_output,
|
|
36
|
+
tail_output_window,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"DEFAULT_AUTH_TOKEN_ENV",
|
|
41
|
+
"DEFAULT_GC_FINISHED_JOB_RETENTION_SECONDS",
|
|
42
|
+
"DEFAULT_GC_INTERVAL_SECONDS",
|
|
43
|
+
"DEFAULT_IDLE_FLUSH_SECONDS",
|
|
44
|
+
"DEFAULT_LIST_LIMIT",
|
|
45
|
+
"DEFAULT_OUTPUT_LIMIT_BYTES",
|
|
46
|
+
"DEFAULT_TERMINAL_COLS",
|
|
47
|
+
"DEFAULT_TERMINAL_ROWS",
|
|
48
|
+
"DEFAULT_TERMINATE_GRACE_SECONDS",
|
|
49
|
+
"DEFAULT_TIMEOUT_SECONDS",
|
|
50
|
+
"DeleteJobResponse",
|
|
51
|
+
"HealthResponse",
|
|
52
|
+
"InputJobRequest",
|
|
53
|
+
"JobInfo",
|
|
54
|
+
"JobResult",
|
|
55
|
+
"JobStatusName",
|
|
56
|
+
"JobStatusView",
|
|
57
|
+
"ListJobsResponse",
|
|
58
|
+
"PtySanitizer",
|
|
59
|
+
"RunJobRequest",
|
|
60
|
+
"ShellctlClient",
|
|
61
|
+
"ShellctlClientError",
|
|
62
|
+
"TerminalSize",
|
|
63
|
+
"TerminateJobRequest",
|
|
64
|
+
"WaitJobRequest",
|
|
65
|
+
"generate_job_id",
|
|
66
|
+
"read_output_window",
|
|
67
|
+
"sanitize_pty_output",
|
|
68
|
+
"tail_output_window",
|
|
69
|
+
]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Async HTTP client package for shellctl.
|
|
2
|
+
|
|
3
|
+
`shell_session_manager.shellctl.client` remains importable as before, but it is
|
|
4
|
+
now a package so future client helpers can live beside the main SDK class.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from shell_session_manager.shellctl.client.sdk import (
|
|
8
|
+
ShellctlClient,
|
|
9
|
+
ShellctlClientError,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = ["ShellctlClient", "ShellctlClientError"]
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Async HTTP client for the shellctl server API.
|
|
2
|
+
|
|
3
|
+
The SDK keeps transport-level knobs (`output_limit`, `idle_flush_seconds`, and
|
|
4
|
+
bearer token handling) on the client instance so individual method calls stay
|
|
5
|
+
close to the proposal's high-level workflow.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from shell_session_manager.shellctl.shared import (
|
|
16
|
+
DEFAULT_AUTH_TOKEN_ENV,
|
|
17
|
+
DEFAULT_IDLE_FLUSH_SECONDS,
|
|
18
|
+
DEFAULT_LIST_LIMIT,
|
|
19
|
+
DEFAULT_OUTPUT_LIMIT_BYTES,
|
|
20
|
+
DEFAULT_TERMINATE_GRACE_SECONDS,
|
|
21
|
+
DEFAULT_TIMEOUT_SECONDS,
|
|
22
|
+
DeleteJobResponse,
|
|
23
|
+
JobInfo,
|
|
24
|
+
JobResult,
|
|
25
|
+
JobStatusView,
|
|
26
|
+
ListJobsResponse,
|
|
27
|
+
RunJobRequest,
|
|
28
|
+
TerminalSize,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ShellctlClientError(RuntimeError):
|
|
33
|
+
"""Raised when the shellctl server returns an error response."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, status_code: int, code: str, message: str) -> None:
|
|
36
|
+
super().__init__(f"{code} ({status_code}): {message}")
|
|
37
|
+
self.status_code = status_code
|
|
38
|
+
self.code = code
|
|
39
|
+
self.message = message
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ShellctlClient:
|
|
43
|
+
"""Thin async SDK for the shellctl HTTP API.
|
|
44
|
+
|
|
45
|
+
The client owns a reusable `httpx.AsyncClient` unless one is injected via the
|
|
46
|
+
`client` argument. Callers can therefore either keep one instance for a full
|
|
47
|
+
workflow or treat it as an async context manager.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
base_url: str,
|
|
53
|
+
*,
|
|
54
|
+
output_limit: int = DEFAULT_OUTPUT_LIMIT_BYTES,
|
|
55
|
+
idle_flush_seconds: float = DEFAULT_IDLE_FLUSH_SECONDS,
|
|
56
|
+
token: str | None = None,
|
|
57
|
+
client: httpx.AsyncClient | None = None,
|
|
58
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
59
|
+
) -> None:
|
|
60
|
+
self.base_url = base_url.rstrip("/")
|
|
61
|
+
self.output_limit = output_limit
|
|
62
|
+
self.idle_flush_seconds = idle_flush_seconds
|
|
63
|
+
self.token = (
|
|
64
|
+
token if token is not None else os.environ.get(DEFAULT_AUTH_TOKEN_ENV)
|
|
65
|
+
)
|
|
66
|
+
self._owns_client = client is None
|
|
67
|
+
self._client = client or httpx.AsyncClient(
|
|
68
|
+
base_url=self.base_url,
|
|
69
|
+
follow_redirects=True,
|
|
70
|
+
timeout=httpx.Timeout(
|
|
71
|
+
DEFAULT_TIMEOUT_SECONDS, connect=DEFAULT_TIMEOUT_SECONDS
|
|
72
|
+
),
|
|
73
|
+
transport=transport,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
async def __aenter__(self) -> ShellctlClient:
|
|
77
|
+
return self
|
|
78
|
+
|
|
79
|
+
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None:
|
|
80
|
+
await self.close()
|
|
81
|
+
|
|
82
|
+
async def close(self) -> None:
|
|
83
|
+
"""Close the underlying HTTP client if this SDK instance owns it."""
|
|
84
|
+
|
|
85
|
+
if self._owns_client:
|
|
86
|
+
await self._client.aclose()
|
|
87
|
+
|
|
88
|
+
async def healthz(self) -> dict[str, Any]:
|
|
89
|
+
"""Call the public health endpoint without requiring auth."""
|
|
90
|
+
|
|
91
|
+
response = await self._client.get("/healthz")
|
|
92
|
+
return self._decode_response(response)
|
|
93
|
+
|
|
94
|
+
async def run(
|
|
95
|
+
self,
|
|
96
|
+
script: str,
|
|
97
|
+
*,
|
|
98
|
+
cwd: str | None = None,
|
|
99
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
100
|
+
terminal: TerminalSize | None = None,
|
|
101
|
+
) -> JobResult:
|
|
102
|
+
"""Create a new job and wait for initial output or completion."""
|
|
103
|
+
|
|
104
|
+
payload = RunJobRequest(
|
|
105
|
+
script=script,
|
|
106
|
+
cwd=cwd,
|
|
107
|
+
terminal=terminal,
|
|
108
|
+
timeout=timeout,
|
|
109
|
+
output_limit=self.output_limit,
|
|
110
|
+
idle_flush_seconds=self.idle_flush_seconds,
|
|
111
|
+
)
|
|
112
|
+
response = await self._client.post(
|
|
113
|
+
"/v1/jobs/run",
|
|
114
|
+
json=payload.model_dump(mode="json", exclude_none=True),
|
|
115
|
+
headers=self._auth_headers(),
|
|
116
|
+
)
|
|
117
|
+
return JobResult.model_validate(self._decode_response(response))
|
|
118
|
+
|
|
119
|
+
async def wait(
|
|
120
|
+
self,
|
|
121
|
+
job_id: str,
|
|
122
|
+
*,
|
|
123
|
+
offset: int,
|
|
124
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
125
|
+
) -> JobResult:
|
|
126
|
+
"""Wait for incremental output, completion, truncation, or timeout."""
|
|
127
|
+
|
|
128
|
+
response = await self._client.post(
|
|
129
|
+
f"/v1/jobs/{job_id}/wait",
|
|
130
|
+
json={
|
|
131
|
+
"offset": offset,
|
|
132
|
+
"timeout": timeout,
|
|
133
|
+
"output_limit": self.output_limit,
|
|
134
|
+
"idle_flush_seconds": self.idle_flush_seconds,
|
|
135
|
+
},
|
|
136
|
+
headers=self._auth_headers(),
|
|
137
|
+
)
|
|
138
|
+
return JobResult.model_validate(self._decode_response(response))
|
|
139
|
+
|
|
140
|
+
async def status(self, job_id: str) -> JobStatusView:
|
|
141
|
+
"""Fetch the materialized status view for one job."""
|
|
142
|
+
|
|
143
|
+
response = await self._client.get(
|
|
144
|
+
f"/v1/jobs/{job_id}",
|
|
145
|
+
headers=self._auth_headers(),
|
|
146
|
+
)
|
|
147
|
+
return JobStatusView.model_validate(self._decode_response(response))
|
|
148
|
+
|
|
149
|
+
async def list_jobs(
|
|
150
|
+
self,
|
|
151
|
+
*,
|
|
152
|
+
status: str | None = None,
|
|
153
|
+
limit: int = DEFAULT_LIST_LIMIT,
|
|
154
|
+
) -> list[JobInfo]:
|
|
155
|
+
"""List recent jobs, optionally filtered by lifecycle status."""
|
|
156
|
+
|
|
157
|
+
params: dict[str, Any] = {"limit": limit}
|
|
158
|
+
if status is not None:
|
|
159
|
+
params["status"] = status
|
|
160
|
+
response = await self._client.get(
|
|
161
|
+
"/v1/jobs",
|
|
162
|
+
params=params,
|
|
163
|
+
headers=self._auth_headers(),
|
|
164
|
+
)
|
|
165
|
+
payload = ListJobsResponse.model_validate(self._decode_response(response))
|
|
166
|
+
return payload.jobs
|
|
167
|
+
|
|
168
|
+
async def input(
|
|
169
|
+
self,
|
|
170
|
+
job_id: str,
|
|
171
|
+
text: str,
|
|
172
|
+
*,
|
|
173
|
+
offset: int,
|
|
174
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
175
|
+
) -> JobResult:
|
|
176
|
+
"""Send text input to a running job and then wait like `wait()`."""
|
|
177
|
+
|
|
178
|
+
response = await self._client.post(
|
|
179
|
+
f"/v1/jobs/{job_id}/input",
|
|
180
|
+
json={
|
|
181
|
+
"text": text,
|
|
182
|
+
"offset": offset,
|
|
183
|
+
"timeout": timeout,
|
|
184
|
+
"output_limit": self.output_limit,
|
|
185
|
+
"idle_flush_seconds": self.idle_flush_seconds,
|
|
186
|
+
},
|
|
187
|
+
headers=self._auth_headers(),
|
|
188
|
+
)
|
|
189
|
+
return JobResult.model_validate(self._decode_response(response))
|
|
190
|
+
|
|
191
|
+
async def tail(self, job_id: str) -> JobResult:
|
|
192
|
+
"""Fetch an immediate UTF-8-safe tail snapshot for a job."""
|
|
193
|
+
|
|
194
|
+
response = await self._client.get(
|
|
195
|
+
f"/v1/jobs/{job_id}/log/tail",
|
|
196
|
+
params={"output_limit": self.output_limit},
|
|
197
|
+
headers=self._auth_headers(),
|
|
198
|
+
)
|
|
199
|
+
return JobResult.model_validate(self._decode_response(response))
|
|
200
|
+
|
|
201
|
+
async def terminate(
|
|
202
|
+
self,
|
|
203
|
+
job_id: str,
|
|
204
|
+
grace_seconds: float = DEFAULT_TERMINATE_GRACE_SECONDS,
|
|
205
|
+
) -> JobStatusView:
|
|
206
|
+
"""Terminate a job, returning the resulting materialized status view."""
|
|
207
|
+
|
|
208
|
+
response = await self._client.post(
|
|
209
|
+
f"/v1/jobs/{job_id}/terminate",
|
|
210
|
+
json={"grace_seconds": grace_seconds},
|
|
211
|
+
headers=self._auth_headers(),
|
|
212
|
+
)
|
|
213
|
+
return JobStatusView.model_validate(self._decode_response(response))
|
|
214
|
+
|
|
215
|
+
async def delete(
|
|
216
|
+
self,
|
|
217
|
+
job_id: str,
|
|
218
|
+
*,
|
|
219
|
+
force: bool = False,
|
|
220
|
+
grace_seconds: float | None = None,
|
|
221
|
+
) -> DeleteJobResponse:
|
|
222
|
+
"""Delete job artifacts, optionally terminating the job first."""
|
|
223
|
+
|
|
224
|
+
params: dict[str, Any] = {"force": str(force).lower()}
|
|
225
|
+
if grace_seconds is not None:
|
|
226
|
+
params["grace_seconds"] = grace_seconds
|
|
227
|
+
response = await self._client.delete(
|
|
228
|
+
f"/v1/jobs/{job_id}",
|
|
229
|
+
params=params,
|
|
230
|
+
headers=self._auth_headers(),
|
|
231
|
+
)
|
|
232
|
+
return DeleteJobResponse.model_validate(self._decode_response(response))
|
|
233
|
+
|
|
234
|
+
def _auth_headers(self) -> dict[str, str]:
|
|
235
|
+
if not self.token:
|
|
236
|
+
return {}
|
|
237
|
+
return {"Authorization": f"Bearer {self.token}"}
|
|
238
|
+
|
|
239
|
+
def _decode_response(self, response: httpx.Response) -> dict[str, Any]:
|
|
240
|
+
try:
|
|
241
|
+
payload = response.json()
|
|
242
|
+
except ValueError as exc: # pragma: no cover - network/proxy corruption
|
|
243
|
+
raise ShellctlClientError(
|
|
244
|
+
response.status_code, "invalid_json", response.text
|
|
245
|
+
) from exc
|
|
246
|
+
|
|
247
|
+
if response.is_error:
|
|
248
|
+
error = payload.get("error") if isinstance(payload, dict) else None
|
|
249
|
+
if isinstance(error, dict):
|
|
250
|
+
code = str(error.get("code", "request_failed"))
|
|
251
|
+
message = str(error.get("message", response.text))
|
|
252
|
+
else:
|
|
253
|
+
code = "request_failed"
|
|
254
|
+
message = response.text
|
|
255
|
+
raise ShellctlClientError(response.status_code, code, message)
|
|
256
|
+
|
|
257
|
+
if not isinstance(payload, dict):
|
|
258
|
+
raise ShellctlClientError(
|
|
259
|
+
response.status_code, "invalid_payload", response.text
|
|
260
|
+
)
|
|
261
|
+
return payload
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
__all__ = ["ShellctlClient", "ShellctlClientError"]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""shellctl server package.
|
|
2
|
+
|
|
3
|
+
The original monolithic `shellctl.server` module is now organized as a package
|
|
4
|
+
with smaller files for configuration, SQLite models, tmux control, lifecycle
|
|
5
|
+
service logic, FastAPI wiring, and CLI commands. This `__init__` keeps the
|
|
6
|
+
common public-ish imports stable without re-exporting unrelated internals.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from shell_session_manager.shellctl.server.api import create_app
|
|
10
|
+
from shell_session_manager.shellctl.server.cli import (
|
|
11
|
+
cli,
|
|
12
|
+
main,
|
|
13
|
+
runner_exit_command,
|
|
14
|
+
sanitize_pty_command,
|
|
15
|
+
serve_command,
|
|
16
|
+
)
|
|
17
|
+
from shell_session_manager.shellctl.server.config import ShellctlConfig
|
|
18
|
+
from shell_session_manager.shellctl.server.db import JobRow
|
|
19
|
+
from shell_session_manager.shellctl.server.errors import ShellctlServerError
|
|
20
|
+
from shell_session_manager.shellctl.server.service import ShellctlService
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"JobRow",
|
|
24
|
+
"ShellctlConfig",
|
|
25
|
+
"ShellctlServerError",
|
|
26
|
+
"ShellctlService",
|
|
27
|
+
"cli",
|
|
28
|
+
"create_app",
|
|
29
|
+
"main",
|
|
30
|
+
"runner_exit_command",
|
|
31
|
+
"sanitize_pty_command",
|
|
32
|
+
"serve_command",
|
|
33
|
+
]
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""FastAPI wiring for shellctl server endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
from typing import Annotated, cast
|
|
7
|
+
|
|
8
|
+
from fastapi import Depends, FastAPI, Header, Query, Request
|
|
9
|
+
from fastapi.responses import JSONResponse
|
|
10
|
+
|
|
11
|
+
from shell_session_manager.shellctl.server.config import ShellctlConfig
|
|
12
|
+
from shell_session_manager.shellctl.server.errors import ShellctlServerError
|
|
13
|
+
from shell_session_manager.shellctl.server.service import ShellctlService
|
|
14
|
+
from shell_session_manager.shellctl.shared import (
|
|
15
|
+
DEFAULT_HEALTH_STATUS,
|
|
16
|
+
DEFAULT_LIST_LIMIT,
|
|
17
|
+
DEFAULT_OUTPUT_LIMIT_BYTES,
|
|
18
|
+
DEFAULT_TERMINATE_GRACE_SECONDS,
|
|
19
|
+
MAX_LIST_LIMIT,
|
|
20
|
+
MAX_OUTPUT_LIMIT_BYTES,
|
|
21
|
+
DeleteJobResponse,
|
|
22
|
+
ErrorDetail,
|
|
23
|
+
ErrorResponse,
|
|
24
|
+
HealthResponse,
|
|
25
|
+
InputJobRequest,
|
|
26
|
+
JobResult,
|
|
27
|
+
JobStatusName,
|
|
28
|
+
JobStatusView,
|
|
29
|
+
ListJobsResponse,
|
|
30
|
+
RunJobRequest,
|
|
31
|
+
TerminateJobRequest,
|
|
32
|
+
WaitJobRequest,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def create_app(
|
|
37
|
+
config: ShellctlConfig | None = None,
|
|
38
|
+
*,
|
|
39
|
+
service: ShellctlService | None = None,
|
|
40
|
+
) -> FastAPI:
|
|
41
|
+
"""Create the FastAPI application used by `shellctl serve`."""
|
|
42
|
+
|
|
43
|
+
resolved_config = config or ShellctlConfig()
|
|
44
|
+
resolved_service = service or ShellctlService(resolved_config)
|
|
45
|
+
|
|
46
|
+
@asynccontextmanager
|
|
47
|
+
async def lifespan(_app: FastAPI):
|
|
48
|
+
await resolved_service.initialize()
|
|
49
|
+
resolved_service.start_background_gc()
|
|
50
|
+
resolved_service.start_background_pipe_monitor()
|
|
51
|
+
try:
|
|
52
|
+
yield
|
|
53
|
+
finally:
|
|
54
|
+
await resolved_service.shutdown()
|
|
55
|
+
|
|
56
|
+
app = FastAPI(title="shellctl", version="0.1.0", lifespan=lifespan)
|
|
57
|
+
app.state.shellctl_service = resolved_service
|
|
58
|
+
|
|
59
|
+
@app.exception_handler(ShellctlServerError)
|
|
60
|
+
async def handle_shellctl_error(
|
|
61
|
+
_request: Request,
|
|
62
|
+
exc: ShellctlServerError,
|
|
63
|
+
) -> JSONResponse:
|
|
64
|
+
return JSONResponse(
|
|
65
|
+
status_code=exc.status_code,
|
|
66
|
+
content=ErrorResponse(
|
|
67
|
+
error=ErrorDetail(code=exc.code, message=exc.message)
|
|
68
|
+
).model_dump(mode="json"),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
@app.exception_handler(RuntimeError)
|
|
72
|
+
async def handle_runtime_error(
|
|
73
|
+
_request: Request, exc: RuntimeError
|
|
74
|
+
) -> JSONResponse:
|
|
75
|
+
return JSONResponse(
|
|
76
|
+
status_code=500,
|
|
77
|
+
content=ErrorResponse(
|
|
78
|
+
error=ErrorDetail(
|
|
79
|
+
code="internal_error",
|
|
80
|
+
message=str(exc) or "internal server error",
|
|
81
|
+
)
|
|
82
|
+
).model_dump(mode="json"),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def get_service() -> ShellctlService:
|
|
86
|
+
return cast(ShellctlService, app.state.shellctl_service)
|
|
87
|
+
|
|
88
|
+
def verify_auth(
|
|
89
|
+
authorization: Annotated[str | None, Header()] = None,
|
|
90
|
+
) -> None:
|
|
91
|
+
expected = f"Bearer {resolved_config.auth_token}"
|
|
92
|
+
if authorization != expected:
|
|
93
|
+
raise ShellctlServerError(
|
|
94
|
+
401, "unauthorized", "Missing or invalid bearer token"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
@app.get("/healthz", response_model=HealthResponse)
|
|
98
|
+
async def healthz() -> HealthResponse:
|
|
99
|
+
return HealthResponse(status=DEFAULT_HEALTH_STATUS)
|
|
100
|
+
|
|
101
|
+
@app.post(
|
|
102
|
+
"/v1/jobs/run",
|
|
103
|
+
response_model=JobResult,
|
|
104
|
+
dependencies=[Depends(verify_auth)],
|
|
105
|
+
)
|
|
106
|
+
async def run_job(
|
|
107
|
+
payload: RunJobRequest,
|
|
108
|
+
svc: ShellctlService = Depends(get_service),
|
|
109
|
+
) -> JobResult:
|
|
110
|
+
return await svc.run_job(payload)
|
|
111
|
+
|
|
112
|
+
@app.post(
|
|
113
|
+
"/v1/jobs/{job_id}/wait",
|
|
114
|
+
response_model=JobResult,
|
|
115
|
+
dependencies=[Depends(verify_auth)],
|
|
116
|
+
)
|
|
117
|
+
async def wait_job(
|
|
118
|
+
job_id: str,
|
|
119
|
+
payload: WaitJobRequest,
|
|
120
|
+
svc: ShellctlService = Depends(get_service),
|
|
121
|
+
) -> JobResult:
|
|
122
|
+
return await svc.wait_job(job_id, payload)
|
|
123
|
+
|
|
124
|
+
@app.get(
|
|
125
|
+
"/v1/jobs/{job_id}/log/tail",
|
|
126
|
+
response_model=JobResult,
|
|
127
|
+
dependencies=[Depends(verify_auth)],
|
|
128
|
+
)
|
|
129
|
+
async def tail_job(
|
|
130
|
+
job_id: str,
|
|
131
|
+
output_limit: Annotated[
|
|
132
|
+
int, Query(ge=1, le=MAX_OUTPUT_LIMIT_BYTES)
|
|
133
|
+
] = DEFAULT_OUTPUT_LIMIT_BYTES,
|
|
134
|
+
svc: ShellctlService = Depends(get_service),
|
|
135
|
+
) -> JobResult:
|
|
136
|
+
return await svc.tail_job(job_id, output_limit=output_limit)
|
|
137
|
+
|
|
138
|
+
@app.get(
|
|
139
|
+
"/v1/jobs/{job_id}",
|
|
140
|
+
response_model=JobStatusView,
|
|
141
|
+
dependencies=[Depends(verify_auth)],
|
|
142
|
+
)
|
|
143
|
+
async def job_status(
|
|
144
|
+
job_id: str,
|
|
145
|
+
svc: ShellctlService = Depends(get_service),
|
|
146
|
+
) -> JobStatusView:
|
|
147
|
+
return await svc.get_job_status(job_id)
|
|
148
|
+
|
|
149
|
+
@app.get(
|
|
150
|
+
"/v1/jobs",
|
|
151
|
+
response_model=ListJobsResponse,
|
|
152
|
+
dependencies=[Depends(verify_auth)],
|
|
153
|
+
)
|
|
154
|
+
async def list_jobs(
|
|
155
|
+
status: Annotated[JobStatusName | None, Query()] = None,
|
|
156
|
+
limit: Annotated[int, Query(ge=1, le=MAX_LIST_LIMIT)] = DEFAULT_LIST_LIMIT,
|
|
157
|
+
svc: ShellctlService = Depends(get_service),
|
|
158
|
+
) -> ListJobsResponse:
|
|
159
|
+
return await svc.list_jobs(status=status, limit=limit)
|
|
160
|
+
|
|
161
|
+
@app.post(
|
|
162
|
+
"/v1/jobs/{job_id}/input",
|
|
163
|
+
response_model=JobResult,
|
|
164
|
+
dependencies=[Depends(verify_auth)],
|
|
165
|
+
)
|
|
166
|
+
async def input_job(
|
|
167
|
+
job_id: str,
|
|
168
|
+
payload: InputJobRequest,
|
|
169
|
+
svc: ShellctlService = Depends(get_service),
|
|
170
|
+
) -> JobResult:
|
|
171
|
+
return await svc.send_input(job_id, payload)
|
|
172
|
+
|
|
173
|
+
@app.post(
|
|
174
|
+
"/v1/jobs/{job_id}/terminate",
|
|
175
|
+
response_model=JobStatusView,
|
|
176
|
+
dependencies=[Depends(verify_auth)],
|
|
177
|
+
)
|
|
178
|
+
async def terminate_job(
|
|
179
|
+
job_id: str,
|
|
180
|
+
payload: TerminateJobRequest,
|
|
181
|
+
svc: ShellctlService = Depends(get_service),
|
|
182
|
+
) -> JobStatusView:
|
|
183
|
+
return await svc.terminate_job(job_id, payload)
|
|
184
|
+
|
|
185
|
+
@app.delete(
|
|
186
|
+
"/v1/jobs/{job_id}",
|
|
187
|
+
response_model=DeleteJobResponse,
|
|
188
|
+
dependencies=[Depends(verify_auth)],
|
|
189
|
+
)
|
|
190
|
+
async def delete_job(
|
|
191
|
+
job_id: str,
|
|
192
|
+
force: bool = False,
|
|
193
|
+
grace_seconds: float = DEFAULT_TERMINATE_GRACE_SECONDS,
|
|
194
|
+
svc: ShellctlService = Depends(get_service),
|
|
195
|
+
) -> DeleteJobResponse:
|
|
196
|
+
return await svc.delete_job(job_id, force=force, grace_seconds=grace_seconds)
|
|
197
|
+
|
|
198
|
+
return app
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
__all__ = ["create_app"]
|