feat: add POST /api/sessions endpoint for new session creation
This commit is contained in:
Binary file not shown.
Binary file not shown.
+39
-1
@@ -14,6 +14,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
import pwd
|
import pwd
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
@@ -25,7 +26,7 @@ from websockets.typing import Subprotocol
|
|||||||
from fastapi import FastAPI, Form, HTTPException, Request, WebSocket
|
from fastapi import FastAPI, Form, HTTPException, Request, WebSocket
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, field_validator
|
||||||
from starlette.responses import RedirectResponse
|
from starlette.responses import RedirectResponse
|
||||||
|
|
||||||
from muxplex.auth import (
|
from muxplex.auth import (
|
||||||
@@ -268,6 +269,18 @@ class HeartbeatPayload(BaseModel):
|
|||||||
last_interaction_at: float
|
last_interaction_at: float
|
||||||
|
|
||||||
|
|
||||||
|
class CreateSessionPayload(BaseModel):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
@field_validator("name")
|
||||||
|
@classmethod
|
||||||
|
def name_must_not_be_blank(cls, v: str) -> str:
|
||||||
|
stripped = v.strip()
|
||||||
|
if not stripped:
|
||||||
|
raise ValueError("name must not be empty or whitespace")
|
||||||
|
return stripped
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Frontend directory
|
# Frontend directory
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -323,6 +336,31 @@ async def get_sessions() -> list[dict]:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/sessions")
|
||||||
|
async def create_session(payload: CreateSessionPayload) -> dict:
|
||||||
|
"""Create a new tmux session using the new_session_template from settings.
|
||||||
|
|
||||||
|
Substitutes {name} in the template with the validated payload name, then
|
||||||
|
runs the command as a fire-and-forget subprocess (stdout/stderr discarded).
|
||||||
|
|
||||||
|
Returns {name: name} regardless of outcome.
|
||||||
|
Raises HTTP 422 if name is empty or whitespace (handled by Pydantic).
|
||||||
|
"""
|
||||||
|
name = payload.name
|
||||||
|
settings = load_settings()
|
||||||
|
command = settings["new_session_template"].replace("{name}", name)
|
||||||
|
try:
|
||||||
|
subprocess.Popen(
|
||||||
|
command,
|
||||||
|
shell=True,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
_log.warning("Failed to launch new-session command: %r", command)
|
||||||
|
return {"name": name}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/sessions/{name}/connect")
|
@app.post("/api/sessions/{name}/connect")
|
||||||
async def connect_session(name: str) -> dict:
|
async def connect_session(name: str) -> dict:
|
||||||
"""Connect to a tmux session via ttyd.
|
"""Connect to a tmux session via ttyd.
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -966,3 +966,57 @@ def test_patch_settings_ignores_unknown_keys(client, tmp_path, monkeypatch):
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert "unknown_key" not in data
|
assert "unknown_key" not in data
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /api/sessions (create new session)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_session_returns_200_with_name(client, monkeypatch):
|
||||||
|
"""POST /api/sessions with valid name returns 200 with {name: name}."""
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
mock_popen = MagicMock()
|
||||||
|
monkeypatch.setattr("muxplex.main.subprocess.Popen", mock_popen)
|
||||||
|
|
||||||
|
response = client.post("/api/sessions", json={"name": "my-project"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["name"] == "my-project"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_session_substitutes_name_in_template(client, tmp_path, monkeypatch):
|
||||||
|
"""POST /api/sessions substitutes {name} with actual name in new_session_template."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
import muxplex.settings as settings_mod
|
||||||
|
|
||||||
|
settings_path = tmp_path / "settings.json"
|
||||||
|
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path)
|
||||||
|
settings_path.write_text(json.dumps({"new_session_template": "echo {name}"}))
|
||||||
|
|
||||||
|
popen_calls = []
|
||||||
|
|
||||||
|
def mock_popen(cmd, **kwargs):
|
||||||
|
popen_calls.append(cmd)
|
||||||
|
return object()
|
||||||
|
|
||||||
|
monkeypatch.setattr("muxplex.main.subprocess.Popen", mock_popen)
|
||||||
|
|
||||||
|
response = client.post("/api/sessions", json={"name": "my-project"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert len(popen_calls) == 1
|
||||||
|
assert popen_calls[0] == "echo my-project"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_session_rejects_empty_name(client):
|
||||||
|
"""POST /api/sessions with empty name returns 422."""
|
||||||
|
response = client.post("/api/sessions", json={"name": ""})
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_session_rejects_missing_name(client):
|
||||||
|
"""POST /api/sessions with missing name returns 422."""
|
||||||
|
response = client.post("/api/sessions", json={})
|
||||||
|
assert response.status_code == 422
|
||||||
|
|||||||
Reference in New Issue
Block a user