shell-session-manager 1.0.1__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.
Files changed (31) hide show
  1. {shell_session_manager-1.0.1 → shell_session_manager-2.0.0}/PKG-INFO +8 -1
  2. {shell_session_manager-1.0.1 → shell_session_manager-2.0.0}/pyproject.toml +11 -1
  3. {shell_session_manager-1.0.1 → shell_session_manager-2.0.0}/src/shell_session_manager/__init__.py +4 -0
  4. shell_session_manager-2.0.0/src/shell_session_manager/shellctl/__init__.py +69 -0
  5. shell_session_manager-2.0.0/src/shell_session_manager/shellctl/client/__init__.py +12 -0
  6. shell_session_manager-2.0.0/src/shell_session_manager/shellctl/client/sdk.py +264 -0
  7. shell_session_manager-2.0.0/src/shell_session_manager/shellctl/server/__init__.py +33 -0
  8. shell_session_manager-2.0.0/src/shell_session_manager/shellctl/server/__main__.py +6 -0
  9. shell_session_manager-2.0.0/src/shell_session_manager/shellctl/server/api.py +201 -0
  10. shell_session_manager-2.0.0/src/shell_session_manager/shellctl/server/cli.py +104 -0
  11. shell_session_manager-2.0.0/src/shell_session_manager/shellctl/server/config.py +103 -0
  12. shell_session_manager-2.0.0/src/shell_session_manager/shellctl/server/db.py +52 -0
  13. shell_session_manager-2.0.0/src/shell_session_manager/shellctl/server/errors.py +14 -0
  14. shell_session_manager-2.0.0/src/shell_session_manager/shellctl/server/service.py +1018 -0
  15. shell_session_manager-2.0.0/src/shell_session_manager/shellctl/server/tmux.py +296 -0
  16. shell_session_manager-2.0.0/src/shell_session_manager/shellctl/shared/__init__.py +117 -0
  17. shell_session_manager-2.0.0/src/shell_session_manager/shellctl/shared/constants.py +44 -0
  18. shell_session_manager-2.0.0/src/shell_session_manager/shellctl/shared/output.py +120 -0
  19. shell_session_manager-2.0.0/src/shell_session_manager/shellctl/shared/runtime.py +97 -0
  20. shell_session_manager-2.0.0/src/shell_session_manager/shellctl/shared/sanitize.py +139 -0
  21. shell_session_manager-2.0.0/src/shell_session_manager/shellctl/shared/schemas.py +195 -0
  22. shell_session_manager-2.0.0/tests/golden_shellctl_sanitize.json +32 -0
  23. shell_session_manager-2.0.0/tests/test_shellctl_client.py +102 -0
  24. shell_session_manager-2.0.0/tests/test_shellctl_service.py +874 -0
  25. shell_session_manager-2.0.0/tests/test_shellctl_shared.py +114 -0
  26. {shell_session_manager-1.0.1 → shell_session_manager-2.0.0}/LICENSE +0 -0
  27. {shell_session_manager-1.0.1 → shell_session_manager-2.0.0}/README.md +0 -0
  28. {shell_session_manager-1.0.1 → shell_session_manager-2.0.0}/src/shell_session_manager/py.typed +0 -0
  29. {shell_session_manager-1.0.1 → shell_session_manager-2.0.0}/src/shell_session_manager/session.py +0 -0
  30. {shell_session_manager-1.0.1 → shell_session_manager-2.0.0}/tests/test_shell_session_autoclose.py +0 -0
  31. {shell_session_manager-1.0.1 → 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: 1.0.1
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
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,13 +6,20 @@ build-backend = "pdm.backend"
6
6
 
7
7
  [project]
8
8
  name = "shell-session-manager"
9
- version = "1.0.1"
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
24
  requires-python = ">=3.12"
18
25
  readme = "README.md"
@@ -21,6 +28,9 @@ 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",
@@ -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,6 @@
1
+ """Support `python -m shell_session_manager.shellctl.server`."""
2
+
3
+ from shell_session_manager.shellctl.server.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -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"]