fix: split read-only data volume to allow state file writes (#85)
All checks were successful
Build Docker Image / build (pull_request) Successful in 57s

The data/ volume was mounted :ro to protect Google Sheets credentials,
but this also prevented all state trackers from persisting JSON files
(scorecards, voice channels, trade channels, soak data), causing silent
save failures and stale data accumulating across restarts.

- Mount only the credentials file as :ro (file-level mount)
- Add a separate :rw storage/ volume for runtime state files
- Move all tracker default paths from data/ to storage/
- Add STATE_HOST_PATH env var (defaults to ./storage)
- Update SHEETS_CREDENTIALS_HOST_PATH semantics: now a file path
  (e.g. ./data/major-domo-service-creds.json) instead of a directory
- Add storage/ to .gitignore

Closes #85

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-03-17 13:34:43 -05:00
parent 6c49233392
commit 03dd449551
7 changed files with 189 additions and 91 deletions

1
.gitignore vendored
View File

@ -218,5 +218,6 @@ __marimo__/
# Project-specific
data/
storage/
production_logs/
*.json

View File

@ -24,7 +24,7 @@ class ScorecardTracker:
- Timestamp tracking for monitoring
"""
def __init__(self, data_file: str = "data/scorecards.json"):
def __init__(self, data_file: str = "storage/scorecards.json"):
"""
Initialize the scorecard tracker.

View File

@ -3,13 +3,14 @@ Soak Tracker
Provides persistent tracking of "soak" mentions using JSON file storage.
"""
import json
import logging
from datetime import datetime, timedelta, UTC
from pathlib import Path
from typing import Dict, List, Optional, Any
logger = logging.getLogger(f'{__name__}.SoakTracker')
logger = logging.getLogger(f"{__name__}.SoakTracker")
class SoakTracker:
@ -22,7 +23,7 @@ class SoakTracker:
- Time-based calculations for disappointment tiers
"""
def __init__(self, data_file: str = "data/soak_data.json"):
def __init__(self, data_file: str = "storage/soak_data.json"):
"""
Initialize the soak tracker.
@ -38,28 +39,22 @@ class SoakTracker:
"""Load soak data from JSON file."""
try:
if self.data_file.exists():
with open(self.data_file, 'r') as f:
with open(self.data_file, "r") as f:
self._data = json.load(f)
logger.debug(f"Loaded soak data: {self._data.get('total_count', 0)} total soaks")
logger.debug(
f"Loaded soak data: {self._data.get('total_count', 0)} total soaks"
)
else:
self._data = {
"last_soak": None,
"total_count": 0,
"history": []
}
self._data = {"last_soak": None, "total_count": 0, "history": []}
logger.info("No existing soak data found, starting fresh")
except Exception as e:
logger.error(f"Failed to load soak data: {e}")
self._data = {
"last_soak": None,
"total_count": 0,
"history": []
}
self._data = {"last_soak": None, "total_count": 0, "history": []}
def save_data(self) -> None:
"""Save soak data to JSON file."""
try:
with open(self.data_file, 'w') as f:
with open(self.data_file, "w") as f:
json.dump(self._data, f, indent=2, default=str)
logger.debug("Soak data saved successfully")
except Exception as e:
@ -71,7 +66,7 @@ class SoakTracker:
username: str,
display_name: str,
channel_id: int,
message_id: int
message_id: int,
) -> None:
"""
Record a new soak mention.
@ -89,7 +84,7 @@ class SoakTracker:
"username": username,
"display_name": display_name,
"channel_id": str(channel_id),
"message_id": str(message_id)
"message_id": str(message_id),
}
# Update last_soak
@ -110,7 +105,9 @@ class SoakTracker:
self.save_data()
logger.info(f"Recorded soak by {username} (ID: {user_id}) in channel {channel_id}")
logger.info(
f"Recorded soak by {username} (ID: {user_id}) in channel {channel_id}"
)
def get_last_soak(self) -> Optional[Dict[str, Any]]:
"""
@ -135,10 +132,12 @@ class SoakTracker:
try:
# Parse ISO format timestamp
last_timestamp_str = last_soak["timestamp"]
if last_timestamp_str.endswith('Z'):
last_timestamp_str = last_timestamp_str[:-1] + '+00:00'
if last_timestamp_str.endswith("Z"):
last_timestamp_str = last_timestamp_str[:-1] + "+00:00"
last_timestamp = datetime.fromisoformat(last_timestamp_str.replace('Z', '+00:00'))
last_timestamp = datetime.fromisoformat(
last_timestamp_str.replace("Z", "+00:00")
)
# Ensure both times are timezone-aware
if last_timestamp.tzinfo is None:

View File

@ -3,6 +3,7 @@ Trade Channel Tracker
Provides persistent tracking of bot-created trade discussion channels using JSON file storage.
"""
import json
from datetime import datetime, UTC
from pathlib import Path
@ -12,7 +13,7 @@ import discord
from utils.logging import get_contextual_logger
logger = get_contextual_logger(f'{__name__}.TradeChannelTracker')
logger = get_contextual_logger(f"{__name__}.TradeChannelTracker")
class TradeChannelTracker:
@ -26,7 +27,7 @@ class TradeChannelTracker:
- Automatic stale entry removal
"""
def __init__(self, data_file: str = "data/trade_channels.json"):
def __init__(self, data_file: str = "storage/trade_channels.json"):
"""
Initialize the trade channel tracker.
@ -42,9 +43,11 @@ class TradeChannelTracker:
"""Load channel data from JSON file."""
try:
if self.data_file.exists():
with open(self.data_file, 'r') as f:
with open(self.data_file, "r") as f:
self._data = json.load(f)
logger.debug(f"Loaded {len(self._data.get('trade_channels', {}))} tracked trade channels")
logger.debug(
f"Loaded {len(self._data.get('trade_channels', {}))} tracked trade channels"
)
else:
self._data = {"trade_channels": {}}
logger.info("No existing trade channel data found, starting fresh")
@ -55,7 +58,7 @@ class TradeChannelTracker:
def save_data(self) -> None:
"""Save channel data to JSON file."""
try:
with open(self.data_file, 'w') as f:
with open(self.data_file, "w") as f:
json.dump(self._data, f, indent=2, default=str)
logger.debug("Trade channel data saved successfully")
except Exception as e:
@ -67,7 +70,7 @@ class TradeChannelTracker:
trade_id: str,
team1_abbrev: str,
team2_abbrev: str,
creator_id: int
creator_id: int,
) -> None:
"""
Add a new trade channel to tracking.
@ -87,10 +90,12 @@ class TradeChannelTracker:
"team1_abbrev": team1_abbrev,
"team2_abbrev": team2_abbrev,
"created_at": datetime.now(UTC).isoformat(),
"creator_id": str(creator_id)
"creator_id": str(creator_id),
}
self.save_data()
logger.info(f"Added trade channel to tracking: {channel.name} (ID: {channel.id}, Trade: {trade_id})")
logger.info(
f"Added trade channel to tracking: {channel.name} (ID: {channel.id}, Trade: {trade_id})"
)
def remove_channel(self, channel_id: int) -> None:
"""
@ -108,7 +113,9 @@ class TradeChannelTracker:
channel_name = channel_data["name"]
del channels[channel_key]
self.save_data()
logger.info(f"Removed trade channel from tracking: {channel_name} (ID: {channel_id}, Trade: {trade_id})")
logger.info(
f"Removed trade channel from tracking: {channel_name} (ID: {channel_id}, Trade: {trade_id})"
)
def get_channel_by_trade_id(self, trade_id: str) -> Optional[Dict[str, Any]]:
"""
@ -175,7 +182,9 @@ class TradeChannelTracker:
channel_name = channels[channel_id_str].get("name", "unknown")
trade_id = channels[channel_id_str].get("trade_id", "unknown")
del channels[channel_id_str]
logger.info(f"Removed stale tracking entry: {channel_name} (ID: {channel_id_str}, Trade: {trade_id})")
logger.info(
f"Removed stale tracking entry: {channel_name} (ID: {channel_id_str}, Trade: {trade_id})"
)
if stale_entries:
self.save_data()

View File

@ -3,6 +3,7 @@ Voice Channel Cleanup Service
Provides automatic cleanup of empty voice channels with restart resilience.
"""
import logging
import discord
@ -12,7 +13,7 @@ from .tracker import VoiceChannelTracker
from commands.gameplay.scorecard_tracker import ScorecardTracker
from utils.logging import get_contextual_logger
logger = logging.getLogger(f'{__name__}.VoiceChannelCleanupService')
logger = logging.getLogger(f"{__name__}.VoiceChannelCleanupService")
class VoiceChannelCleanupService:
@ -27,7 +28,9 @@ class VoiceChannelCleanupService:
- Automatic scorecard unpublishing when voice channel is cleaned up
"""
def __init__(self, bot: commands.Bot, data_file: str = "data/voice_channels.json"):
def __init__(
self, bot: commands.Bot, data_file: str = "storage/voice_channels.json"
):
"""
Initialize the cleanup service.
@ -36,10 +39,10 @@ class VoiceChannelCleanupService:
data_file: Path to the JSON data file for persistence
"""
self.bot = bot
self.logger = get_contextual_logger(f'{__name__}.VoiceChannelCleanupService')
self.logger = get_contextual_logger(f"{__name__}.VoiceChannelCleanupService")
self.tracker = VoiceChannelTracker(data_file)
self.scorecard_tracker = ScorecardTracker()
self.empty_threshold = 5 # Delete after 5 minutes empty
self.empty_threshold = 5 # Delete after 5 minutes empty
# Start the cleanup task - @before_loop will wait for bot readiness
self.cleanup_loop.start()
@ -90,13 +93,17 @@ class VoiceChannelCleanupService:
guild = bot.get_guild(guild_id)
if not guild:
self.logger.warning(f"Guild {guild_id} not found, removing channel {channel_data['name']}")
self.logger.warning(
f"Guild {guild_id} not found, removing channel {channel_data['name']}"
)
channels_to_remove.append(channel_id)
continue
channel = guild.get_channel(channel_id)
if not channel:
self.logger.warning(f"Channel {channel_data['name']} (ID: {channel_id}) no longer exists")
self.logger.warning(
f"Channel {channel_data['name']} (ID: {channel_id}) no longer exists"
)
channels_to_remove.append(channel_id)
continue
@ -121,18 +128,26 @@ class VoiceChannelCleanupService:
if channel_data and channel_data.get("text_channel_id"):
try:
text_channel_id_int = int(channel_data["text_channel_id"])
was_unpublished = self.scorecard_tracker.unpublish_scorecard(text_channel_id_int)
was_unpublished = self.scorecard_tracker.unpublish_scorecard(
text_channel_id_int
)
if was_unpublished:
self.logger.info(f"📋 Unpublished scorecard from text channel {text_channel_id_int} (stale voice channel)")
self.logger.info(
f"📋 Unpublished scorecard from text channel {text_channel_id_int} (stale voice channel)"
)
except (ValueError, TypeError) as e:
self.logger.warning(f"Invalid text_channel_id in stale voice channel data: {e}")
self.logger.warning(
f"Invalid text_channel_id in stale voice channel data: {e}"
)
# Also clean up any additional stale entries
stale_removed = self.tracker.cleanup_stale_entries(valid_channel_ids)
total_removed = len(channels_to_remove) + stale_removed
if total_removed > 0:
self.logger.info(f"Cleaned up {total_removed} stale channel tracking entries")
self.logger.info(
f"Cleaned up {total_removed} stale channel tracking entries"
)
self.logger.info(f"Verified {len(valid_channel_ids)} valid tracked channels")
@ -149,10 +164,14 @@ class VoiceChannelCleanupService:
await self.update_all_channel_statuses(bot)
# Get channels ready for cleanup
channels_for_cleanup = self.tracker.get_channels_for_cleanup(self.empty_threshold)
channels_for_cleanup = self.tracker.get_channels_for_cleanup(
self.empty_threshold
)
if channels_for_cleanup:
self.logger.info(f"Found {len(channels_for_cleanup)} channels ready for cleanup")
self.logger.info(
f"Found {len(channels_for_cleanup)} channels ready for cleanup"
)
# Delete empty channels
for channel_data in channels_for_cleanup:
@ -182,12 +201,16 @@ class VoiceChannelCleanupService:
guild = bot.get_guild(guild_id)
if not guild:
self.logger.debug(f"Guild {guild_id} not found for channel {channel_data['name']}")
self.logger.debug(
f"Guild {guild_id} not found for channel {channel_data['name']}"
)
return
channel = guild.get_channel(channel_id)
if not channel:
self.logger.debug(f"Channel {channel_data['name']} no longer exists, removing from tracking")
self.logger.debug(
f"Channel {channel_data['name']} no longer exists, removing from tracking"
)
self.tracker.remove_channel(channel_id)
# Unpublish associated scorecard if it exists
@ -195,17 +218,25 @@ class VoiceChannelCleanupService:
if text_channel_id:
try:
text_channel_id_int = int(text_channel_id)
was_unpublished = self.scorecard_tracker.unpublish_scorecard(text_channel_id_int)
was_unpublished = self.scorecard_tracker.unpublish_scorecard(
text_channel_id_int
)
if was_unpublished:
self.logger.info(f"📋 Unpublished scorecard from text channel {text_channel_id_int} (manually deleted voice channel)")
self.logger.info(
f"📋 Unpublished scorecard from text channel {text_channel_id_int} (manually deleted voice channel)"
)
except (ValueError, TypeError) as e:
self.logger.warning(f"Invalid text_channel_id in manually deleted voice channel data: {e}")
self.logger.warning(
f"Invalid text_channel_id in manually deleted voice channel data: {e}"
)
return
# Ensure it's a voice channel before checking members
if not isinstance(channel, discord.VoiceChannel):
self.logger.warning(f"Channel {channel_data['name']} is not a voice channel, removing from tracking")
self.logger.warning(
f"Channel {channel_data['name']} is not a voice channel, removing from tracking"
)
self.tracker.remove_channel(channel_id)
# Unpublish associated scorecard if it exists
@ -213,11 +244,17 @@ class VoiceChannelCleanupService:
if text_channel_id:
try:
text_channel_id_int = int(text_channel_id)
was_unpublished = self.scorecard_tracker.unpublish_scorecard(text_channel_id_int)
was_unpublished = self.scorecard_tracker.unpublish_scorecard(
text_channel_id_int
)
if was_unpublished:
self.logger.info(f"📋 Unpublished scorecard from text channel {text_channel_id_int} (wrong channel type)")
self.logger.info(
f"📋 Unpublished scorecard from text channel {text_channel_id_int} (wrong channel type)"
)
except (ValueError, TypeError) as e:
self.logger.warning(f"Invalid text_channel_id in wrong channel type data: {e}")
self.logger.warning(
f"Invalid text_channel_id in wrong channel type data: {e}"
)
return
@ -225,11 +262,15 @@ class VoiceChannelCleanupService:
is_empty = len(channel.members) == 0
self.tracker.update_channel_status(channel_id, is_empty)
self.logger.debug(f"Channel {channel_data['name']}: {'empty' if is_empty else 'occupied'} "
f"({len(channel.members)} members)")
self.logger.debug(
f"Channel {channel_data['name']}: {'empty' if is_empty else 'occupied'} "
f"({len(channel.members)} members)"
)
except Exception as e:
self.logger.error(f"Error checking channel status for {channel_data.get('name', 'unknown')}: {e}")
self.logger.error(
f"Error checking channel status for {channel_data.get('name', 'unknown')}: {e}"
)
async def cleanup_channel(self, bot: commands.Bot, channel_data: dict) -> None:
"""
@ -246,25 +287,33 @@ class VoiceChannelCleanupService:
guild = bot.get_guild(guild_id)
if not guild:
self.logger.info(f"Guild {guild_id} not found, removing tracking for {channel_name}")
self.logger.info(
f"Guild {guild_id} not found, removing tracking for {channel_name}"
)
self.tracker.remove_channel(channel_id)
return
channel = guild.get_channel(channel_id)
if not channel:
self.logger.info(f"Channel {channel_name} already deleted, removing from tracking")
self.logger.info(
f"Channel {channel_name} already deleted, removing from tracking"
)
self.tracker.remove_channel(channel_id)
return
# Ensure it's a voice channel before checking members
if not isinstance(channel, discord.VoiceChannel):
self.logger.warning(f"Channel {channel_name} is not a voice channel, removing from tracking")
self.logger.warning(
f"Channel {channel_name} is not a voice channel, removing from tracking"
)
self.tracker.remove_channel(channel_id)
return
# Final check: make sure channel is still empty before deleting
if len(channel.members) > 0:
self.logger.info(f"Channel {channel_name} is no longer empty, skipping cleanup")
self.logger.info(
f"Channel {channel_name} is no longer empty, skipping cleanup"
)
self.tracker.update_channel_status(channel_id, False)
return
@ -272,24 +321,36 @@ class VoiceChannelCleanupService:
await channel.delete(reason="Automatic cleanup - empty for 5+ minutes")
self.tracker.remove_channel(channel_id)
self.logger.info(f"✅ Cleaned up empty voice channel: {channel_name} (ID: {channel_id})")
self.logger.info(
f"✅ Cleaned up empty voice channel: {channel_name} (ID: {channel_id})"
)
# Unpublish associated scorecard if it exists
text_channel_id = channel_data.get("text_channel_id")
if text_channel_id:
try:
text_channel_id_int = int(text_channel_id)
was_unpublished = self.scorecard_tracker.unpublish_scorecard(text_channel_id_int)
was_unpublished = self.scorecard_tracker.unpublish_scorecard(
text_channel_id_int
)
if was_unpublished:
self.logger.info(f"📋 Unpublished scorecard from text channel {text_channel_id_int} (voice channel cleanup)")
self.logger.info(
f"📋 Unpublished scorecard from text channel {text_channel_id_int} (voice channel cleanup)"
)
else:
self.logger.debug(f"No scorecard found for text channel {text_channel_id_int}")
self.logger.debug(
f"No scorecard found for text channel {text_channel_id_int}"
)
except (ValueError, TypeError) as e:
self.logger.warning(f"Invalid text_channel_id in voice channel data: {e}")
self.logger.warning(
f"Invalid text_channel_id in voice channel data: {e}"
)
except discord.NotFound:
# Channel was already deleted
self.logger.info(f"Channel {channel_data.get('name', 'unknown')} was already deleted")
self.logger.info(
f"Channel {channel_data.get('name', 'unknown')} was already deleted"
)
self.tracker.remove_channel(int(channel_data["channel_id"]))
# Still try to unpublish associated scorecard
@ -297,15 +358,25 @@ class VoiceChannelCleanupService:
if text_channel_id:
try:
text_channel_id_int = int(text_channel_id)
was_unpublished = self.scorecard_tracker.unpublish_scorecard(text_channel_id_int)
was_unpublished = self.scorecard_tracker.unpublish_scorecard(
text_channel_id_int
)
if was_unpublished:
self.logger.info(f"📋 Unpublished scorecard from text channel {text_channel_id_int} (stale voice channel cleanup)")
self.logger.info(
f"📋 Unpublished scorecard from text channel {text_channel_id_int} (stale voice channel cleanup)"
)
except (ValueError, TypeError) as e:
self.logger.warning(f"Invalid text_channel_id in voice channel data: {e}")
self.logger.warning(
f"Invalid text_channel_id in voice channel data: {e}"
)
except discord.Forbidden:
self.logger.error(f"Missing permissions to delete channel {channel_data.get('name', 'unknown')}")
self.logger.error(
f"Missing permissions to delete channel {channel_data.get('name', 'unknown')}"
)
except Exception as e:
self.logger.error(f"Error cleaning up channel {channel_data.get('name', 'unknown')}: {e}")
self.logger.error(
f"Error cleaning up channel {channel_data.get('name', 'unknown')}: {e}"
)
def get_tracker(self) -> VoiceChannelTracker:
"""
@ -330,7 +401,7 @@ class VoiceChannelCleanupService:
"running": self.cleanup_loop.is_running(),
"total_tracked": len(all_channels),
"empty_channels": len(empty_channels),
"empty_threshold": self.empty_threshold
"empty_threshold": self.empty_threshold,
}

View File

@ -3,6 +3,7 @@ Voice Channel Tracker
Provides persistent tracking of bot-created voice channels using JSON file storage.
"""
import json
import logging
from datetime import datetime, timedelta, UTC
@ -11,7 +12,7 @@ from typing import Dict, List, Optional, Any
import discord
logger = logging.getLogger(f'{__name__}.VoiceChannelTracker')
logger = logging.getLogger(f"{__name__}.VoiceChannelTracker")
class VoiceChannelTracker:
@ -25,7 +26,7 @@ class VoiceChannelTracker:
- Automatic stale entry removal
"""
def __init__(self, data_file: str = "data/voice_channels.json"):
def __init__(self, data_file: str = "storage/voice_channels.json"):
"""
Initialize the voice channel tracker.
@ -41,9 +42,11 @@ class VoiceChannelTracker:
"""Load channel data from JSON file."""
try:
if self.data_file.exists():
with open(self.data_file, 'r') as f:
with open(self.data_file, "r") as f:
self._data = json.load(f)
logger.debug(f"Loaded {len(self._data.get('voice_channels', {}))} tracked channels")
logger.debug(
f"Loaded {len(self._data.get('voice_channels', {}))} tracked channels"
)
else:
self._data = {"voice_channels": {}}
logger.info("No existing voice channel data found, starting fresh")
@ -54,7 +57,7 @@ class VoiceChannelTracker:
def save_data(self) -> None:
"""Save channel data to JSON file."""
try:
with open(self.data_file, 'w') as f:
with open(self.data_file, "w") as f:
json.dump(self._data, f, indent=2, default=str)
logger.debug("Voice channel data saved successfully")
except Exception as e:
@ -65,7 +68,7 @@ class VoiceChannelTracker:
channel: discord.VoiceChannel,
channel_type: str,
creator_id: int,
text_channel_id: Optional[int] = None
text_channel_id: Optional[int] = None,
) -> None:
"""
Add a new channel to tracking.
@ -85,7 +88,7 @@ class VoiceChannelTracker:
"last_checked": datetime.now(UTC).isoformat(),
"empty_since": None,
"creator_id": str(creator_id),
"text_channel_id": str(text_channel_id) if text_channel_id else None
"text_channel_id": str(text_channel_id) if text_channel_id else None,
}
self.save_data()
logger.info(f"Added channel to tracking: {channel.name} (ID: {channel.id})")
@ -130,9 +133,13 @@ class VoiceChannelTracker:
channel_name = channels[channel_key]["name"]
del channels[channel_key]
self.save_data()
logger.info(f"Removed channel from tracking: {channel_name} (ID: {channel_id})")
logger.info(
f"Removed channel from tracking: {channel_name} (ID: {channel_id})"
)
def get_channels_for_cleanup(self, empty_threshold_minutes: int = 15) -> List[Dict[str, Any]]:
def get_channels_for_cleanup(
self, empty_threshold_minutes: int = 15
) -> List[Dict[str, Any]]:
"""
Get channels that should be deleted based on empty duration.
@ -153,10 +160,12 @@ class VoiceChannelTracker:
# Parse empty_since timestamp
empty_since_str = channel_data["empty_since"]
# Handle both with and without timezone info
if empty_since_str.endswith('Z'):
empty_since_str = empty_since_str[:-1] + '+00:00'
if empty_since_str.endswith("Z"):
empty_since_str = empty_since_str[:-1] + "+00:00"
empty_since = datetime.fromisoformat(empty_since_str.replace('Z', '+00:00'))
empty_since = datetime.fromisoformat(
empty_since_str.replace("Z", "+00:00")
)
# Remove timezone info for comparison (both times are UTC)
if empty_since.tzinfo:
@ -164,10 +173,14 @@ class VoiceChannelTracker:
if empty_since <= cutoff_time:
cleanup_candidates.append(channel_data)
logger.debug(f"Channel {channel_data['name']} ready for cleanup (empty since {empty_since})")
logger.debug(
f"Channel {channel_data['name']} ready for cleanup (empty since {empty_since})"
)
except (ValueError, TypeError) as e:
logger.warning(f"Invalid timestamp for channel {channel_data.get('name', 'unknown')}: {e}")
logger.warning(
f"Invalid timestamp for channel {channel_data.get('name', 'unknown')}: {e}"
)
return cleanup_candidates
@ -242,7 +255,9 @@ class VoiceChannelTracker:
for channel_id_str in stale_entries:
channel_name = channels[channel_id_str].get("name", "unknown")
del channels[channel_id_str]
logger.info(f"Removed stale tracking entry: {channel_name} (ID: {channel_id_str})")
logger.info(
f"Removed stale tracking entry: {channel_name} (ID: {channel_id_str})"
)
if stale_entries:
self.save_data()

View File

@ -36,8 +36,11 @@ services:
# Volume mounts
volumes:
# Google Sheets credentials (required)
- ${SHEETS_CREDENTIALS_HOST_PATH:-./data}:/app/data:ro
# Google Sheets credentials (read-only, file mount)
- ${SHEETS_CREDENTIALS_HOST_PATH:-./data/major-domo-service-creds.json}:/app/data/major-domo-service-creds.json:ro
# Runtime state files (writable) - scorecards, voice channels, trade channels, soak data
- ${STATE_HOST_PATH:-./storage}:/app/storage:rw
# Logs directory (persistent) - mounted to /app/logs where the application expects it
- ${LOGS_HOST_PATH:-./logs}:/app/logs:rw