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 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.
+54
View File
@@ -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