Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions coconut.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

from core import config, cache, classifier, llm
from core.health import HealthWriter
from core.logrotate import RotatingLog
from core.ratelimit import RateLimiter
from adapters.base import Message

Expand Down Expand Up @@ -51,8 +52,9 @@ def _init_log_file(data_dir):
log_path = os.environ.get('COCONUT_LOG_FILE', '')
if not log_path:
log_path = os.path.join(data_dir, 'coconut.log')
os.makedirs(os.path.dirname(log_path) or '.', exist_ok=True)
_log_file = open(log_path, 'a')
max_bytes = int(os.environ.get('COCONUT_LOG_MAX_BYTES', 5 * 1024 * 1024))
backups = int(os.environ.get('COCONUT_LOG_BACKUPS', 3))
_log_file = RotatingLog(log_path, max_bytes=max_bytes, backups=backups)


def _load_adapters(cfg):
Expand Down
5 changes: 5 additions & 0 deletions config/coconut.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ COCONUT_RATE_LIMIT_ENABLED=true
COCONUT_RATE_LIMIT_WINDOW=60
COCONUT_RATE_LIMIT_MAX=10

# ── Logging ─────────────────────────────────────────────────────
# COCONUT_LOG_FILE="" # default: data/coconut.log
COCONUT_LOG_MAX_BYTES=5242880 # 5MB — rotate when exceeded
COCONUT_LOG_BACKUPS=3 # keep 3 rotated backups (.log.1, .log.2, .log.3)

# ── Persona ───────────────────────────────────────────────────────
COCONUT_SYSTEM_PROMPT_FILE="config/system-prompt.md"

Expand Down
80 changes: 80 additions & 0 deletions core/logrotate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""Size-based log rotation — stdlib only.

Rotates coconut.log when it exceeds a configurable max size.
Keeps N backup files (coconut.log.1, coconut.log.2, ...).

Config (env vars):
COCONUT_LOG_MAX_BYTES — max log size before rotation (default 5MB)
COCONUT_LOG_BACKUPS — number of backup files to keep (default 3)
"""
import os


DEFAULT_MAX_BYTES = 5 * 1024 * 1024 # 5MB
DEFAULT_BACKUPS = 3


class RotatingLog:
"""File-like object that rotates on size threshold."""

def __init__(self, path, max_bytes=None, backups=None):
self.path = path
self.max_bytes = max_bytes or DEFAULT_MAX_BYTES
self.backups = backups if backups is not None else DEFAULT_BACKUPS
self._file = None
self._size = 0
self._open()

def _open(self):
"""Open (or reopen) the log file for appending."""
os.makedirs(os.path.dirname(self.path) or '.', exist_ok=True)
self._file = open(self.path, 'a')
try:
self._size = os.path.getsize(self.path)
except OSError:
self._size = 0

def write(self, data):
"""Write data, rotating if size threshold exceeded."""
if self._size + len(data) > self.max_bytes:
self._rotate()
self._file.write(data)
self._size += len(data)

def flush(self):
if self._file:
self._file.flush()

def close(self):
if self._file:
self._file.close()
self._file = None

def _rotate(self):
"""Rotate log files: .log -> .log.1 -> .log.2 -> ..."""
self.close()

if self.backups <= 0:
# No backups — just truncate
if os.path.exists(self.path):
os.remove(self.path)
self._open()
return

# Remove oldest backup if at limit
oldest = f'{self.path}.{self.backups}'
if os.path.exists(oldest):
os.remove(oldest)

# Shift existing backups up by one
for i in range(self.backups - 1, 0, -1):
src = f'{self.path}.{i}'
dst = f'{self.path}.{i + 1}'
if os.path.exists(src):
os.replace(src, dst)

# Current log becomes .1
if os.path.exists(self.path):
os.replace(self.path, f'{self.path}.1')

self._open()
124 changes: 124 additions & 0 deletions scripts/test/test-logrotate.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
#!/usr/bin/env bash
# T033: Log rotation tests
set -euo pipefail
cd "$(dirname "$0")/../.."

PASS=0; FAIL=0
pass() { PASS=$((PASS+1)); echo " PASS: $1"; }
fail() { FAIL=$((FAIL+1)); echo " FAIL: $1"; }

echo "=== Log Rotation Tests ==="

# Test 1: RotatingLog creates file and writes
echo "Test 1: Basic write..."
python3 -c "
import os, tempfile, shutil
from core.logrotate import RotatingLog

d = tempfile.mkdtemp()
try:
log = RotatingLog(os.path.join(d, 'test.log'), max_bytes=1000, backups=2)
log.write('hello\n')
log.flush()
assert os.path.exists(os.path.join(d, 'test.log'))
with open(os.path.join(d, 'test.log')) as f:
assert f.read() == 'hello\n'
log.close()
print('OK')
finally:
shutil.rmtree(d)
" && pass "basic write" || fail "basic write"

# Test 2: Rotation triggers at size threshold
echo "Test 2: Rotation triggers..."
python3 -c "
import os, tempfile, shutil
from core.logrotate import RotatingLog

d = tempfile.mkdtemp()
try:
log = RotatingLog(os.path.join(d, 'test.log'), max_bytes=100, backups=2)
# Write ~80 chars
log.write('A' * 80 + '\n')
log.flush()

# Write another ~80 — should trigger rotation
log.write('B' * 80 + '\n')
log.flush()
assert os.path.exists(os.path.join(d, 'test.log.1')), 'test.log.1 not created'
# .1 should have the old content (A's)
with open(os.path.join(d, 'test.log.1')) as f:
assert 'A' * 80 in f.read()
# Current log should have new content (B's)
with open(os.path.join(d, 'test.log')) as f:
assert 'B' * 80 in f.read()
log.close()
print('OK')
finally:
shutil.rmtree(d)
" && pass "rotation triggers" || fail "rotation triggers"

# Test 3: Backup limit respected
echo "Test 3: Backup limit..."
python3 -c "
import os, tempfile, shutil
from core.logrotate import RotatingLog

d = tempfile.mkdtemp()
try:
log = RotatingLog(os.path.join(d, 'test.log'), max_bytes=50, backups=2)
for i in range(5):
log.write(f'round-{i}-' + 'X' * 45 + '\n')
log.flush()
log.close()

# Should have current + .1 + .2 only (backups=2)
assert os.path.exists(os.path.join(d, 'test.log'))
assert os.path.exists(os.path.join(d, 'test.log.1'))
assert os.path.exists(os.path.join(d, 'test.log.2'))
assert not os.path.exists(os.path.join(d, 'test.log.3')), '.log.3 should not exist'
print('OK')
finally:
shutil.rmtree(d)
" && pass "backup limit" || fail "backup limit"

# Test 4: Zero backups = no rotation files kept
echo "Test 4: Zero backups..."
python3 -c "
import os, tempfile, shutil
from core.logrotate import RotatingLog

d = tempfile.mkdtemp()
try:
log = RotatingLog(os.path.join(d, 'test.log'), max_bytes=50, backups=0)
log.write('A' * 60 + '\n')
log.flush()
log.write('B' * 60 + '\n')
log.flush()
log.close()

assert os.path.exists(os.path.join(d, 'test.log'))
assert not os.path.exists(os.path.join(d, 'test.log.1')), 'no backups should exist'
print('OK')
finally:
shutil.rmtree(d)
" && pass "zero backups" || fail "zero backups"

# Test 5: Integration — coconut.py imports RotatingLog
echo "Test 5: Integration import..."
python3 -c "
import ast
with open('coconut.py') as f:
tree = ast.parse(f.read())
imports = [n for n in ast.walk(tree) if isinstance(n, ast.ImportFrom)]
found = any(
n.module == 'core.logrotate' and any(a.name == 'RotatingLog' for a in n.names)
for n in imports
)
assert found, 'coconut.py must import RotatingLog from core.logrotate'
print('OK')
" && pass "integration import" || fail "integration import"

echo ""
echo "=== Results: $PASS passed, $FAIL failed ==="
[ "$FAIL" -eq 0 ] || exit 1
Loading