shell-session-manager 2.1.0__tar.gz → 2.1.1__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-2.1.0 → shell_session_manager-2.1.1}/PKG-INFO +1 -1
- {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/pyproject.toml +1 -1
- shell_session_manager-2.1.1/src/shell_session_manager/shellctl/server/artifacts.py +45 -0
- {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/server/service.py +70 -10
- {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/server/tmux.py +60 -10
- {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/tests/test_shellctl_service.py +297 -0
- {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/LICENSE +0 -0
- {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/README.md +0 -0
- {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/src/shell_session_manager/__init__.py +0 -0
- {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/src/shell_session_manager/py.typed +0 -0
- {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/src/shell_session_manager/session.py +0 -0
- {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/__init__.py +0 -0
- {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/client/__init__.py +0 -0
- {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/client/sdk.py +0 -0
- {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/server/__init__.py +0 -0
- {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/server/__main__.py +0 -0
- {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/server/api.py +0 -0
- {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/server/cli.py +0 -0
- {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/server/config.py +0 -0
- {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/server/db.py +0 -0
- {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/server/errors.py +0 -0
- {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/shared/__init__.py +0 -0
- {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/shared/constants.py +0 -0
- {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/shared/output.py +0 -0
- {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/shared/runtime.py +0 -0
- {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/shared/sanitize.py +0 -0
- {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/src/shell_session_manager/shellctl/shared/schemas.py +0 -0
- {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/tests/golden_shellctl_sanitize.json +0 -0
- {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/tests/test_shell_session_autoclose.py +0 -0
- {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/tests/test_shell_session_manager.py +0 -0
- {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/tests/test_shellctl_client.py +0 -0
- {shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/tests/test_shellctl_shared.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: shell-session-manager
|
|
3
|
-
Version: 2.1.
|
|
3
|
+
Version: 2.1.1
|
|
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
|
|
@@ -6,7 +6,7 @@ build-backend = "pdm.backend"
|
|
|
6
6
|
|
|
7
7
|
[project]
|
|
8
8
|
name = "shell-session-manager"
|
|
9
|
-
version = "2.1.
|
|
9
|
+
version = "2.1.1"
|
|
10
10
|
description = "Async subprocess session manager with incremental stdin/stdout/stderr support"
|
|
11
11
|
authors = [
|
|
12
12
|
{ name = "Yanli 盐粒", email = "yanli@mail.one" },
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Per-job artifact names used by shellctl server/runtime code.
|
|
2
|
+
|
|
3
|
+
Normal job completion is coordinated through small marker files inside each
|
|
4
|
+
`jobs/<job_id>/` directory so the tmux output-pipe finalizer can publish the
|
|
5
|
+
SQLite `exited(exit_code, ended_at)` state only after PTY output is fully
|
|
6
|
+
drained into `output.log`. A separate failure marker is used so an unsuccessful
|
|
7
|
+
sanitizer run does not masquerade as a drained normal exit.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
RUNNER_EXIT_CODE_FILENAME = ".runner-exit-code"
|
|
15
|
+
RUNNER_ENDED_AT_FILENAME = ".runner-ended-at"
|
|
16
|
+
PIPE_DRAINED_FILENAME = ".pipe-drained"
|
|
17
|
+
PIPE_FAILED_FILENAME = ".pipe-failed"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def runner_exit_code_path(job_dir: Path) -> Path:
|
|
21
|
+
return job_dir / RUNNER_EXIT_CODE_FILENAME
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def runner_ended_at_path(job_dir: Path) -> Path:
|
|
25
|
+
return job_dir / RUNNER_ENDED_AT_FILENAME
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def pipe_drained_path(job_dir: Path) -> Path:
|
|
29
|
+
return job_dir / PIPE_DRAINED_FILENAME
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def pipe_failed_path(job_dir: Path) -> Path:
|
|
33
|
+
return job_dir / PIPE_FAILED_FILENAME
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"PIPE_DRAINED_FILENAME",
|
|
38
|
+
"PIPE_FAILED_FILENAME",
|
|
39
|
+
"RUNNER_ENDED_AT_FILENAME",
|
|
40
|
+
"RUNNER_EXIT_CODE_FILENAME",
|
|
41
|
+
"pipe_drained_path",
|
|
42
|
+
"pipe_failed_path",
|
|
43
|
+
"runner_ended_at_path",
|
|
44
|
+
"runner_exit_code_path",
|
|
45
|
+
]
|
|
@@ -23,6 +23,14 @@ from sqlalchemy.exc import IntegrityError
|
|
|
23
23
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
24
24
|
from sqlmodel import SQLModel
|
|
25
25
|
|
|
26
|
+
from shell_session_manager.shellctl.server.artifacts import (
|
|
27
|
+
RUNNER_ENDED_AT_FILENAME,
|
|
28
|
+
RUNNER_EXIT_CODE_FILENAME,
|
|
29
|
+
pipe_drained_path,
|
|
30
|
+
pipe_failed_path,
|
|
31
|
+
runner_ended_at_path,
|
|
32
|
+
runner_exit_code_path,
|
|
33
|
+
)
|
|
26
34
|
from shell_session_manager.shellctl.server.config import ShellctlConfig
|
|
27
35
|
from shell_session_manager.shellctl.server.db import JobRow, configure_sqlite_engine
|
|
28
36
|
from shell_session_manager.shellctl.server.errors import ShellctlServerError
|
|
@@ -540,9 +548,12 @@ class ShellctlService:
|
|
|
540
548
|
async def record_runner_exit(
|
|
541
549
|
self, job_id: str, exit_code: int, ended_at: str
|
|
542
550
|
) -> None:
|
|
543
|
-
"""Persist
|
|
551
|
+
"""Persist a drained normal-exit fact into SQLite.
|
|
544
552
|
|
|
545
|
-
|
|
553
|
+
The usual caller is the tmux pipe finalizer after `sanitize-pty` has
|
|
554
|
+
reached EOF and flushed `output.log`. `_materialize_status_view()` may
|
|
555
|
+
also call this as a recovery path when `.pipe-drained` plus the normal
|
|
556
|
+
exit metadata files exist but SQLite is still non-terminal. The
|
|
546
557
|
statement always records `exit_code` and `ended_at`, but it only changes
|
|
547
558
|
`status` to `exited` when the current row is still non-terminal.
|
|
548
559
|
"""
|
|
@@ -660,6 +671,13 @@ class ShellctlService:
|
|
|
660
671
|
- terminal SQLite status wins and is preserved
|
|
661
672
|
- if SQLite already has an `exit_code` on a non-terminal row, materialize
|
|
662
673
|
`exited`
|
|
674
|
+
- if normal-exit metadata and `.pipe-drained` already exist, recover the
|
|
675
|
+
drained normal exit immediately even when the finalizer has not yet
|
|
676
|
+
committed SQLite state
|
|
677
|
+
- if normal-exit metadata exists and neither `.pipe-drained` nor
|
|
678
|
+
`.pipe-failed` exists, keep the non-terminal row instead of guessing
|
|
679
|
+
`lost` while the pipe finalizer is still responsible for the eventual
|
|
680
|
+
`runner-exit` commit
|
|
663
681
|
- if a live tmux session exists but the output pipe is known-dead,
|
|
664
682
|
conditionally materialize `failed(reason=pipe_failed)`
|
|
665
683
|
- if no live session exists and the row is not protected by the local
|
|
@@ -693,6 +711,10 @@ class ShellctlService:
|
|
|
693
711
|
target=JobStatusName.EXITED,
|
|
694
712
|
ended_at=row.ended_at or format_timestamp(),
|
|
695
713
|
)
|
|
714
|
+
elif (drained_exit := self._drained_normal_exit_metadata(job_id)) is not None:
|
|
715
|
+
exit_code, ended_at = drained_exit
|
|
716
|
+
await self.record_runner_exit(job_id, exit_code, ended_at)
|
|
717
|
+
row = await self._get_job_row(job_id)
|
|
696
718
|
elif session_exists:
|
|
697
719
|
if pipe_active is False:
|
|
698
720
|
if (
|
|
@@ -728,7 +750,9 @@ class ShellctlService:
|
|
|
728
750
|
require_exit_code_null=True,
|
|
729
751
|
)
|
|
730
752
|
else:
|
|
731
|
-
if
|
|
753
|
+
if self._normal_exit_commit_pending(job_id):
|
|
754
|
+
pass
|
|
755
|
+
elif not (
|
|
732
756
|
status in {JobStatusName.CREATED, JobStatusName.STARTING}
|
|
733
757
|
and job_id in self._starting_jobs
|
|
734
758
|
):
|
|
@@ -911,6 +935,36 @@ class ShellctlService:
|
|
|
911
935
|
def _artifact_dir(self, job_id: str) -> Path:
|
|
912
936
|
return self.config.jobs_dir / job_id
|
|
913
937
|
|
|
938
|
+
def _normal_exit_commit_pending(self, job_id: str) -> bool:
|
|
939
|
+
"""Check whether a normal exit is still waiting for pipe drain + commit."""
|
|
940
|
+
|
|
941
|
+
job_dir = self._artifact_dir(job_id)
|
|
942
|
+
return (
|
|
943
|
+
runner_exit_code_path(job_dir).exists()
|
|
944
|
+
and runner_ended_at_path(job_dir).exists()
|
|
945
|
+
and not pipe_drained_path(job_dir).exists()
|
|
946
|
+
and not pipe_failed_path(job_dir).exists()
|
|
947
|
+
)
|
|
948
|
+
|
|
949
|
+
def _drained_normal_exit_metadata(self, job_id: str) -> tuple[int, str] | None:
|
|
950
|
+
"""Read drained normal-exit metadata after successful sanitize drain."""
|
|
951
|
+
|
|
952
|
+
job_dir = self._artifact_dir(job_id)
|
|
953
|
+
drained_path = pipe_drained_path(job_dir)
|
|
954
|
+
exit_code_path = runner_exit_code_path(job_dir)
|
|
955
|
+
ended_at_path = runner_ended_at_path(job_dir)
|
|
956
|
+
if (
|
|
957
|
+
not drained_path.exists()
|
|
958
|
+
or not exit_code_path.exists()
|
|
959
|
+
or not ended_at_path.exists()
|
|
960
|
+
):
|
|
961
|
+
return None
|
|
962
|
+
raw_exit_code = exit_code_path.read_text(encoding="utf-8").strip()
|
|
963
|
+
raw_ended_at = ended_at_path.read_text(encoding="utf-8").strip()
|
|
964
|
+
if not raw_exit_code or not raw_ended_at:
|
|
965
|
+
return None
|
|
966
|
+
return int(raw_exit_code), raw_ended_at
|
|
967
|
+
|
|
914
968
|
def _allocate_job_dir(self) -> tuple[str, Path]:
|
|
915
969
|
"""Atomically allocate a unique artifact directory for a new job.
|
|
916
970
|
|
|
@@ -968,12 +1022,7 @@ class ShellctlService:
|
|
|
968
1022
|
self.config.runner_path.chmod(mode | stat.S_IXUSR)
|
|
969
1023
|
|
|
970
1024
|
def _runner_script_source(self) -> str:
|
|
971
|
-
tmux_socket = shlex.quote(str(self.config.tmux_socket))
|
|
972
|
-
state_dir = shlex.quote(str(self.config.state_dir))
|
|
973
1025
|
auth_env = shlex.quote(DEFAULT_AUTH_TOKEN_ENV)
|
|
974
|
-
shellctl_command = " ".join(
|
|
975
|
-
shlex.quote(part) for part in self.config.shellctl_command
|
|
976
|
-
)
|
|
977
1026
|
return f"""#!/usr/bin/env bash
|
|
978
1027
|
set -uo pipefail
|
|
979
1028
|
|
|
@@ -982,6 +1031,16 @@ JOB_ID="$2"
|
|
|
982
1031
|
CWD="$3"
|
|
983
1032
|
SCRIPT_PATH="$JOB_DIR/script"
|
|
984
1033
|
START_GATE="$JOB_DIR/start-gate"
|
|
1034
|
+
RUNNER_EXIT_CODE_PATH="$JOB_DIR/{RUNNER_EXIT_CODE_FILENAME}"
|
|
1035
|
+
RUNNER_ENDED_AT_PATH="$JOB_DIR/{RUNNER_ENDED_AT_FILENAME}"
|
|
1036
|
+
|
|
1037
|
+
write_atomic() {{
|
|
1038
|
+
local dest="$1"
|
|
1039
|
+
local value="$2"
|
|
1040
|
+
local tmp="${{dest}}.tmp.$$"
|
|
1041
|
+
printf '%s\n' "$value" > "$tmp"
|
|
1042
|
+
mv "$tmp" "$dest"
|
|
1043
|
+
}}
|
|
985
1044
|
|
|
986
1045
|
while [ ! -e "$START_GATE" ]; do
|
|
987
1046
|
sleep 0.05
|
|
@@ -1013,8 +1072,9 @@ print(datetime.now(UTC).replace(microsecond=0).isoformat().replace('+00:00', 'Z'
|
|
|
1013
1072
|
PY
|
|
1014
1073
|
)"
|
|
1015
1074
|
|
|
1016
|
-
|
|
1017
|
-
|
|
1075
|
+
write_atomic "$RUNNER_EXIT_CODE_PATH" "$EXIT_CODE"
|
|
1076
|
+
write_atomic "$RUNNER_ENDED_AT_PATH" "$ENDED_AT"
|
|
1077
|
+
|
|
1018
1078
|
exit "$EXIT_CODE"
|
|
1019
1079
|
"""
|
|
1020
1080
|
|
|
@@ -16,6 +16,12 @@ from typing import Protocol, cast
|
|
|
16
16
|
|
|
17
17
|
import anyio
|
|
18
18
|
|
|
19
|
+
from shell_session_manager.shellctl.server.artifacts import (
|
|
20
|
+
pipe_drained_path,
|
|
21
|
+
pipe_failed_path,
|
|
22
|
+
runner_ended_at_path,
|
|
23
|
+
runner_exit_code_path,
|
|
24
|
+
)
|
|
19
25
|
from shell_session_manager.shellctl.server.config import ShellctlConfig
|
|
20
26
|
from shell_session_manager.shellctl.server.errors import ShellctlServerError
|
|
21
27
|
from shell_session_manager.shellctl.shared import (
|
|
@@ -148,16 +154,10 @@ class TmuxController:
|
|
|
148
154
|
async def enable_output_pipe(
|
|
149
155
|
self, *, job_id: str, job_dir: Path, ready_file: Path
|
|
150
156
|
) -> None:
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
"--ready-file",
|
|
156
|
-
str(ready_file),
|
|
157
|
-
)
|
|
158
|
-
)
|
|
159
|
-
output_command = (
|
|
160
|
-
f"{sanitize_command} >> {shlex.quote(str(job_dir / 'output.log'))}"
|
|
157
|
+
output_command = self._pipe_command_source(
|
|
158
|
+
job_id=job_id,
|
|
159
|
+
job_dir=job_dir,
|
|
160
|
+
ready_file=ready_file,
|
|
161
161
|
)
|
|
162
162
|
result = await self._run_tmux(
|
|
163
163
|
"pipe-pane",
|
|
@@ -175,6 +175,55 @@ class TmuxController:
|
|
|
175
175
|
or f"Failed to attach output pipe for {job_id}",
|
|
176
176
|
)
|
|
177
177
|
|
|
178
|
+
def _pipe_command_source(
|
|
179
|
+
self, *, job_id: str, job_dir: Path, ready_file: Path
|
|
180
|
+
) -> str:
|
|
181
|
+
"""Build the tmux `pipe-pane` command that drains and finalizes output.
|
|
182
|
+
|
|
183
|
+
For normal exits, the runner now records completion metadata into job
|
|
184
|
+
artifacts and the pipe finalizer commits `runner-exit` only after
|
|
185
|
+
`sanitize-pty` reaches EOF and flushes `output.log` successfully.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
sanitize_command = self._shell_join(
|
|
189
|
+
(
|
|
190
|
+
*self._config.shellctl_command,
|
|
191
|
+
"sanitize-pty",
|
|
192
|
+
"--ready-file",
|
|
193
|
+
str(ready_file),
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
runner_exit_command = self._shell_join(
|
|
197
|
+
(
|
|
198
|
+
*self._config.shellctl_command,
|
|
199
|
+
"runner-exit",
|
|
200
|
+
"--state-dir",
|
|
201
|
+
str(self._config.state_dir),
|
|
202
|
+
"--job-id",
|
|
203
|
+
job_id,
|
|
204
|
+
)
|
|
205
|
+
)
|
|
206
|
+
output_path = shlex.quote(str(job_dir / "output.log"))
|
|
207
|
+
drained_path = shlex.quote(str(pipe_drained_path(job_dir)))
|
|
208
|
+
failed_path = shlex.quote(str(pipe_failed_path(job_dir)))
|
|
209
|
+
exit_code_path = shlex.quote(str(runner_exit_code_path(job_dir)))
|
|
210
|
+
ended_at_path = shlex.quote(str(runner_ended_at_path(job_dir)))
|
|
211
|
+
return " ; ".join(
|
|
212
|
+
[
|
|
213
|
+
f"{sanitize_command} >> {output_path}",
|
|
214
|
+
"sanitize_status=$?",
|
|
215
|
+
(
|
|
216
|
+
'if [ "$sanitize_status" -eq 0 ]; then '
|
|
217
|
+
f": > {drained_path}; "
|
|
218
|
+
f"if [ -s {exit_code_path} ] && [ -s {ended_at_path} ]; then "
|
|
219
|
+
f'{runner_exit_command} --exit-code "$(cat {exit_code_path})" '
|
|
220
|
+
f'--ended-at "$(cat {ended_at_path})"; fi; '
|
|
221
|
+
f"else : > {failed_path}; fi"
|
|
222
|
+
),
|
|
223
|
+
'exit "$sanitize_status"',
|
|
224
|
+
]
|
|
225
|
+
)
|
|
226
|
+
|
|
178
227
|
async def send_input(self, *, job_id: str, text: str) -> None:
|
|
179
228
|
buffer_name = f"shellctl-in-{job_id}"
|
|
180
229
|
runtime_dir = cast(Path, self._config.runtime_dir)
|
|
@@ -286,6 +335,7 @@ def _tmux_target_missing(stderr: str) -> bool:
|
|
|
286
335
|
or "can't find session" in normalized
|
|
287
336
|
or "no server running" in normalized
|
|
288
337
|
or "failed to connect" in normalized
|
|
338
|
+
or "server exited unexpectedly" in normalized
|
|
289
339
|
)
|
|
290
340
|
|
|
291
341
|
|
|
@@ -2,6 +2,8 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import importlib
|
|
4
4
|
import os
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
5
7
|
import sqlite3
|
|
6
8
|
import subprocess
|
|
7
9
|
import sys
|
|
@@ -23,6 +25,13 @@ from shell_session_manager.shellctl.server import (
|
|
|
23
25
|
cli,
|
|
24
26
|
create_app,
|
|
25
27
|
)
|
|
28
|
+
from shell_session_manager.shellctl.server.artifacts import (
|
|
29
|
+
PIPE_DRAINED_FILENAME,
|
|
30
|
+
PIPE_FAILED_FILENAME,
|
|
31
|
+
RUNNER_ENDED_AT_FILENAME,
|
|
32
|
+
RUNNER_EXIT_CODE_FILENAME,
|
|
33
|
+
)
|
|
34
|
+
from shell_session_manager.shellctl.server.tmux import TmuxController
|
|
26
35
|
from shell_session_manager.shellctl.shared import (
|
|
27
36
|
DEFAULT_AUTH_TOKEN_ENV,
|
|
28
37
|
InputJobRequest,
|
|
@@ -106,6 +115,62 @@ async def _create_service(
|
|
|
106
115
|
return service, fake_tmux
|
|
107
116
|
|
|
108
117
|
|
|
118
|
+
async def _create_real_service(
|
|
119
|
+
tmp_path: Path,
|
|
120
|
+
*,
|
|
121
|
+
shellctl_command: tuple[str, ...] | None = None,
|
|
122
|
+
) -> ShellctlService:
|
|
123
|
+
config = ShellctlConfig(
|
|
124
|
+
state_dir=tmp_path / "state",
|
|
125
|
+
runtime_dir=tmp_path / "run",
|
|
126
|
+
shellctl_command=shellctl_command
|
|
127
|
+
or ShellctlConfig(
|
|
128
|
+
state_dir=tmp_path / "default-state",
|
|
129
|
+
runtime_dir=tmp_path / "default-run",
|
|
130
|
+
).shellctl_command,
|
|
131
|
+
)
|
|
132
|
+
service = ShellctlService(config)
|
|
133
|
+
await service.initialize()
|
|
134
|
+
return service
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _write_delayed_sanitize_wrapper(
|
|
138
|
+
tmp_path: Path,
|
|
139
|
+
*,
|
|
140
|
+
delay_seconds: float,
|
|
141
|
+
sanitize_exit_code: int | None = None,
|
|
142
|
+
) -> tuple[str, ...]:
|
|
143
|
+
wrapper_path = tmp_path / "delayed-sanitize-wrapper.py"
|
|
144
|
+
wrapper_path.write_text(
|
|
145
|
+
"\n".join(
|
|
146
|
+
[
|
|
147
|
+
"from __future__ import annotations",
|
|
148
|
+
"",
|
|
149
|
+
"import subprocess",
|
|
150
|
+
"import sys",
|
|
151
|
+
"import time",
|
|
152
|
+
"from pathlib import Path",
|
|
153
|
+
"",
|
|
154
|
+
f"REAL = [{sys.executable!r}, '-m', 'shell_session_manager.shellctl.server']",
|
|
155
|
+
"args = sys.argv[1:]",
|
|
156
|
+
"if args[:1] == ['sanitize-pty'] and '--ready-file' in args:",
|
|
157
|
+
" ready_file = Path(args[args.index('--ready-file') + 1])",
|
|
158
|
+
" ready_file.touch()",
|
|
159
|
+
f" time.sleep({delay_seconds!r})",
|
|
160
|
+
(
|
|
161
|
+
f" raise SystemExit({sanitize_exit_code!r})"
|
|
162
|
+
if sanitize_exit_code is not None
|
|
163
|
+
else ""
|
|
164
|
+
),
|
|
165
|
+
"raise SystemExit(subprocess.run(REAL + args, check=False).returncode)",
|
|
166
|
+
"",
|
|
167
|
+
]
|
|
168
|
+
),
|
|
169
|
+
encoding="utf-8",
|
|
170
|
+
)
|
|
171
|
+
return (sys.executable, str(wrapper_path))
|
|
172
|
+
|
|
173
|
+
|
|
109
174
|
def _capture_serve_config(
|
|
110
175
|
monkeypatch: pytest.MonkeyPatch,
|
|
111
176
|
) -> tuple[dict[str, object], CliRunner]:
|
|
@@ -324,6 +389,174 @@ async def test_run_job_happy_path_persists_running_row_and_artifacts(
|
|
|
324
389
|
await service.shutdown()
|
|
325
390
|
|
|
326
391
|
|
|
392
|
+
@pytest.mark.anyio
|
|
393
|
+
@pytest.mark.skipif(shutil.which("tmux") is None, reason="tmux is required")
|
|
394
|
+
async def test_run_job_returns_flushed_output_on_first_terminal_result(
|
|
395
|
+
tmp_path: Path,
|
|
396
|
+
) -> None:
|
|
397
|
+
shellctl_command = _write_delayed_sanitize_wrapper(tmp_path, delay_seconds=0.2)
|
|
398
|
+
service = await _create_real_service(
|
|
399
|
+
tmp_path,
|
|
400
|
+
shellctl_command=shellctl_command,
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
try:
|
|
404
|
+
result = await service.run_job(
|
|
405
|
+
RunJobRequest(
|
|
406
|
+
script="printf 'delayed-flush\\n'\n",
|
|
407
|
+
cwd=str(tmp_path),
|
|
408
|
+
terminal=TerminalSize(),
|
|
409
|
+
timeout=3,
|
|
410
|
+
output_limit=8192,
|
|
411
|
+
idle_flush_seconds=1,
|
|
412
|
+
)
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
reread = await service.wait_job(
|
|
416
|
+
result.job_id,
|
|
417
|
+
WaitJobRequest(
|
|
418
|
+
offset=0,
|
|
419
|
+
timeout=0.001,
|
|
420
|
+
output_limit=8192,
|
|
421
|
+
idle_flush_seconds=0.01,
|
|
422
|
+
),
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
assert result.done is True
|
|
426
|
+
assert result.status is JobStatusName.EXITED
|
|
427
|
+
assert result.exit_code == 0
|
|
428
|
+
assert result.output == "delayed-flush\n"
|
|
429
|
+
assert reread.output == "delayed-flush\n"
|
|
430
|
+
assert reread.offset == result.offset
|
|
431
|
+
finally:
|
|
432
|
+
await service.shutdown()
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
@pytest.mark.anyio
|
|
436
|
+
@pytest.mark.skipif(shutil.which("tmux") is None, reason="tmux is required")
|
|
437
|
+
async def test_sanitize_failure_does_not_commit_normal_exit(
|
|
438
|
+
tmp_path: Path,
|
|
439
|
+
) -> None:
|
|
440
|
+
shellctl_command = _write_delayed_sanitize_wrapper(
|
|
441
|
+
tmp_path,
|
|
442
|
+
delay_seconds=0.2,
|
|
443
|
+
sanitize_exit_code=7,
|
|
444
|
+
)
|
|
445
|
+
service = await _create_real_service(
|
|
446
|
+
tmp_path,
|
|
447
|
+
shellctl_command=shellctl_command,
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
try:
|
|
451
|
+
result = await service.run_job(
|
|
452
|
+
RunJobRequest(
|
|
453
|
+
script="printf 'missing-output\\n'\n",
|
|
454
|
+
cwd=str(tmp_path),
|
|
455
|
+
terminal=TerminalSize(),
|
|
456
|
+
timeout=3,
|
|
457
|
+
output_limit=8192,
|
|
458
|
+
idle_flush_seconds=1,
|
|
459
|
+
)
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
reread = await service.wait_job(
|
|
463
|
+
result.job_id,
|
|
464
|
+
WaitJobRequest(
|
|
465
|
+
offset=0,
|
|
466
|
+
timeout=0.001,
|
|
467
|
+
output_limit=8192,
|
|
468
|
+
idle_flush_seconds=0.01,
|
|
469
|
+
),
|
|
470
|
+
)
|
|
471
|
+
job_dir = service.config.jobs_dir / result.job_id
|
|
472
|
+
|
|
473
|
+
assert result.done is True
|
|
474
|
+
assert result.status is not JobStatusName.EXITED
|
|
475
|
+
assert result.exit_code is None
|
|
476
|
+
assert result.output == ""
|
|
477
|
+
assert reread.output == ""
|
|
478
|
+
assert not (job_dir / PIPE_DRAINED_FILENAME).exists()
|
|
479
|
+
assert (job_dir / PIPE_FAILED_FILENAME).exists()
|
|
480
|
+
finally:
|
|
481
|
+
await service.shutdown()
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
@pytest.mark.anyio
|
|
485
|
+
@pytest.mark.skipif(shutil.which("tmux") is None, reason="tmux is required")
|
|
486
|
+
@pytest.mark.skipif(shutil.which("uv") is None, reason="uv is required")
|
|
487
|
+
async def test_uv_quiet_shebang_returns_output_in_first_terminal_result(
|
|
488
|
+
tmp_path: Path,
|
|
489
|
+
) -> None:
|
|
490
|
+
service = await _create_real_service(tmp_path)
|
|
491
|
+
|
|
492
|
+
try:
|
|
493
|
+
result = await service.run_job(
|
|
494
|
+
RunJobRequest(
|
|
495
|
+
script=(
|
|
496
|
+
"#!/usr/bin/env -S uv run --script --quiet\n"
|
|
497
|
+
"# /// script\n"
|
|
498
|
+
'# requires-python = ">=3.12"\n'
|
|
499
|
+
"# dependencies = []\n"
|
|
500
|
+
"# ///\n"
|
|
501
|
+
'print("hello")\n'
|
|
502
|
+
),
|
|
503
|
+
cwd=str(tmp_path),
|
|
504
|
+
terminal=TerminalSize(),
|
|
505
|
+
timeout=10,
|
|
506
|
+
output_limit=8192,
|
|
507
|
+
idle_flush_seconds=1,
|
|
508
|
+
)
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
assert result.done is True
|
|
512
|
+
assert result.status is JobStatusName.EXITED
|
|
513
|
+
assert result.exit_code == 0
|
|
514
|
+
assert result.output == "hello\n"
|
|
515
|
+
finally:
|
|
516
|
+
await service.shutdown()
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
@pytest.mark.anyio
|
|
520
|
+
@pytest.mark.skipif(shutil.which("tmux") is None, reason="tmux is required")
|
|
521
|
+
@pytest.mark.skipif(shutil.which("bash") is None, reason="bash is required")
|
|
522
|
+
@pytest.mark.parametrize(
|
|
523
|
+
("script", "expected_output"),
|
|
524
|
+
[
|
|
525
|
+
("printf 'no-shebang\\n'\n", "no-shebang\n"),
|
|
526
|
+
("#!/bin/sh\nprintf 'sh-shebang\\n'\n", "sh-shebang\n"),
|
|
527
|
+
(
|
|
528
|
+
"#!/usr/bin/env bash\nprintf 'bash-shebang\\n'\n",
|
|
529
|
+
"bash-shebang\n",
|
|
530
|
+
),
|
|
531
|
+
],
|
|
532
|
+
)
|
|
533
|
+
async def test_existing_script_modes_still_return_output(
|
|
534
|
+
tmp_path: Path,
|
|
535
|
+
script: str,
|
|
536
|
+
expected_output: str,
|
|
537
|
+
) -> None:
|
|
538
|
+
service = await _create_real_service(tmp_path)
|
|
539
|
+
|
|
540
|
+
try:
|
|
541
|
+
result = await service.run_job(
|
|
542
|
+
RunJobRequest(
|
|
543
|
+
script=script,
|
|
544
|
+
cwd=str(tmp_path),
|
|
545
|
+
terminal=TerminalSize(),
|
|
546
|
+
timeout=5,
|
|
547
|
+
output_limit=8192,
|
|
548
|
+
idle_flush_seconds=1,
|
|
549
|
+
)
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
assert result.done is True
|
|
553
|
+
assert result.status is JobStatusName.EXITED
|
|
554
|
+
assert result.exit_code == 0
|
|
555
|
+
assert result.output == expected_output
|
|
556
|
+
finally:
|
|
557
|
+
await service.shutdown()
|
|
558
|
+
|
|
559
|
+
|
|
327
560
|
@pytest.mark.anyio
|
|
328
561
|
async def test_run_job_retries_after_sqlite_insert_conflict_and_cleans_artifacts(
|
|
329
562
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
@@ -557,6 +790,32 @@ async def test_session_disappearing_between_probes_materializes_lost_not_pipe_fa
|
|
|
557
790
|
await service.shutdown()
|
|
558
791
|
|
|
559
792
|
|
|
793
|
+
@pytest.mark.anyio
|
|
794
|
+
async def test_drained_uncommitted_normal_exit_self_commits_to_exited(
|
|
795
|
+
tmp_path: Path,
|
|
796
|
+
) -> None:
|
|
797
|
+
service, _fake_tmux = await _create_service(tmp_path)
|
|
798
|
+
job_id = "05211530-k7p"
|
|
799
|
+
await _seed_job(service, job_id=job_id, status=JobStatusName.RUNNING)
|
|
800
|
+
job_dir = service.config.jobs_dir / job_id
|
|
801
|
+
(job_dir / RUNNER_EXIT_CODE_FILENAME).write_text("0\n", encoding="utf-8")
|
|
802
|
+
(job_dir / RUNNER_ENDED_AT_FILENAME).write_text(
|
|
803
|
+
"2026-05-21T15:30:20Z\n", encoding="utf-8"
|
|
804
|
+
)
|
|
805
|
+
(job_dir / PIPE_DRAINED_FILENAME).touch()
|
|
806
|
+
|
|
807
|
+
view = await service.get_job_status(job_id)
|
|
808
|
+
row = await service._get_job_row(job_id)
|
|
809
|
+
|
|
810
|
+
assert view.status is JobStatusName.EXITED
|
|
811
|
+
assert view.done is True
|
|
812
|
+
assert view.exit_code == 0
|
|
813
|
+
assert row.status == JobStatusName.EXITED.value
|
|
814
|
+
assert row.exit_code == 0
|
|
815
|
+
assert row.ended_at == "2026-05-21T15:30:20Z"
|
|
816
|
+
await service.shutdown()
|
|
817
|
+
|
|
818
|
+
|
|
560
819
|
@pytest.mark.anyio
|
|
561
820
|
async def test_list_jobs_ignores_half_created_directory_and_uses_db_rows(
|
|
562
821
|
tmp_path: Path,
|
|
@@ -1074,6 +1333,44 @@ def test_serve_cli_reads_auth_token_from_environment(
|
|
|
1074
1333
|
assert config.auth_token == "env-token"
|
|
1075
1334
|
|
|
1076
1335
|
|
|
1336
|
+
def test_runner_script_records_completion_metadata_without_direct_runner_exit(
|
|
1337
|
+
tmp_path: Path,
|
|
1338
|
+
) -> None:
|
|
1339
|
+
service = ShellctlService(
|
|
1340
|
+
ShellctlConfig(state_dir=tmp_path / "state", runtime_dir=tmp_path / "run"),
|
|
1341
|
+
tmux=FakeTmuxController(),
|
|
1342
|
+
)
|
|
1343
|
+
source = service._runner_script_source()
|
|
1344
|
+
|
|
1345
|
+
assert RUNNER_EXIT_CODE_FILENAME in source
|
|
1346
|
+
assert RUNNER_ENDED_AT_FILENAME in source
|
|
1347
|
+
assert "write_atomic" in source
|
|
1348
|
+
assert 'mv "$tmp" "$dest"' in source
|
|
1349
|
+
assert re.search(r"\brunner-exit\s+--state-dir\b", source) is None
|
|
1350
|
+
anyio.run(service.shutdown)
|
|
1351
|
+
|
|
1352
|
+
|
|
1353
|
+
def test_pipe_command_finalizer_commits_runner_exit_after_drain(tmp_path: Path) -> None:
|
|
1354
|
+
controller = TmuxController(
|
|
1355
|
+
ShellctlConfig(state_dir=tmp_path / "state", runtime_dir=tmp_path / "run")
|
|
1356
|
+
)
|
|
1357
|
+
source = controller._pipe_command_source(
|
|
1358
|
+
job_id="05211530-k7p",
|
|
1359
|
+
job_dir=tmp_path / "state" / "jobs" / "05211530-k7p",
|
|
1360
|
+
ready_file=tmp_path / "state" / "jobs" / "05211530-k7p" / ".pipe-ready",
|
|
1361
|
+
)
|
|
1362
|
+
|
|
1363
|
+
assert "sanitize-pty" in source
|
|
1364
|
+
assert PIPE_DRAINED_FILENAME in source
|
|
1365
|
+
assert PIPE_FAILED_FILENAME in source
|
|
1366
|
+
assert RUNNER_EXIT_CODE_FILENAME in source
|
|
1367
|
+
assert RUNNER_ENDED_AT_FILENAME in source
|
|
1368
|
+
assert re.search(r"\brunner-exit\b", source) is not None
|
|
1369
|
+
assert 'if [ "$sanitize_status" -eq 0 ]' in source
|
|
1370
|
+
assert source.index("sanitize-pty") < source.index(PIPE_DRAINED_FILENAME)
|
|
1371
|
+
assert source.index(PIPE_DRAINED_FILENAME) < source.index("runner-exit")
|
|
1372
|
+
|
|
1373
|
+
|
|
1077
1374
|
def test_runner_exit_cli_accepts_runner_option_contract(tmp_path: Path) -> None:
|
|
1078
1375
|
async def setup_running_job() -> ShellctlConfig:
|
|
1079
1376
|
service, _fake_tmux = await _create_service(tmp_path)
|
|
File without changes
|
|
File without changes
|
{shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/src/shell_session_manager/__init__.py
RENAMED
|
File without changes
|
{shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/src/shell_session_manager/py.typed
RENAMED
|
File without changes
|
{shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/src/shell_session_manager/session.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/tests/golden_shellctl_sanitize.json
RENAMED
|
File without changes
|
{shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/tests/test_shell_session_autoclose.py
RENAMED
|
File without changes
|
{shell_session_manager-2.1.0 → shell_session_manager-2.1.1}/tests/test_shell_session_manager.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|