feat: add POST /api/sessions endpoint for new session creation

This commit is contained in:
Brian Krabach
2026-03-29 22:54:23 -07:00
parent 6c72bd05cc
commit 19ec5c02a5
6 changed files with 93 additions and 1 deletions
Binary file not shown.
Binary file not shown.
+39 -1
View File
@@ -14,6 +14,7 @@ import logging
import os
import pathlib
import pwd
import subprocess
import sys
import time
from typing import Literal
@@ -25,7 +26,7 @@ from websockets.typing import Subprotocol
from fastapi import FastAPI, Form, HTTPException, Request, WebSocket
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from pydantic import BaseModel, field_validator
from starlette.responses import RedirectResponse
from muxplex.auth import (
@@ -268,6 +269,18 @@ class HeartbeatPayload(BaseModel):
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
# ---------------------------------------------------------------------------
@@ -323,6 +336,31 @@ async def get_sessions() -> list[dict]:
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")
async def connect_session(name: str) -> dict:
"""Connect to a tmux session via ttyd.
+54
View File
@@ -966,3 +966,57 @@ def test_patch_settings_ignores_unknown_keys(client, tmp_path, monkeypatch):
assert response.status_code == 200
data = response.json()
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