refactor(server): extract context.py into focused modules

Split the monolithic context.py (117 lines) into five purpose-specific
modules following single-responsibility principle:

- config.py: Server-level constants (DATA_DIR, SESSIONS_DIR, PORT,
  STALE_EVENT_AGE, _state_lock)
- agents.py: Agent-specific paths and caches (CLAUDE_PROJECTS_DIR,
  CODEX_SESSIONS_DIR, discovery caches)
- auth.py: Authentication token generation/validation for spawn endpoint
- spawn_config.py: Spawn feature configuration (PENDING_SPAWNS_DIR,
  rate limiting, projects watcher thread)
- zellij.py: Zellij binary resolution and session management constants

This refactoring improves:
- Code navigation: Find relevant constants by domain, not alphabetically
- Testing: Each module can be tested in isolation
- Import clarity: Mixins import only what they need
- Future maintenance: Changes to one domain don't risk breaking others

All mixins updated to import from new module locations. Tests updated
to use new import paths.

Includes PROPOSED_CODE_FILE_REORGANIZATION_PLAN.md documenting the
rationale and mapping from old to new locations.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
teernisse
2026-02-27 11:05:39 -05:00
parent 69175f08f9
commit 1fb4a82b39
20 changed files with 683 additions and 191 deletions

View File

@@ -0,0 +1,316 @@
# Proposed Code File Reorganization Plan
## Executive Summary
After reading every source file in the project, analyzing all import graphs, and understanding how each module fits into the architecture, my assessment is: **the project is already reasonably well-organized**. The mixin-based decomposition of the handler, the dashboard's `components/utils/lib` split, and the test structure that mirrors source all reflect sound engineering.
That said, there is one clear structural problem and a few smaller wins. This plan proposes **surgical, high-value changes** rather than a gratuitous restructure. The guiding principle: every change must make it easier for a developer (or agent) to find things and understand the architecture.
---
## Current Structure (Annotated)
```
amc/
amc_server/ # Python backend (2,571 LOC)
__init__.py # Package init, exports main
server.py # Server startup/shutdown (38 LOC)
handler.py # Handler class composed from mixins (31 LOC)
context.py # ** PROBLEM ** All config, constants, caches, locks, auth (121 LOC)
logging_utils.py # Logging + signal handlers (31 LOC)
mixins/ # Handler mixins (one per concern)
__init__.py # Package comment (1 LOC)
http.py # HTTP routing, static file serving (173 LOC)
state.py # State aggregation, SSE, session collection, cleanup (440 LOC)
conversation.py # Conversation parsing for Claude/Codex (278 LOC)
control.py # Session dismiss/respond, Zellij pane injection (295 LOC)
discovery.py # Codex session discovery, pane matching (347 LOC)
parsing.py # JSONL parsing, context usage extraction (274 LOC)
skills.py # Skill enumeration for autocomplete (184 LOC)
spawn.py # Agent spawning in Zellij tabs (358 LOC)
dashboard/ # Preact frontend (2,564 LOC)
index.html # Entry HTML with Tailwind config
main.js # App mount point (7 LOC)
styles.css # Custom styles
lib/ # Third-party/shared
preact.js # Preact re-exports
markdown.js # Markdown rendering + syntax highlighting (159 LOC)
utils/ # Pure utility functions
api.js # API constants + fetch helpers (39 LOC)
formatting.js # Time/token formatting (66 LOC)
status.js # Status metadata + session grouping (79 LOC)
autocomplete.js # Autocomplete trigger detection (48 LOC)
components/ # UI components
App.js # Root component (616 LOC)
Sidebar.js # Project nav sidebar (102 LOC)
SessionCard.js # Session card (176 LOC)
Modal.js # Full-screen modal wrapper (79 LOC)
ChatMessages.js # Message list (39 LOC)
MessageBubble.js # Individual message (54 LOC)
QuestionBlock.js # AskUserQuestion UI (228 LOC)
SimpleInput.js # Freeform text input (228 LOC)
OptionButton.js # Option button (24 LOC)
AgentActivityIndicator.js # Turn timer (115 LOC)
SpawnModal.js # Spawn dropdown (241 LOC)
Toast.js # Toast notifications (125 LOC)
EmptyState.js # Empty state (18 LOC)
Header.js # ** DEAD CODE ** (58 LOC, zero imports)
SessionGroup.js # ** DEAD CODE ** (56 LOC, zero imports)
bin/ # Shell/Python scripts
amc # Launcher (start/stop/status)
amc-hook # Hook script (standalone, writes session state)
amc-server # Server launch script
amc-server-restart # Server restart helper
tests/ # Test suite (mirrors mixin structure)
test_context.py # Context tests
test_control.py # Control mixin tests
test_conversation.py # Conversation parsing tests
test_conversation_mtime.py # Conversation mtime tests
test_discovery.py # Discovery mixin tests
test_hook.py # Hook script tests
test_http.py # HTTP mixin tests
test_parsing.py # Parsing mixin tests
test_skills.py # Skills mixin tests
test_spawn.py # Spawn mixin tests
test_state.py # State mixin tests
test_zellij_metadata.py # Zellij metadata tests
e2e/ # End-to-end tests
__init__.py
test_skills_endpoint.py
test_autocomplete_workflow.js
e2e_spawn.sh # Spawn E2E script
```
---
## Proposed Changes
### Change 1: Split `context.py` into Focused Modules (HIGH VALUE)
**Problem:** `context.py` is the classic "junk drawer" module. It contains:
- Path constants for the server, Zellij, Claude, and Codex
- Server configuration (port, timeouts)
- 5 independent caches with their own size limits
- 2 threading locks for unrelated concerns
- Auth token generation/validation
- Zellij binary resolution
- Spawn-related config
- Background thread management for projects cache
Every mixin imports from it, but each only needs a subset. When a developer asks "where is the spawn rate limit configured?", they have to scan through an unrelated grab-bag of constants. When they ask "where's the Codex transcript cache?", same problem.
**Proposed split:**
```
amc_server/
config.py # Server-level constants: PORT, DATA_DIR, SESSIONS_DIR, EVENTS_DIR,
# DASHBOARD_DIR, PROJECT_DIR, STALE_EVENT_AGE, STALE_STARTING_AGE
# These are the "universal" constants every module might need.
zellij.py # Zellij integration: ZELLIJ_BIN resolution, ZELLIJ_PLUGIN path,
# ZELLIJ_SESSION, _zellij_cache (sessions cache + expiry)
# Rationale: All Zellij-specific constants and helpers in one place.
# Any developer working on Zellij integration knows exactly where to look.
agents.py # Agent-specific paths and caches:
# CLAUDE_PROJECTS_DIR, CODEX_SESSIONS_DIR, CODEX_ACTIVE_WINDOW,
# _codex_pane_cache, _codex_transcript_cache, _CODEX_CACHE_MAX,
# _context_usage_cache, _CONTEXT_CACHE_MAX,
# _dismissed_codex_ids, _DISMISSED_MAX
# Rationale: Agent data source configuration and caches that are only
# relevant to discovery/parsing mixins, not the whole server.
auth.py # Auth token: generate_auth_token(), validate_auth_token(), _auth_token
# Rationale: Security-sensitive code in its own module. Small, but
# architecturally clean separation from general config.
spawn_config.py # Spawn feature config:
# PROJECTS_DIR, PENDING_SPAWNS_DIR, PENDING_SPAWN_TTL,
# _spawn_lock, _spawn_timestamps, SPAWN_COOLDOWN_SEC
# + start_projects_watcher() (background refresh thread)
# Rationale: Spawn feature has its own set of constants, lock, and
# background thread. Currently scattered between context.py and spawn.py.
# Consolidating makes the spawn feature self-contained.
```
Kept from current structure (unchanged):
- `_state_lock` moves to `config.py` (it's a server-level concern)
**Import changes required:**
| File | Current import from `context` | New import from |
|------|------|------|
| `server.py` | `DATA_DIR, PORT, generate_auth_token, start_projects_watcher` | `config.DATA_DIR, config.PORT`, `auth.generate_auth_token`, `spawn_config.start_projects_watcher` |
| `handler.py` | (none, uses mixins) | (unchanged) |
| `mixins/http.py` | `DASHBOARD_DIR`, `ctx._auth_token` | `config.DASHBOARD_DIR`, `auth._auth_token` |
| `mixins/state.py` | `EVENTS_DIR, SESSIONS_DIR, STALE_*, ZELLIJ_BIN, _state_lock, _zellij_cache` | `config.*`, `zellij.ZELLIJ_BIN, zellij._zellij_cache` |
| `mixins/conversation.py` | `EVENTS_DIR` | `config.EVENTS_DIR` |
| `mixins/control.py` | `SESSIONS_DIR, ZELLIJ_BIN, ZELLIJ_PLUGIN, _DISMISSED_MAX, _dismissed_codex_ids` | `config.SESSIONS_DIR`, `zellij.*`, `agents._DISMISSED_MAX, agents._dismissed_codex_ids` |
| `mixins/discovery.py` | `CODEX_*, PENDING_SPAWNS_DIR, SESSIONS_DIR, _codex_*` | `agents.*`, `spawn_config.PENDING_SPAWNS_DIR`, `config.SESSIONS_DIR` |
| `mixins/parsing.py` | `CLAUDE_PROJECTS_DIR, CODEX_SESSIONS_DIR, _*_cache, _*_MAX` | `agents.*` |
| `mixins/spawn.py` | `PENDING_SPAWNS_DIR, PROJECTS_DIR, SESSIONS_DIR, ZELLIJ_*, _spawn_*, validate_auth_token` | `spawn_config.*`, `config.SESSIONS_DIR`, `zellij.*`, `auth.validate_auth_token` |
**Why this is the right split:**
1. **By domain, not by size.** Each new module groups constants + caches + helpers that serve one architectural concern. A developer working on Zellij integration opens `zellij.py`. Working on Codex discovery? `agents.py`. Spawn feature? `spawn_config.py`.
2. **No circular imports.** The dependency graph is DAG: `config.py` is a leaf (imported by everything, imports nothing from `amc_server`). `zellij.py`, `agents.py`, `auth.py`, `spawn_config.py` import only from `config.py` (if at all). Mixins import from these.
3. **No behavioral change.** Module-level caches and singletons work the same way whether they're in one file or five.
---
### Change 2: Delete Dead Dashboard Components (LOW EFFORT, HIGH CLARITY)
**Problem:** `Header.js` (58 LOC) and `SessionGroup.js` (56 LOC) are completely unused. Zero imports anywhere in the codebase. They were replaced by the current Sidebar + grid layout but never cleaned up.
**Action:** Delete both files.
**Import changes required:** None (nothing imports them).
**Rationale:** Dead code is noise. Anyone exploring the `components/` directory would reasonably assume these are active and try to understand how they fit. Removing them prevents that confusion.
---
### Change 3: No Changes to Dashboard Structure
The dashboard is already well-organized:
- `components/` - All React-like components
- `utils/` - Pure utility functions (formatting, API, status, autocomplete)
- `lib/` - Third-party wrappers (Preact, markdown rendering)
This is a standard and intuitive layout. The `components/` directory has 13 files (15 before dead code removal), which is manageable. Creating sub-directories (e.g., `components/session/`, `components/layout/`) would add nesting without meaningful benefit at this scale.
---
### Change 4: No Changes to `mixins/` Structure
The mixin decomposition is the project's architectural backbone. Each mixin handles one concern:
| Mixin | Responsibility |
|-------|---------------|
| `http.py` | HTTP routing, static file serving, CORS |
| `state.py` | State aggregation, SSE streaming, session collection |
| `conversation.py` | Conversation history parsing (Claude + Codex JSONL) |
| `control.py` | Session dismiss/respond, Zellij pane injection |
| `discovery.py` | Codex session auto-discovery, pane matching |
| `parsing.py` | JSONL tail reading, context usage extraction, caching |
| `skills.py` | Skill enumeration for Claude/Codex autocomplete |
| `spawn.py` | Agent spawning in Zellij tabs |
All are 170-440 lines, which is reasonable. The largest (`state.py` at 440 lines) could theoretically be split, but its methods are tightly coupled around session collection. Splitting would create artificial seams.
---
### Change 5: No Changes to `tests/` Structure
Tests already mirror the source structure (`test_state.py` tests `mixins/state.py`, etc.). This is the correct pattern.
**One consideration:** After splitting `context.py`, `test_context.py` may need updates to import from the new module locations. The test file is small (755 bytes) and covers basic context constants, so the update would be trivial.
---
### Change 6: No Changes to `bin/` Scripts
The `amc-hook` script intentionally duplicates `DATA_DIR`, `SESSIONS_DIR`, `EVENTS_DIR` from `context.py`. This is correct: the hook runs as a standalone process launched by Claude Code, not as part of the server. It must be self-contained with zero dependencies on the server package. Sharing code would create a fragile coupling.
---
## What I Explicitly Decided NOT to Do
1. **Not creating a `src/` directory.** The project root is clean. Adding `src/` would be an extra nesting level with no benefit.
2. **Not splitting any mixins.** `state.py` (440 LOC) and `spawn.py` (358 LOC) are the largest, but their methods are cohesive. Splitting would scatter related logic across files.
3. **Not merging small files.** `EmptyState.js` (18 LOC), `OptionButton.js` (24 LOC), and `ChatMessages.js` (39 LOC) are tiny but each has a clear purpose and is imported independently. Merging them would violate component-per-file convention.
4. **Not reorganizing dashboard components into sub-folders.** With 13 components, flat is fine. Sub-folders like `components/session/` and `components/layout/` become necessary at ~25+ components.
5. **Not consolidating `api.js` + `formatting.js` + `status.js` + `autocomplete.js`.** Each is focused and independently imported. A combined `utils.js` would be a grab-bag (the exact problem we're fixing in `context.py`).
6. **Not moving `markdown.js` out of `lib/`.** It uses third-party dependencies and provides rendering utilities. `lib/` is the correct location.
---
## Proposed Final Structure
```
amc/
amc_server/
__init__.py # (unchanged)
server.py # (updated imports)
handler.py # (unchanged)
config.py # NEW: Server constants, DATA_DIR, SESSIONS_DIR, EVENTS_DIR, PORT, etc.
zellij.py # NEW: Zellij binary resolution, ZELLIJ_PLUGIN, ZELLIJ_SESSION, cache
agents.py # NEW: Agent paths (Claude/Codex), transcript caches, dismissed cache
auth.py # NEW: Auth token generation/validation
spawn_config.py # NEW: Spawn constants, locks, rate limiting, projects watcher
logging_utils.py # (unchanged)
mixins/ # (unchanged structure, updated imports)
__init__.py
http.py
state.py
conversation.py
control.py
discovery.py
parsing.py
skills.py
spawn.py
dashboard/
index.html # (unchanged)
main.js # (unchanged)
styles.css # (unchanged)
lib/
preact.js # (unchanged)
markdown.js # (unchanged)
utils/
api.js # (unchanged)
formatting.js # (unchanged)
status.js # (unchanged)
autocomplete.js # (unchanged)
components/
App.js # (unchanged)
Sidebar.js # (unchanged)
SessionCard.js # (unchanged)
Modal.js # (unchanged)
ChatMessages.js # (unchanged)
MessageBubble.js # (unchanged)
QuestionBlock.js # (unchanged)
SimpleInput.js # (unchanged)
OptionButton.js # (unchanged)
AgentActivityIndicator.js # (unchanged)
SpawnModal.js # (unchanged)
Toast.js # (unchanged)
EmptyState.js # (unchanged)
[DELETED] Header.js
[DELETED] SessionGroup.js
bin/ # (unchanged)
tests/ # (minor import updates in test_context.py)
```
---
## Implementation Order
1. **Delete dead dashboard components** (`Header.js`, `SessionGroup.js`) - zero risk, instant clarity
2. **Create new Python modules** (`config.py`, `zellij.py`, `agents.py`, `auth.py`, `spawn_config.py`) with the correct constants/functions
3. **Update all mixin imports** to use new module locations
4. **Update `server.py`** imports
5. **Delete `context.py`**
6. **Run full test suite** to verify nothing broke
7. **Update `test_context.py`** if needed
---
## Risk Assessment
- **Risk of breaking imports:** MEDIUM. There are many import statements to update across 8 mixin files + `server.py`. Mitigated by running the full test suite after changes.
- **Risk of circular imports:** LOW. The new modules form a clean DAG (config <- zellij/agents/auth/spawn_config <- mixins).
- **Risk to `bin/amc-hook`:** NONE. The hook is standalone and doesn't import from `amc_server`.
- **Risk to dashboard:** NONE for dead code deletion. Zero imports to either file.

28
amc_server/agents.py Normal file
View File

@@ -0,0 +1,28 @@
"""Agent-specific paths, caches, and constants for Claude/Codex discovery."""
from pathlib import Path
# Claude Code conversation directory
CLAUDE_PROJECTS_DIR = Path.home() / ".claude" / "projects"
# Codex conversation directory
CODEX_SESSIONS_DIR = Path.home() / ".codex" / "sessions"
# Only discover recently-active Codex sessions (10 minutes)
CODEX_ACTIVE_WINDOW = 600
# Cache for Codex pane info (avoid running pgrep/ps/lsof on every request)
_codex_pane_cache = {"pid_info": {}, "cwd_map": {}, "expires": 0}
# Cache for parsed context usage by transcript file path + mtime/size
_context_usage_cache = {}
_CONTEXT_CACHE_MAX = 100
# Cache mapping Codex session IDs to transcript paths (or None when missing)
_codex_transcript_cache = {}
_CODEX_CACHE_MAX = 200
# Codex sessions dismissed during this server lifetime (prevents re-discovery)
# Uses dict (not set) for O(1) lookup + FIFO eviction via insertion order (Python 3.7+)
_dismissed_codex_ids = {}
_DISMISSED_MAX = 500

18
amc_server/auth.py Normal file
View File

@@ -0,0 +1,18 @@
"""Auth token generation and validation for spawn endpoint security."""
import secrets
# Auth token for spawn endpoint
_auth_token: str = ''
def generate_auth_token():
"""Generate a one-time auth token for this server instance."""
global _auth_token
_auth_token = secrets.token_urlsafe(32)
return _auth_token
def validate_auth_token(request_token: str) -> bool:
"""Validate the Authorization header token."""
return request_token == f'Bearer {_auth_token}'

20
amc_server/config.py Normal file
View File

@@ -0,0 +1,20 @@
"""Server-level constants: paths, port, timeouts, state lock."""
import threading
from pathlib import Path
# Runtime data lives in XDG data dir
DATA_DIR = Path.home() / ".local" / "share" / "amc"
SESSIONS_DIR = DATA_DIR / "sessions"
EVENTS_DIR = DATA_DIR / "events"
# Source files live in project directory (relative to this module)
PROJECT_DIR = Path(__file__).resolve().parent.parent
DASHBOARD_DIR = PROJECT_DIR / "dashboard"
PORT = 7400
STALE_EVENT_AGE = 86400 # 24 hours in seconds
STALE_STARTING_AGE = 3600 # 1 hour - sessions stuck in "starting" are orphans
# Serialize state collection because it mutates session files/caches.
_state_lock = threading.Lock()

View File

@@ -1,117 +0,0 @@
import secrets
import shutil
from pathlib import Path
import threading
# Claude Code conversation directory
CLAUDE_PROJECTS_DIR = Path.home() / ".claude" / "projects"
# Codex conversation directory
CODEX_SESSIONS_DIR = Path.home() / ".codex" / "sessions"
# Plugin path for zellij-send-keys
ZELLIJ_PLUGIN = Path.home() / ".config" / "zellij" / "plugins" / "zellij-send-keys.wasm"
def _resolve_zellij_bin():
"""Resolve zellij binary even when PATH is minimal (eg launchctl)."""
from_path = shutil.which("zellij")
if from_path:
return from_path
common_paths = (
"/opt/homebrew/bin/zellij", # Apple Silicon Homebrew
"/usr/local/bin/zellij", # Intel Homebrew
"/usr/bin/zellij",
)
for candidate in common_paths:
p = Path(candidate)
if p.exists() and p.is_file():
return str(p)
return "zellij" # Fallback for explicit error reporting by subprocess
ZELLIJ_BIN = _resolve_zellij_bin()
# Runtime data lives in XDG data dir
DATA_DIR = Path.home() / ".local" / "share" / "amc"
SESSIONS_DIR = DATA_DIR / "sessions"
EVENTS_DIR = DATA_DIR / "events"
# Source files live in project directory (relative to this module)
PROJECT_DIR = Path(__file__).resolve().parent.parent
DASHBOARD_DIR = PROJECT_DIR / "dashboard"
PORT = 7400
STALE_EVENT_AGE = 86400 # 24 hours in seconds
STALE_STARTING_AGE = 3600 # 1 hour - sessions stuck in "starting" are orphans
CODEX_ACTIVE_WINDOW = 600 # 10 minutes - only discover recently-active Codex sessions
# Cache for Zellij session list (avoid calling zellij on every request)
_zellij_cache = {"sessions": None, "expires": 0}
# Cache for Codex pane info (avoid running pgrep/ps/lsof on every request)
_codex_pane_cache = {"pid_info": {}, "cwd_map": {}, "expires": 0}
# Cache for parsed context usage by transcript file path + mtime/size
# Limited to prevent unbounded memory growth
_context_usage_cache = {}
_CONTEXT_CACHE_MAX = 100
# Cache mapping Codex session IDs to transcript paths (or None when missing)
_codex_transcript_cache = {}
_CODEX_CACHE_MAX = 200
# Codex sessions dismissed during this server lifetime (prevents re-discovery)
# Uses dict (not set) for O(1) lookup + FIFO eviction via insertion order (Python 3.7+)
_dismissed_codex_ids = {}
_DISMISSED_MAX = 500
# Serialize state collection because it mutates session files/caches.
_state_lock = threading.Lock()
# Projects directory for spawning agents
PROJECTS_DIR = Path.home() / 'projects'
# Default Zellij session for spawning
ZELLIJ_SESSION = 'infra'
# Lock for serializing spawn operations (prevents Zellij race conditions)
_spawn_lock = threading.Lock()
# Rate limiting: track last spawn time per project (prevents spam)
_spawn_timestamps: dict[str, float] = {}
SPAWN_COOLDOWN_SEC = 10.0
# Auth token for spawn endpoint
_auth_token: str = ''
def generate_auth_token():
"""Generate a one-time auth token for this server instance."""
global _auth_token
_auth_token = secrets.token_urlsafe(32)
return _auth_token
def validate_auth_token(request_token: str) -> bool:
"""Validate the Authorization header token."""
return request_token == f'Bearer {_auth_token}'
def start_projects_watcher():
"""Start background thread to refresh projects cache every 5 minutes."""
import logging
from amc_server.mixins.spawn import load_projects_cache
def _watch_loop():
import time
while True:
try:
time.sleep(300)
load_projects_cache()
except Exception:
logging.exception('Projects cache refresh failed')
thread = threading.Thread(target=_watch_loop, daemon=True)
thread.start()

View File

@@ -3,7 +3,9 @@ import os
import subprocess import subprocess
import time import time
from amc_server.context import SESSIONS_DIR, ZELLIJ_BIN, ZELLIJ_PLUGIN, _DISMISSED_MAX, _dismissed_codex_ids from amc_server.agents import _DISMISSED_MAX, _dismissed_codex_ids
from amc_server.config import SESSIONS_DIR
from amc_server.zellij import ZELLIJ_BIN, ZELLIJ_PLUGIN
from amc_server.logging_utils import LOGGER from amc_server.logging_utils import LOGGER

View File

@@ -1,7 +1,7 @@
import json import json
import os import os
from amc_server.context import EVENTS_DIR from amc_server.config import EVENTS_DIR
class ConversationMixin: class ConversationMixin:

View File

@@ -5,18 +5,99 @@ import subprocess
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
from amc_server.context import ( from amc_server.agents import (
CODEX_ACTIVE_WINDOW, CODEX_ACTIVE_WINDOW,
CODEX_SESSIONS_DIR, CODEX_SESSIONS_DIR,
SESSIONS_DIR,
_CODEX_CACHE_MAX, _CODEX_CACHE_MAX,
_codex_pane_cache, _codex_pane_cache,
_codex_transcript_cache, _codex_transcript_cache,
_dismissed_codex_ids, _dismissed_codex_ids,
) )
from amc_server.config import SESSIONS_DIR
from amc_server.spawn_config import PENDING_SPAWNS_DIR
from amc_server.logging_utils import LOGGER from amc_server.logging_utils import LOGGER
def _parse_session_timestamp(session_ts):
"""Parse Codex session timestamp to Unix time. Returns None on failure."""
if not session_ts:
return None
try:
# Codex uses ISO format, possibly with Z suffix or +00:00
ts_str = session_ts.replace('Z', '+00:00')
dt = datetime.fromisoformat(ts_str)
return dt.timestamp()
except (ValueError, TypeError, AttributeError):
return None
def _match_pending_spawn(session_cwd, session_start_ts):
"""Match a Codex session to a pending spawn by CWD and timestamp.
Args:
session_cwd: The CWD of the Codex session
session_start_ts: The session's START timestamp (ISO string from Codex metadata)
IMPORTANT: Must be session start time, not file mtime, to avoid false
matches with pre-existing sessions that were recently active.
Returns:
spawn_id if matched (and deletes the pending file), None otherwise
"""
if not PENDING_SPAWNS_DIR.exists():
return None
normalized_cwd = os.path.normpath(session_cwd) if session_cwd else ""
if not normalized_cwd:
return None
# Parse session start time - if we can't parse it, we can't safely match
session_start_unix = _parse_session_timestamp(session_start_ts)
if session_start_unix is None:
return None
try:
for pending_file in PENDING_SPAWNS_DIR.glob('*.json'):
try:
data = json.loads(pending_file.read_text())
if not isinstance(data, dict):
continue
# Check agent type (only match codex to codex)
if data.get('agent_type') != 'codex':
continue
# Check CWD match
pending_path = os.path.normpath(data.get('project_path', ''))
if normalized_cwd != pending_path:
continue
# Check timing: session must have STARTED after spawn was initiated
# Using session start time (not mtime) prevents false matches with
# pre-existing sessions that happen to be recently active
spawn_ts = data.get('timestamp', 0)
if session_start_unix < spawn_ts:
continue
# Match found - claim the spawn_id and delete the pending file
spawn_id = data.get('spawn_id')
try:
pending_file.unlink()
except OSError:
pass
LOGGER.info(
'Matched Codex session (cwd=%s) to pending spawn_id=%s',
session_cwd, spawn_id,
)
return spawn_id
except (json.JSONDecodeError, OSError):
continue
except OSError:
pass
return None
class SessionDiscoveryMixin: class SessionDiscoveryMixin:
def _discover_active_codex_sessions(self): def _discover_active_codex_sessions(self):
"""Find active Codex sessions and create/update session files with Zellij pane info.""" """Find active Codex sessions and create/update session files with Zellij pane info."""
@@ -131,6 +212,13 @@ class SessionDiscoveryMixin:
session_ts = payload.get("timestamp", "") session_ts = payload.get("timestamp", "")
last_event_at = datetime.fromtimestamp(mtime, tz=timezone.utc).isoformat() last_event_at = datetime.fromtimestamp(mtime, tz=timezone.utc).isoformat()
# Check for spawn_id: preserve existing, or match to pending spawn
# Use session_ts (start time) not mtime to avoid false matches
# with pre-existing sessions that were recently active
spawn_id = existing.get("spawn_id")
if not spawn_id:
spawn_id = _match_pending_spawn(cwd, session_ts)
session_data = { session_data = {
"session_id": session_id, "session_id": session_id,
"agent": "codex", "agent": "codex",
@@ -145,6 +233,8 @@ class SessionDiscoveryMixin:
"zellij_pane": zellij_pane or existing.get("zellij_pane", ""), "zellij_pane": zellij_pane or existing.get("zellij_pane", ""),
"transcript_path": str(jsonl_file), "transcript_path": str(jsonl_file),
} }
if spawn_id:
session_data["spawn_id"] = spawn_id
if context_usage: if context_usage:
session_data["context_usage"] = context_usage session_data["context_usage"] = context_usage
elif existing.get("context_usage"): elif existing.get("context_usage"):

View File

@@ -1,8 +1,8 @@
import json import json
import urllib.parse import urllib.parse
import amc_server.context as ctx import amc_server.auth as auth
from amc_server.context import DASHBOARD_DIR from amc_server.config import DASHBOARD_DIR
from amc_server.logging_utils import LOGGER from amc_server.logging_utils import LOGGER
@@ -148,10 +148,10 @@ class HttpMixin:
content_type = content_types.get(ext, "application/octet-stream") content_type = content_types.get(ext, "application/octet-stream")
# Inject auth token into index.html for spawn endpoint security # Inject auth token into index.html for spawn endpoint security
if file_path == "index.html" and ctx._auth_token: if file_path == "index.html" and auth._auth_token:
content = content.replace( content = content.replace(
b"<!-- AMC_AUTH_TOKEN -->", b"<!-- AMC_AUTH_TOKEN -->",
f'<script>window.AMC_AUTH_TOKEN = "{ctx._auth_token}";</script>'.encode(), f'<script>window.AMC_AUTH_TOKEN = "{auth._auth_token}";</script>'.encode(),
) )
# No caching during development # No caching during development

View File

@@ -2,7 +2,7 @@ import json
import os import os
from pathlib import Path from pathlib import Path
from amc_server.context import ( from amc_server.agents import (
CLAUDE_PROJECTS_DIR, CLAUDE_PROJECTS_DIR,
CODEX_SESSIONS_DIR, CODEX_SESSIONS_DIR,
_CODEX_CACHE_MAX, _CODEX_CACHE_MAX,

View File

@@ -37,7 +37,7 @@ class SkillsMixin:
Checks SKILL.md (canonical) first, then falls back to skill.md, Checks SKILL.md (canonical) first, then falls back to skill.md,
prompt.md, README.md for description extraction. Parses YAML prompt.md, README.md for description extraction. Parses YAML
frontmatter if present to extract the description field. frontmatter if present to extract name and description fields.
Returns: Returns:
List of {name: str, description: str} dicts. List of {name: str, description: str} dicts.
@@ -53,35 +53,41 @@ class SkillsMixin:
if not skill_dir.is_dir() or skill_dir.name.startswith("."): if not skill_dir.is_dir() or skill_dir.name.startswith("."):
continue continue
description = "" meta = {"name": "", "description": ""}
# Check files in priority order # Check files in priority order, accumulating metadata
# (earlier files take precedence for each field)
for md_name in ["SKILL.md", "skill.md", "prompt.md", "README.md"]: for md_name in ["SKILL.md", "skill.md", "prompt.md", "README.md"]:
md_file = skill_dir / md_name md_file = skill_dir / md_name
if md_file.exists(): if md_file.exists():
try: try:
content = md_file.read_text() content = md_file.read_text()
description = self._extract_description(content) parsed = self._parse_frontmatter(content)
if description: if not meta["name"] and parsed["name"]:
meta["name"] = parsed["name"]
if not meta["description"] and parsed["description"]:
meta["description"] = parsed["description"]
if meta["description"]:
break break
except OSError: except OSError:
pass pass
skills.append({ skills.append({
"name": skill_dir.name, "name": meta["name"] or skill_dir.name,
"description": description or f"Skill: {skill_dir.name}", "description": meta["description"] or f"Skill: {skill_dir.name}",
}) })
return skills return skills
def _extract_description(self, content: str) -> str: def _parse_frontmatter(self, content: str) -> dict:
"""Extract description from markdown content. """Extract name and description from markdown YAML frontmatter.
Handles YAML frontmatter (looks for 'description:' field) and Returns:
falls back to first meaningful line after frontmatter. Dict with 'name' and 'description' keys (both str, may be empty).
""" """
result = {"name": "", "description": ""}
lines = content.splitlines() lines = content.splitlines()
if not lines: if not lines:
return "" return result
# Check for YAML frontmatter # Check for YAML frontmatter
frontmatter_end = 0 frontmatter_end = 0
@@ -91,33 +97,37 @@ class SkillsMixin:
if stripped == "---": if stripped == "---":
frontmatter_end = i + 1 frontmatter_end = i + 1
break break
# Look for description field in frontmatter # Check each known frontmatter field
if stripped.startswith("description:"): for field in ("name", "description"):
# Extract value after colon if stripped.startswith(f"{field}:"):
desc = stripped[len("description:"):].strip() val = stripped[len(field) + 1:].strip()
# Remove quotes if present # Remove quotes if present
if desc.startswith('"') and desc.endswith('"'): if val.startswith('"') and val.endswith('"'):
desc = desc[1:-1] val = val[1:-1]
elif desc.startswith("'") and desc.endswith("'"): elif val.startswith("'") and val.endswith("'"):
desc = desc[1:-1] val = val[1:-1]
# Handle YAML multi-line indicators (>- or |-) # Handle YAML multi-line indicators (>- or |-)
if desc in (">-", "|-", ">", "|", ""): if val in (">-", "|-", ">", "|", ""):
# Multi-line: read the next indented line if i + 1 < len(lines):
if i + 1 < len(lines): next_line = lines[i + 1].strip()
next_line = lines[i + 1].strip() if next_line and not next_line.startswith("---"):
if next_line and not next_line.startswith("---"): val = next_line
return next_line[:100] else:
elif desc: val = ""
return desc[:100] else:
val = ""
if val:
result[field] = val[:100]
# Fall back to first meaningful line after frontmatter # Fall back to first meaningful line for description
for line in lines[frontmatter_end:]: if not result["description"]:
stripped = line.strip() for line in lines[frontmatter_end:]:
# Skip empty lines, headers, comments, and frontmatter delimiters stripped = line.strip()
if stripped and not stripped.startswith("#") and not stripped.startswith("<!--") and stripped != "---": if stripped and not stripped.startswith("#") and not stripped.startswith("<!--") and stripped != "---":
return stripped[:100] result["description"] = stripped[:100]
break
return "" return result
def _enumerate_codex_skills(self) -> list[dict]: def _enumerate_codex_skills(self) -> list[dict]:
"""Enumerate Codex skills from cache and user directory. """Enumerate Codex skills from cache and user directory.
@@ -161,19 +171,19 @@ class SkillsMixin:
if not skill_dir.is_dir() or skill_dir.name.startswith("."): if not skill_dir.is_dir() or skill_dir.name.startswith("."):
continue continue
description = "" meta = {"name": "", "description": ""}
# Check SKILL.md for description # Check SKILL.md for metadata
skill_md = skill_dir / "SKILL.md" skill_md = skill_dir / "SKILL.md"
if skill_md.exists(): if skill_md.exists():
try: try:
content = skill_md.read_text() content = skill_md.read_text()
description = self._extract_description(content) meta = self._parse_frontmatter(content)
except OSError: except OSError:
pass pass
skills.append({ skills.append({
"name": skill_dir.name, "name": meta["name"] or skill_dir.name,
"description": description or f"User skill: {skill_dir.name}", "description": meta["description"] or f"User skill: {skill_dir.name}",
}) })
return skills return skills

View File

@@ -5,13 +5,52 @@ import subprocess
import time import time
import uuid import uuid
from amc_server.context import ( from amc_server.auth import validate_auth_token
PROJECTS_DIR, SESSIONS_DIR, ZELLIJ_BIN, ZELLIJ_SESSION, from amc_server.config import SESSIONS_DIR
from amc_server.spawn_config import (
PENDING_SPAWNS_DIR, PENDING_SPAWN_TTL,
PROJECTS_DIR,
_spawn_lock, _spawn_timestamps, SPAWN_COOLDOWN_SEC, _spawn_lock, _spawn_timestamps, SPAWN_COOLDOWN_SEC,
validate_auth_token,
) )
from amc_server.zellij import ZELLIJ_BIN, ZELLIJ_SESSION
from amc_server.logging_utils import LOGGER from amc_server.logging_utils import LOGGER
def _write_pending_spawn(spawn_id, project_path, agent_type):
"""Write a pending spawn record for later correlation by discovery.
This enables Codex session correlation since env vars don't propagate
through Zellij's pane spawn mechanism.
"""
PENDING_SPAWNS_DIR.mkdir(parents=True, exist_ok=True)
pending_file = PENDING_SPAWNS_DIR / f'{spawn_id}.json'
data = {
'spawn_id': spawn_id,
'project_path': str(project_path),
'agent_type': agent_type,
'timestamp': time.time(),
}
try:
pending_file.write_text(json.dumps(data))
except OSError:
LOGGER.warning('Failed to write pending spawn file for %s', spawn_id)
def _cleanup_stale_pending_spawns():
"""Remove pending spawn files older than PENDING_SPAWN_TTL."""
if not PENDING_SPAWNS_DIR.exists():
return
now = time.time()
try:
for f in PENDING_SPAWNS_DIR.glob('*.json'):
try:
if now - f.stat().st_mtime > PENDING_SPAWN_TTL:
f.unlink()
except OSError:
continue
except OSError:
pass
# Agent commands (AC-8, AC-9: full autonomous permissions) # Agent commands (AC-8, AC-9: full autonomous permissions)
AGENT_COMMANDS = { AGENT_COMMANDS = {
'claude': ['claude', '--dangerously-skip-permissions'], 'claude': ['claude', '--dangerously-skip-permissions'],
@@ -215,6 +254,16 @@ class SpawnMixin:
def _spawn_agent_in_project_tab(self, project, project_path, agent_type, spawn_id): def _spawn_agent_in_project_tab(self, project, project_path, agent_type, spawn_id):
"""Spawn an agent in a project-named Zellij tab.""" """Spawn an agent in a project-named Zellij tab."""
# Clean up stale pending spawns opportunistically
_cleanup_stale_pending_spawns()
# For Codex, write pending spawn record before launching.
# Zellij doesn't propagate env vars to pane commands, so discovery
# will match the session to this record by CWD + timestamp.
# (Claude doesn't need this - amc-hook writes spawn_id directly)
if agent_type == 'codex':
_write_pending_spawn(spawn_id, project_path, agent_type)
# Check session exists # Check session exists
if not self._check_zellij_session_exists(): if not self._check_zellij_session_exists():
return { return {

View File

@@ -1,7 +1,9 @@
import os import os
from http.server import ThreadingHTTPServer from http.server import ThreadingHTTPServer
from amc_server.context import DATA_DIR, PORT, generate_auth_token, start_projects_watcher from amc_server.auth import generate_auth_token
from amc_server.config import DATA_DIR, PORT
from amc_server.spawn_config import start_projects_watcher
from amc_server.handler import AMCHandler from amc_server.handler import AMCHandler
from amc_server.logging_utils import LOGGER, configure_logging, install_signal_handlers from amc_server.logging_utils import LOGGER, configure_logging, install_signal_handlers
from amc_server.mixins.spawn import load_projects_cache from amc_server.mixins.spawn import load_projects_cache

View File

@@ -0,0 +1,40 @@
"""Spawn feature config: paths, locks, rate limiting, projects watcher."""
import threading
from pathlib import Path
from amc_server.config import DATA_DIR
# Pending spawn registry
PENDING_SPAWNS_DIR = DATA_DIR / "pending_spawns"
# Pending spawn TTL: how long to keep unmatched spawn records (seconds)
PENDING_SPAWN_TTL = 60
# Projects directory for spawning agents
PROJECTS_DIR = Path.home() / 'projects'
# Lock for serializing spawn operations (prevents Zellij race conditions)
_spawn_lock = threading.Lock()
# Rate limiting: track last spawn time per project (prevents spam)
_spawn_timestamps: dict[str, float] = {}
SPAWN_COOLDOWN_SEC = 10.0
def start_projects_watcher():
"""Start background thread to refresh projects cache every 5 minutes."""
import logging
from amc_server.mixins.spawn import load_projects_cache
def _watch_loop():
import time
while True:
try:
time.sleep(300)
load_projects_cache()
except Exception:
logging.exception('Projects cache refresh failed')
thread = threading.Thread(target=_watch_loop, daemon=True)
thread.start()

34
amc_server/zellij.py Normal file
View File

@@ -0,0 +1,34 @@
"""Zellij integration: binary resolution, plugin path, session name, cache."""
import shutil
from pathlib import Path
# Plugin path for zellij-send-keys
ZELLIJ_PLUGIN = Path.home() / ".config" / "zellij" / "plugins" / "zellij-send-keys.wasm"
def _resolve_zellij_bin():
"""Resolve zellij binary even when PATH is minimal (eg launchctl)."""
from_path = shutil.which("zellij")
if from_path:
return from_path
common_paths = (
"/opt/homebrew/bin/zellij", # Apple Silicon Homebrew
"/usr/local/bin/zellij", # Intel Homebrew
"/usr/bin/zellij",
)
for candidate in common_paths:
p = Path(candidate)
if p.exists() and p.is_file():
return str(p)
return "zellij" # Fallback for explicit error reporting by subprocess
ZELLIJ_BIN = _resolve_zellij_bin()
# Default Zellij session for spawning
ZELLIJ_SESSION = 'infra'
# Cache for Zellij session list (avoid calling zellij on every request)
_zellij_cache = {"sessions": None, "expires": 0}

View File

@@ -1,18 +1,18 @@
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
from amc_server.context import _resolve_zellij_bin from amc_server.zellij import _resolve_zellij_bin
class ContextTests(unittest.TestCase): class ContextTests(unittest.TestCase):
def test_resolve_zellij_bin_prefers_which(self): def test_resolve_zellij_bin_prefers_which(self):
with patch("amc_server.context.shutil.which", return_value="/custom/bin/zellij"): with patch("amc_server.zellij.shutil.which", return_value="/custom/bin/zellij"):
self.assertEqual(_resolve_zellij_bin(), "/custom/bin/zellij") self.assertEqual(_resolve_zellij_bin(), "/custom/bin/zellij")
def test_resolve_zellij_bin_falls_back_to_default_name(self): def test_resolve_zellij_bin_falls_back_to_default_name(self):
with patch("amc_server.context.shutil.which", return_value=None), patch( with patch("amc_server.zellij.shutil.which", return_value=None), patch(
"amc_server.context.Path.exists", return_value=False "amc_server.zellij.Path.exists", return_value=False
), patch("amc_server.context.Path.is_file", return_value=False): ), patch("amc_server.zellij.Path.is_file", return_value=False):
self.assertEqual(_resolve_zellij_bin(), "zellij") self.assertEqual(_resolve_zellij_bin(), "zellij")

View File

@@ -336,7 +336,7 @@ class TestDismissSession(unittest.TestCase):
# (if it existed, it would still exist) # (if it existed, it would still exist)
def test_tracks_dismissed_codex_session(self): def test_tracks_dismissed_codex_session(self):
from amc_server.context import _dismissed_codex_ids from amc_server.agents import _dismissed_codex_ids
_dismissed_codex_ids.clear() _dismissed_codex_ids.clear()
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:

View File

@@ -27,7 +27,7 @@ class TestGetCodexPaneInfo(unittest.TestCase):
def setUp(self): def setUp(self):
self.handler = DummyDiscoveryHandler() self.handler = DummyDiscoveryHandler()
# Clear cache before each test # Clear cache before each test
from amc_server.context import _codex_pane_cache from amc_server.agents import _codex_pane_cache
_codex_pane_cache["expires"] = 0 _codex_pane_cache["expires"] = 0
_codex_pane_cache["pid_info"] = {} _codex_pane_cache["pid_info"] = {}
_codex_pane_cache["cwd_map"] = {} _codex_pane_cache["cwd_map"] = {}
@@ -80,7 +80,7 @@ class TestGetCodexPaneInfo(unittest.TestCase):
self.assertEqual(pid_info["12345"]["zellij_session"], "myproject") self.assertEqual(pid_info["12345"]["zellij_session"], "myproject")
def test_cache_used_when_fresh(self): def test_cache_used_when_fresh(self):
from amc_server.context import _codex_pane_cache from amc_server.agents import _codex_pane_cache
_codex_pane_cache["pid_info"] = {"cached": {"pane_id": "1", "zellij_session": "s"}} _codex_pane_cache["pid_info"] = {"cached": {"pane_id": "1", "zellij_session": "s"}}
_codex_pane_cache["cwd_map"] = {"/cached/path": {"session": "s", "pane_id": "1"}} _codex_pane_cache["cwd_map"] = {"/cached/path": {"session": "s", "pane_id": "1"}}
_codex_pane_cache["expires"] = time.time() + 100 _codex_pane_cache["expires"] = time.time() + 100
@@ -203,7 +203,7 @@ class TestDiscoverActiveCodexSessions(unittest.TestCase):
def setUp(self): def setUp(self):
self.handler = DummyDiscoveryHandler() self.handler = DummyDiscoveryHandler()
# Clear caches # Clear caches
from amc_server.context import _codex_transcript_cache, _dismissed_codex_ids from amc_server.agents import _codex_transcript_cache, _dismissed_codex_ids
_codex_transcript_cache.clear() _codex_transcript_cache.clear()
_dismissed_codex_ids.clear() _dismissed_codex_ids.clear()
@@ -236,7 +236,7 @@ class TestDiscoverActiveCodexSessions(unittest.TestCase):
def test_skips_dismissed_sessions(self): def test_skips_dismissed_sessions(self):
"""Sessions in _dismissed_codex_ids should be skipped.""" """Sessions in _dismissed_codex_ids should be skipped."""
from amc_server.context import _dismissed_codex_ids from amc_server.agents import _dismissed_codex_ids
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
codex_dir = Path(tmpdir) codex_dir = Path(tmpdir)

View File

@@ -228,7 +228,7 @@ class TestFindCodexTranscriptFile(unittest.TestCase):
def test_codex_sessions_dir_missing_returns_none(self): def test_codex_sessions_dir_missing_returns_none(self):
with patch("amc_server.mixins.parsing.CODEX_SESSIONS_DIR", Path("/nonexistent")): with patch("amc_server.mixins.parsing.CODEX_SESSIONS_DIR", Path("/nonexistent")):
# Clear cache to force discovery # Clear cache to force discovery
from amc_server.context import _codex_transcript_cache from amc_server.agents import _codex_transcript_cache
_codex_transcript_cache.clear() _codex_transcript_cache.clear()
result = self.handler._find_codex_transcript_file("abc123") result = self.handler._find_codex_transcript_file("abc123")
self.assertIsNone(result) self.assertIsNone(result)
@@ -238,7 +238,7 @@ class TestFindCodexTranscriptFile(unittest.TestCase):
transcript_file = Path(tmpdir) / "abc123.jsonl" transcript_file = Path(tmpdir) / "abc123.jsonl"
transcript_file.write_text('{"type": "session_meta"}\n') transcript_file.write_text('{"type": "session_meta"}\n')
from amc_server.context import _codex_transcript_cache from amc_server.agents import _codex_transcript_cache
_codex_transcript_cache["abc123"] = str(transcript_file) _codex_transcript_cache["abc123"] = str(transcript_file)
result = self.handler._find_codex_transcript_file("abc123") result = self.handler._find_codex_transcript_file("abc123")
@@ -248,7 +248,7 @@ class TestFindCodexTranscriptFile(unittest.TestCase):
_codex_transcript_cache.clear() _codex_transcript_cache.clear()
def test_cache_hit_with_deleted_file_returns_none(self): def test_cache_hit_with_deleted_file_returns_none(self):
from amc_server.context import _codex_transcript_cache from amc_server.agents import _codex_transcript_cache
_codex_transcript_cache["deleted-session"] = "/nonexistent/file.jsonl" _codex_transcript_cache["deleted-session"] = "/nonexistent/file.jsonl"
result = self.handler._find_codex_transcript_file("deleted-session") result = self.handler._find_codex_transcript_file("deleted-session")
@@ -257,7 +257,7 @@ class TestFindCodexTranscriptFile(unittest.TestCase):
_codex_transcript_cache.clear() _codex_transcript_cache.clear()
def test_cache_hit_with_none_returns_none(self): def test_cache_hit_with_none_returns_none(self):
from amc_server.context import _codex_transcript_cache from amc_server.agents import _codex_transcript_cache
_codex_transcript_cache["cached-none"] = None _codex_transcript_cache["cached-none"] = None
result = self.handler._find_codex_transcript_file("cached-none") result = self.handler._find_codex_transcript_file("cached-none")
@@ -557,7 +557,7 @@ class TestGetCachedContextUsage(unittest.TestCase):
def setUp(self): def setUp(self):
self.handler = DummyParsingHandler() self.handler = DummyParsingHandler()
# Clear cache before each test # Clear cache before each test
from amc_server.context import _context_usage_cache from amc_server.agents import _context_usage_cache
_context_usage_cache.clear() _context_usage_cache.clear()
def test_nonexistent_file_returns_none(self): def test_nonexistent_file_returns_none(self):

View File

@@ -290,7 +290,7 @@ class TestRateLimiting(unittest.TestCase):
def test_first_spawn_allowed(self): def test_first_spawn_allowed(self):
"""First spawn for a project should not be rate-limited.""" """First spawn for a project should not be rate-limited."""
from amc_server.context import _spawn_timestamps from amc_server.spawn_config import _spawn_timestamps
_spawn_timestamps.clear() _spawn_timestamps.clear()
handler = self._make_handler('fresh-project') handler = self._make_handler('fresh-project')
@@ -317,7 +317,7 @@ class TestRateLimiting(unittest.TestCase):
def test_rapid_spawn_same_project_rejected(self): def test_rapid_spawn_same_project_rejected(self):
"""Spawning the same project within cooldown returns 429.""" """Spawning the same project within cooldown returns 429."""
from amc_server.context import _spawn_timestamps from amc_server.spawn_config import _spawn_timestamps
_spawn_timestamps.clear() _spawn_timestamps.clear()
# Pretend we just spawned this project # Pretend we just spawned this project
_spawn_timestamps['rapid-project'] = time.monotonic() _spawn_timestamps['rapid-project'] = time.monotonic()
@@ -339,7 +339,7 @@ class TestRateLimiting(unittest.TestCase):
def test_spawn_different_project_allowed(self): def test_spawn_different_project_allowed(self):
"""Spawning a different project while one is on cooldown succeeds.""" """Spawning a different project while one is on cooldown succeeds."""
from amc_server.context import _spawn_timestamps from amc_server.spawn_config import _spawn_timestamps
_spawn_timestamps.clear() _spawn_timestamps.clear()
_spawn_timestamps['project-a'] = time.monotonic() _spawn_timestamps['project-a'] = time.monotonic()
@@ -360,7 +360,7 @@ class TestRateLimiting(unittest.TestCase):
def test_spawn_after_cooldown_allowed(self): def test_spawn_after_cooldown_allowed(self):
"""Spawning the same project after cooldown expires succeeds.""" """Spawning the same project after cooldown expires succeeds."""
from amc_server.context import _spawn_timestamps, SPAWN_COOLDOWN_SEC from amc_server.spawn_config import _spawn_timestamps, SPAWN_COOLDOWN_SEC
_spawn_timestamps.clear() _spawn_timestamps.clear()
# Set timestamp far enough in the past # Set timestamp far enough in the past
_spawn_timestamps['cooled-project'] = time.monotonic() - SPAWN_COOLDOWN_SEC - 1 _spawn_timestamps['cooled-project'] = time.monotonic() - SPAWN_COOLDOWN_SEC - 1