mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
Feat: transcript retention (#123)
* feat(retention): prune old agent transcripts + panel-tunable setting
Agents write a {session-id}.jsonl per spawn under ~/.claude/projects; nothing
ever deleted them, so the operator's bind-mounted ~/.claude grew without bound.
Add a throttled orchestrator sweep that prunes agent-owned transcripts (the
shared -app dir + per-workspace dirs) older than a retention window — and ONLY
agent-owned dirs, never the operator's own Claude sessions (proven by the
temp-dir selection tests). The window is panel-tunable: a new system_settings
key-value table (migration 027) holds transcript_retention_days, read via
SettingsService with the roboco.config default (14d) as the fallback, exposed
through GET/PUT /api/settings. Panel wiring follows.
* feat(panel): add a panel-tunable transcript retention control
Wire the settings page to the /api/settings backend: a settings API client and
a self-contained Transcript Retention card (React Query) that loads
transcript_retention_days and saves it back, with client-side validation. The
existing settings controls are unchanged.
---------
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -33,6 +33,7 @@ from roboco.api.routes.project import router as project_router
|
||||
from roboco.api.routes.prompter_live import router as prompter_live_router
|
||||
from roboco.api.routes.provider import router as provider_router
|
||||
from roboco.api.routes.sessions import router as sessions_router
|
||||
from roboco.api.routes.settings import router as settings_router
|
||||
from roboco.api.routes.stream import router as stream_router
|
||||
from roboco.api.routes.system import router as system_router
|
||||
from roboco.api.routes.tasks import router as tasks_router
|
||||
@@ -221,6 +222,12 @@ def create_app() -> FastAPI:
|
||||
tags=["Sessions"],
|
||||
)
|
||||
|
||||
app.include_router(
|
||||
settings_router,
|
||||
prefix=f"{api_prefix}/settings",
|
||||
tags=["Settings"],
|
||||
)
|
||||
|
||||
app.include_router(
|
||||
messages_router,
|
||||
prefix=f"{api_prefix}/messages",
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""System settings API — read and update runtime-editable settings.
|
||||
|
||||
Backs the panel settings page. Values persist in the ``system_settings`` table
|
||||
and are read by the backend (e.g. the transcript-retention prune sweep) with a
|
||||
``roboco.config`` default as the fallback when a key is unset.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from roboco.api.deps import DbSession
|
||||
from roboco.api.schemas.settings import SettingsResponse, SettingUpdate
|
||||
from roboco.services.settings import SettingValidationError, get_settings_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=SettingsResponse)
|
||||
async def list_settings(db: DbSession) -> SettingsResponse:
|
||||
"""Return every stored runtime-editable setting."""
|
||||
return SettingsResponse(settings=await get_settings_service(db).all())
|
||||
|
||||
|
||||
@router.put("/{key}", response_model=SettingsResponse)
|
||||
async def update_setting(
|
||||
key: str, data: SettingUpdate, db: DbSession
|
||||
) -> SettingsResponse:
|
||||
"""Validate and persist a single setting, returning the full updated map."""
|
||||
service = get_settings_service(db)
|
||||
try:
|
||||
await service.set(key, data.value)
|
||||
except SettingValidationError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)
|
||||
) from exc
|
||||
# Write route commits explicitly (get_db auto-commit is unreliable).
|
||||
await db.commit()
|
||||
return SettingsResponse(settings=await service.all())
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Schemas for the system-settings API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SettingUpdate(BaseModel):
|
||||
"""Body for PUT /settings/{key} — the new value (stored as text)."""
|
||||
|
||||
value: str = Field(..., description="New value for the setting, stored as text")
|
||||
|
||||
|
||||
class SettingsResponse(BaseModel):
|
||||
"""All runtime-editable settings as a flat key→value map."""
|
||||
|
||||
settings: dict[str, str] = Field(default_factory=dict)
|
||||
@@ -244,6 +244,37 @@ class Settings(BaseSettings):
|
||||
),
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# Transcript retention (agent Claude Code transcripts under ~/.claude)
|
||||
# ==========================================================================
|
||||
transcript_retention_days: int = Field(
|
||||
default=14,
|
||||
ge=1,
|
||||
description=(
|
||||
"Default retention window, in days, for agent Claude Code "
|
||||
"transcripts (the *.jsonl files agents write under "
|
||||
"~/.claude/projects). A background sweep prunes agent-owned "
|
||||
"transcripts older than this. Panel-editable: a stored "
|
||||
"`transcript_retention_days` system setting overrides this default "
|
||||
"when present; this is the fallback used before one is set."
|
||||
),
|
||||
)
|
||||
transcript_prune_enabled: bool = Field(
|
||||
default=True,
|
||||
description=(
|
||||
"Whether the orchestrator background sweep prunes old agent "
|
||||
"transcripts. Disable to keep every transcript indefinitely."
|
||||
),
|
||||
)
|
||||
transcript_prune_interval_seconds: int = Field(
|
||||
default=3600,
|
||||
ge=300,
|
||||
description=(
|
||||
"Minimum seconds between transcript-retention prune passes. The "
|
||||
"prune is age-based (days), so it need not run more than hourly."
|
||||
),
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# Git command execution
|
||||
# ==========================================================================
|
||||
|
||||
@@ -1736,6 +1736,27 @@ class ProviderConfigTable(Base):
|
||||
__table_args__ = (Index("ix_provider_configs_enabled", "enabled"),)
|
||||
|
||||
|
||||
class SystemSettingTable(Base):
|
||||
"""Key-value store for runtime-editable, panel-tunable system settings.
|
||||
|
||||
Operator-tunable values that must persist across restarts and be editable
|
||||
from the panel (first user: ``transcript_retention_days``). One row per key;
|
||||
the value is stored as text and parsed by the reader. Code defaults in
|
||||
``roboco.config`` are the fallback when a key has no row yet.
|
||||
"""
|
||||
|
||||
__tablename__ = "system_settings"
|
||||
|
||||
key: Mapped[str] = mapped_column(String(100), primary_key=True)
|
||||
value: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(UTC),
|
||||
onupdate=lambda: datetime.now(UTC),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
class ModelAssignmentTable(Base):
|
||||
"""SQLAlchemy table for (scope, provider, model) routing rows.
|
||||
|
||||
|
||||
@@ -561,6 +561,8 @@ class AgentOrchestrator:
|
||||
self._health_task: asyncio.Task | None = None
|
||||
self._dispatcher_task: asyncio.Task | None = None
|
||||
self._sweeper_task: asyncio.Task | None = None
|
||||
# Last time the transcript-retention prune ran (throttled in the sweep).
|
||||
self._last_transcript_prune: datetime | None = None
|
||||
# Rate-limit probe loop: 30-second interval, scans Redis for all
|
||||
# rate-limited providers and resolves waiting agents on success.
|
||||
self._rate_limit_probe_task: asyncio.Task | None = None
|
||||
@@ -3915,6 +3917,65 @@ Start by:
|
||||
await self._sweep_token_snapshots()
|
||||
await self._sweep_daily_rollup()
|
||||
|
||||
# Prune old agent transcripts (throttled internally to ~hourly) so the
|
||||
# operator's bind-mounted ~/.claude doesn't grow without bound.
|
||||
await self._sweep_transcript_retention()
|
||||
|
||||
async def _sweep_transcript_retention(self) -> None:
|
||||
"""Prune agent transcripts older than the retention window.
|
||||
|
||||
Throttled to ``settings.transcript_prune_interval_seconds``. Reads the
|
||||
window from the ``system_settings`` store (panel-editable), falling back
|
||||
to ``settings.transcript_retention_days``. Only agent-owned project dirs
|
||||
(``-app`` + per-workspace dirs) are touched — never the operator's own
|
||||
Claude sessions. Best-effort: any failure is logged, never raised.
|
||||
"""
|
||||
if not settings.transcript_prune_enabled:
|
||||
return
|
||||
now = datetime.now(UTC)
|
||||
last = self._last_transcript_prune
|
||||
if (
|
||||
last is not None
|
||||
and (now - last).total_seconds()
|
||||
< settings.transcript_prune_interval_seconds
|
||||
):
|
||||
return
|
||||
self._last_transcript_prune = now
|
||||
|
||||
retention_days = settings.transcript_retention_days
|
||||
with contextlib.suppress(Exception):
|
||||
from roboco.db.base import get_session_factory
|
||||
from roboco.services.settings import get_settings_service
|
||||
|
||||
session_factory = get_session_factory()
|
||||
async with session_factory() as db:
|
||||
retention_days = await get_settings_service(db).get_int(
|
||||
"transcript_retention_days", settings.transcript_retention_days
|
||||
)
|
||||
|
||||
from roboco.runtime.transcript_retention import select_prunable_transcripts
|
||||
|
||||
projects_root = Path.home() / ".claude" / "projects"
|
||||
cutoff = (now - timedelta(days=retention_days)).timestamp()
|
||||
prunable = select_prunable_transcripts(
|
||||
projects_root, settings.workspaces_root, cutoff
|
||||
)
|
||||
pruned = 0
|
||||
for transcript in prunable:
|
||||
try:
|
||||
transcript.unlink()
|
||||
pruned += 1
|
||||
except OSError as exc:
|
||||
logger.debug(
|
||||
"Transcript prune failed", path=str(transcript), error=str(exc)
|
||||
)
|
||||
if pruned:
|
||||
logger.info(
|
||||
"Pruned old agent transcripts",
|
||||
count=pruned,
|
||||
retention_days=retention_days,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _fetch_budget_status(
|
||||
client: httpx.AsyncClient, url: str, agent_id: str
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Agent transcript retention — select old Claude Code transcripts to prune.
|
||||
|
||||
Agents write a ``{session-id}.jsonl`` transcript per spawn under
|
||||
``~/.claude/projects/<encoded-cwd>/``. Review/coordinate roles share the
|
||||
``-app`` dir; authoring roles get a per-workspace dir under the workspaces
|
||||
root. Nothing ever deleted them, so the host ``~/.claude`` grows unbounded — and
|
||||
it is the operator's *real* ``~/.claude`` via the agent bind mount. This module
|
||||
selects agent-owned transcripts older than the retention window, and ONLY
|
||||
agent-owned dirs, never the operator's own Claude sessions.
|
||||
|
||||
The selection is a pure function so the deletion target is unit-testable against
|
||||
a temp dir before it ever runs on a real home directory.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def is_agent_owned_dir(dir_name: str, workspaces_root: str) -> bool:
|
||||
"""True if a ``~/.claude/projects`` subdir was written by a spawned agent.
|
||||
|
||||
Agents run either at the image WORKDIR ``/app`` (review/coordinate roles →
|
||||
the shared ``-app`` dir) or in a per-agent clone under the workspaces root
|
||||
(authoring roles). Claude Code encodes the cwd into the dir name by replacing
|
||||
``/`` with ``-``, so those dirs are ``-app`` and anything starting with the
|
||||
encoded workspaces root (e.g. ``-data-workspaces``). The operator's own
|
||||
sessions live under their real cwd (``-Users-…``, ``-home-…``) and are never
|
||||
matched.
|
||||
"""
|
||||
if dir_name == "-app":
|
||||
return True
|
||||
encoded_root = workspaces_root.rstrip("/").replace("/", "-")
|
||||
return bool(encoded_root) and dir_name.startswith(encoded_root)
|
||||
|
||||
|
||||
def select_prunable_transcripts(
|
||||
projects_root: Path, workspaces_root: str, cutoff_epoch: float
|
||||
) -> list[Path]:
|
||||
"""Agent-owned ``*.jsonl`` transcripts last modified before ``cutoff_epoch``.
|
||||
|
||||
Only files inside agent-owned project dirs are considered; directories and
|
||||
the operator's own session dirs are never returned. Missing/unreadable
|
||||
entries are skipped rather than raising.
|
||||
"""
|
||||
if not projects_root.is_dir():
|
||||
return []
|
||||
prunable: list[Path] = []
|
||||
for child in sorted(projects_root.iterdir()):
|
||||
if not child.is_dir() or not is_agent_owned_dir(child.name, workspaces_root):
|
||||
continue
|
||||
for transcript in child.glob("*.jsonl"):
|
||||
try:
|
||||
if transcript.is_file() and transcript.stat().st_mtime < cutoff_epoch:
|
||||
prunable.append(transcript)
|
||||
except OSError:
|
||||
continue
|
||||
return prunable
|
||||
@@ -0,0 +1,90 @@
|
||||
"""System settings service — runtime-editable, panel-tunable config.
|
||||
|
||||
Reads/writes the ``system_settings`` key-value table. Values are stored as text
|
||||
and parsed by typed accessors. Code defaults in ``roboco.config`` are the
|
||||
fallback used when a key has no row yet. Only keys in ``KNOWN_SETTINGS`` are
|
||||
writable, each with a validator, so the panel can't persist junk.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from roboco.db.tables import SystemSettingTable
|
||||
from roboco.services.base import BaseService
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
class SettingValidationError(ValueError):
|
||||
"""Raised when a setting key is unknown or its value is invalid."""
|
||||
|
||||
|
||||
def _validate_retention_days(value: str) -> None:
|
||||
try:
|
||||
days = int(value)
|
||||
except ValueError as exc:
|
||||
raise SettingValidationError(
|
||||
"transcript_retention_days must be an integer"
|
||||
) from exc
|
||||
if days < 1:
|
||||
raise SettingValidationError("transcript_retention_days must be >= 1")
|
||||
|
||||
|
||||
# Writable settings: key -> validator. Keys absent here are rejected on write so
|
||||
# the panel can only persist values the backend understands.
|
||||
_VALIDATORS = {
|
||||
"transcript_retention_days": _validate_retention_days,
|
||||
}
|
||||
|
||||
|
||||
def validate_setting(key: str, value: str) -> None:
|
||||
"""Raise SettingValidationError if ``key`` is not writable or ``value`` invalid."""
|
||||
validator = _VALIDATORS.get(key)
|
||||
if validator is None:
|
||||
raise SettingValidationError(f"Unknown or read-only setting: {key}")
|
||||
validator(value)
|
||||
|
||||
|
||||
class SettingsService(BaseService):
|
||||
"""CRUD for the ``system_settings`` key-value store."""
|
||||
|
||||
async def get(self, key: str) -> str | None:
|
||||
"""Return the stored value for ``key``, or None if unset."""
|
||||
result = await self.session.execute(
|
||||
select(SystemSettingTable.value).where(SystemSettingTable.key == key)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_int(self, key: str, default: int) -> int:
|
||||
"""Return ``key`` parsed as int, or ``default`` if unset/unparseable."""
|
||||
raw = await self.get(key)
|
||||
if raw is None:
|
||||
return default
|
||||
try:
|
||||
return int(raw)
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
async def set(self, key: str, value: str) -> None:
|
||||
"""Validate then upsert ``key`` = ``value``. Caller commits."""
|
||||
validate_setting(key, value)
|
||||
existing = await self.session.get(SystemSettingTable, key)
|
||||
if existing is None:
|
||||
self.session.add(SystemSettingTable(key=key, value=value))
|
||||
else:
|
||||
existing.value = value
|
||||
await self.session.flush()
|
||||
|
||||
async def all(self) -> dict[str, str]:
|
||||
"""Return every stored setting as a ``{key: value}`` map."""
|
||||
result = await self.session.execute(select(SystemSettingTable))
|
||||
return {row.key: row.value for row in result.scalars().all()}
|
||||
|
||||
|
||||
def get_settings_service(session: AsyncSession) -> SettingsService:
|
||||
"""Construct a SettingsService bound to ``session``."""
|
||||
return SettingsService(session)
|
||||
Reference in New Issue
Block a user