Merge pull request #5 from EvoScientist/feat/session-naming
Add `--name/-n` flag for naming run workspaces
This commit is contained in:
+51
-10
@@ -16,12 +16,14 @@ Features:
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import queue
|
||||
import sys
|
||||
import threading
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import typer # type: ignore[import-untyped]
|
||||
@@ -39,7 +41,7 @@ from rich.table import Table # type: ignore[import-untyped]
|
||||
# Backward-compat re-exports (tests import these from EvoScientist.cli)
|
||||
from .stream.state import SubAgentState, StreamState, _parse_todo_items, _build_todo_stats # noqa: F401
|
||||
from .stream.display import console, _run_streaming
|
||||
from .paths import ensure_dirs, new_run_dir, default_workspace_dir
|
||||
from .paths import ensure_dirs, new_run_dir, default_workspace_dir, RUNS_DIR
|
||||
|
||||
|
||||
def _shorten_path(path: str) -> str:
|
||||
@@ -785,6 +787,7 @@ def cmd_interactive(
|
||||
provider: str | None = None,
|
||||
imessage_enabled: bool = False,
|
||||
imessage_allowed_senders: str = "",
|
||||
run_name: str | None = None,
|
||||
) -> None:
|
||||
"""Interactive conversation mode with streaming output.
|
||||
|
||||
@@ -798,6 +801,7 @@ def cmd_interactive(
|
||||
provider: LLM provider name to display in banner
|
||||
imessage_enabled: Whether to auto-start iMessage channel
|
||||
imessage_allowed_senders: Comma-separated allowed senders
|
||||
run_name: Optional run name for /new session deduplication
|
||||
"""
|
||||
import nest_asyncio
|
||||
nest_asyncio.apply()
|
||||
@@ -964,7 +968,7 @@ def cmd_interactive(
|
||||
if user_input.lower() == "/new":
|
||||
# New session: new thread; workspace only changes if not fixed
|
||||
if not workspace_fixed:
|
||||
state["workspace_dir"] = _create_session_workspace()
|
||||
state["workspace_dir"] = _create_session_workspace(run_name)
|
||||
console.print("[dim]Loading new session...[/dim]")
|
||||
state["agent"] = _load_agent(workspace_dir=state["workspace_dir"])
|
||||
state["thread_id"] = str(uuid.uuid4())
|
||||
@@ -1088,9 +1092,28 @@ def cmd_run(agent: Any, prompt: str, thread_id: str | None = None, show_thinking
|
||||
# Agent loading helpers
|
||||
# =============================================================================
|
||||
|
||||
def _create_session_workspace() -> str:
|
||||
"""Create a per-session workspace directory and return its path."""
|
||||
session_id = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
def _deduplicate_run_name(name: str, runs_dir: Path = RUNS_DIR) -> str:
|
||||
"""Return *name* if available, otherwise *name_1*, *name_2*, etc."""
|
||||
if not (runs_dir / name).exists():
|
||||
return name
|
||||
i = 1
|
||||
while (runs_dir / f"{name}_{i}").exists():
|
||||
i += 1
|
||||
return f"{name}_{i}"
|
||||
|
||||
|
||||
def _create_session_workspace(name: str | None = None) -> str:
|
||||
"""Create a per-session workspace directory and return its path.
|
||||
|
||||
Args:
|
||||
name: Optional human-friendly run name. Duplicates are resolved
|
||||
by appending ``_1``, ``_2``, etc. Falls back to a timestamp
|
||||
if *name* is None.
|
||||
"""
|
||||
if name:
|
||||
session_id = _deduplicate_run_name(name)
|
||||
else:
|
||||
session_id = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
workspace_dir = str(new_run_dir(session_id))
|
||||
os.makedirs(workspace_dir, exist_ok=True)
|
||||
return workspace_dir
|
||||
@@ -1456,6 +1479,12 @@ def _main_callback(
|
||||
"--mode",
|
||||
help="Workspace mode: 'daemon' (persistent, default) or 'run' (isolated per-session)",
|
||||
),
|
||||
name: Optional[str] = typer.Option(
|
||||
None,
|
||||
"-n",
|
||||
"--name",
|
||||
help="Name for this run (used as directory name instead of timestamp; requires --mode run)",
|
||||
),
|
||||
prompt: Optional[str] = typer.Option(None, "-p", "--prompt", help="Query to execute (single-shot mode)"),
|
||||
thread_id: Optional[str] = typer.Option(None, "--thread-id", help="Thread ID for conversation persistence"),
|
||||
workdir: Optional[str] = typer.Option(None, "--workdir", help="Override workspace directory for this session"),
|
||||
@@ -1498,6 +1527,15 @@ def _main_callback(
|
||||
if mode and mode not in ("run", "daemon"):
|
||||
raise typer.BadParameter("--mode must be 'run' or 'daemon'")
|
||||
|
||||
# --name only makes sense in run mode
|
||||
if name and not (mode == "run" or (not mode and not workdir and not use_cwd and config.default_mode == "run")):
|
||||
raise typer.BadParameter("--name can only be used with --mode run")
|
||||
|
||||
# Sanitize run name: allow alphanumeric, hyphens, underscores
|
||||
if name:
|
||||
if not re.fullmatch(r"[A-Za-z0-9_-]+", name):
|
||||
raise typer.BadParameter("--name may only contain letters, digits, hyphens, and underscores")
|
||||
|
||||
ensure_dirs()
|
||||
|
||||
# Resolve effective mode from config (CLI mode already applied via overrides)
|
||||
@@ -1518,8 +1556,9 @@ def _main_callback(
|
||||
workspace_root = config.default_workdir or str(default_workspace_dir())
|
||||
workspace_root = os.path.abspath(os.path.expanduser(workspace_root))
|
||||
if effective_mode == "run":
|
||||
session_id = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
workspace_dir = os.path.join(workspace_root, "runs", session_id)
|
||||
runs_dir = Path(workspace_root, "runs")
|
||||
session_id = _deduplicate_run_name(name, runs_dir) if name else datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
workspace_dir = os.path.join(runs_dir, session_id)
|
||||
os.makedirs(workspace_dir, exist_ok=True)
|
||||
workspace_fixed = False
|
||||
else: # daemon
|
||||
@@ -1531,8 +1570,9 @@ def _main_callback(
|
||||
workspace_root = os.path.abspath(os.path.expanduser(config.default_workdir))
|
||||
effective_mode = config.default_mode
|
||||
if effective_mode == "run":
|
||||
session_id = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
workspace_dir = os.path.join(workspace_root, "runs", session_id)
|
||||
runs_dir = Path(workspace_root, "runs")
|
||||
session_id = _deduplicate_run_name(name, runs_dir) if name else datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
workspace_dir = os.path.join(runs_dir, session_id)
|
||||
os.makedirs(workspace_dir, exist_ok=True)
|
||||
workspace_fixed = False
|
||||
else: # daemon
|
||||
@@ -1542,7 +1582,7 @@ def _main_callback(
|
||||
else:
|
||||
effective_mode = config.default_mode
|
||||
if effective_mode == "run":
|
||||
workspace_dir = _create_session_workspace()
|
||||
workspace_dir = _create_session_workspace(name)
|
||||
workspace_fixed = False
|
||||
else: # daemon mode (default)
|
||||
workspace_dir = str(default_workspace_dir())
|
||||
@@ -1568,6 +1608,7 @@ def _main_callback(
|
||||
provider=config.provider,
|
||||
imessage_enabled=config.imessage_enabled,
|
||||
imessage_allowed_senders=config.imessage_allowed_senders,
|
||||
run_name=name,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -170,6 +170,7 @@ EvoSci # or EvoScientist
|
||||
|
||||
```
|
||||
--mode <mode> Workspace mode: 'daemon' (persistent) or 'run' (isolated per-session)
|
||||
-n, --name <name> Name for the run directory (requires --mode run; duplicates get _1, _2, …)
|
||||
--workdir <path> Override workspace directory for this session
|
||||
--use-cwd Use current working directory as workspace
|
||||
--thread-id <id> Resume a conversation thread
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Tests for the --name/-n run-name deduplication logic."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
from EvoScientist.cli import _deduplicate_run_name
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# _deduplicate_run_name
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestDeduplicateRunName:
|
||||
def test_fresh_name(self, tmp_path: Path):
|
||||
assert _deduplicate_run_name("experiment", tmp_path) == "experiment"
|
||||
|
||||
def test_single_collision(self, tmp_path: Path):
|
||||
(tmp_path / "experiment").mkdir()
|
||||
assert _deduplicate_run_name("experiment", tmp_path) == "experiment_1"
|
||||
|
||||
def test_multiple_collisions(self, tmp_path: Path):
|
||||
(tmp_path / "experiment").mkdir()
|
||||
(tmp_path / "experiment_1").mkdir()
|
||||
(tmp_path / "experiment_2").mkdir()
|
||||
assert _deduplicate_run_name("experiment", tmp_path) == "experiment_3"
|
||||
|
||||
def test_gap_in_sequence(self, tmp_path: Path):
|
||||
"""Picks the first available suffix, not the highest + 1."""
|
||||
(tmp_path / "run").mkdir()
|
||||
(tmp_path / "run_2").mkdir()
|
||||
# _1 doesn't exist, so it should pick _1
|
||||
assert _deduplicate_run_name("run", tmp_path) == "run_1"
|
||||
|
||||
def test_does_not_collide_with_files(self, tmp_path: Path):
|
||||
"""A regular file (not dir) with the same name still counts as taken."""
|
||||
(tmp_path / "run").write_text("")
|
||||
assert _deduplicate_run_name("run", tmp_path) == "run_1"
|
||||
Reference in New Issue
Block a user