diff --git a/.env.example b/.env.example index 486bb98..242d6a9 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,9 @@ LETTA_API_KEY=your_letta_api_key # Working directory for agent workspace # WORKING_DIR=/tmp/lettabot +# Persistent data directory override (agent store, cron jobs, logs) +# DATA_DIR=/absolute/path/to/lettabot-data + # Custom system prompt (optional) # SYSTEM_PROMPT=You are a helpful assistant... diff --git a/docs/cron-setup.md b/docs/cron-setup.md index 22d3d23..120334b 100644 --- a/docs/cron-setup.md +++ b/docs/cron-setup.md @@ -162,6 +162,19 @@ Shows: - `cron-jobs.json` - Job configurations - `cron-log.jsonl` - Execution logs +### Cron Storage Path + +Cron state is resolved with deterministic precedence: + +1. `RAILWAY_VOLUME_MOUNT_PATH` +2. `DATA_DIR` +3. `WORKING_DIR` +4. `/tmp/lettabot` + +Migration note: +- Older versions used `process.cwd()/cron-jobs.json` when `DATA_DIR` was not set. +- On first run after upgrade, LettaBot auto-copies that legacy file into the new canonical cron path. + ## Troubleshooting ### Cron jobs not running diff --git a/src/cli.ts b/src/cli.ts index 9c0e2f8..4eccc85 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -14,7 +14,7 @@ const config = loadAppConfigOrExit(); applyConfigToEnv(config); import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; -import { getDataDir, getWorkingDir } from './utils/paths.js'; +import { getCronStorePath, getDataDir, getLegacyCronStorePath, getWorkingDir } from './utils/paths.js'; import { fileURLToPath } from 'node:url'; import { spawn, spawnSync } from 'node:child_process'; import updateNotifier from 'update-notifier'; @@ -326,7 +326,8 @@ async function main() { const workingDir = getWorkingDir(); const agentJsonPath = join(dataDir, 'lettabot-agent.json'); const skillsDir = join(workingDir, '.skills'); - const cronJobsPath = join(dataDir, 'cron-jobs.json'); + const cronJobsPath = getCronStorePath(); + const legacyCronJobsPath = getLegacyCronStorePath(); p.intro('🗑️ Destroy LettaBot Data'); @@ -334,6 +335,9 @@ async function main() { p.log.message(` • Agent store: ${agentJsonPath}`); p.log.message(` • Skills: ${skillsDir}`); p.log.message(` • Cron jobs: ${cronJobsPath}`); + if (legacyCronJobsPath !== cronJobsPath) { + p.log.message(` • Legacy cron jobs: ${legacyCronJobsPath}`); + } p.log.message(''); p.log.message('Note: The agent on Letta servers will NOT be deleted.'); @@ -367,6 +371,12 @@ async function main() { p.log.success('Deleted cron-jobs.json'); deleted++; } + + if (legacyCronJobsPath !== cronJobsPath && existsSync(legacyCronJobsPath)) { + rmSync(legacyCronJobsPath); + p.log.success('Deleted legacy cron-jobs.json'); + deleted++; + } if (deleted === 0) { p.log.info('Nothing to delete'); diff --git a/src/cron/cli.ts b/src/cron/cli.ts index 5a89af4..6e61f2e 100644 --- a/src/cron/cli.ts +++ b/src/cron/cli.ts @@ -13,9 +13,9 @@ */ -import { existsSync, readFileSync, writeFileSync, appendFileSync, mkdirSync } from 'node:fs'; -import { resolve, dirname } from 'node:path'; -import { getDataDir } from '../utils/paths.js'; +import { existsSync, readFileSync, writeFileSync, appendFileSync, mkdirSync, copyFileSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { getCronLogPath, getCronStorePath, getLegacyCronStorePath } from '../utils/paths.js'; // Parse ISO datetime string function parseISODateTime(input: string): Date { @@ -56,8 +56,23 @@ interface CronStore { } // Store path -const STORE_PATH = resolve(getDataDir(), 'cron-jobs.json'); -const LOG_PATH = resolve(getDataDir(), 'cron-log.jsonl'); +const STORE_PATH = getCronStorePath(); +const LOG_PATH = getCronLogPath(); + +function migrateLegacyStoreIfNeeded(): void { + if (existsSync(STORE_PATH)) return; + + const legacyPath = getLegacyCronStorePath(); + if (legacyPath === STORE_PATH || !existsSync(legacyPath)) return; + + try { + mkdirSync(dirname(STORE_PATH), { recursive: true }); + copyFileSync(legacyPath, STORE_PATH); + console.error(`[Cron] store_migrated: ${JSON.stringify({ from: legacyPath, to: STORE_PATH })}`); + } catch (e) { + console.error('[Cron] Failed to migrate legacy cron store:', e); + } +} function log(event: string, data: Record): void { const entry = { @@ -78,6 +93,7 @@ function log(event: string, data: Record): void { } function loadStore(): CronStore { + migrateLegacyStoreIfNeeded(); try { if (existsSync(STORE_PATH)) { return JSON.parse(readFileSync(STORE_PATH, 'utf-8')); diff --git a/src/cron/heartbeat.ts b/src/cron/heartbeat.ts index 7692224..37ce38c 100644 --- a/src/cron/heartbeat.ts +++ b/src/cron/heartbeat.ts @@ -12,11 +12,11 @@ import { resolve, dirname } from 'node:path'; import type { AgentSession } from '../core/interfaces.js'; import type { TriggerContext } from '../core/types.js'; import { buildHeartbeatPrompt, buildCustomHeartbeatPrompt } from '../core/prompts.js'; -import { getDataDir } from '../utils/paths.js'; +import { getCronLogPath } from '../utils/paths.js'; // Log file -const LOG_PATH = resolve(getDataDir(), 'cron-log.jsonl'); +const LOG_PATH = getCronLogPath(); function logEvent(event: string, data: Record): void { const entry = { diff --git a/src/cron/service.ts b/src/cron/service.ts index c46ea88..0b82a13 100644 --- a/src/cron/service.ts +++ b/src/cron/service.ts @@ -5,15 +5,15 @@ * Supports heartbeat check-ins and agent-managed cron jobs. */ -import { existsSync, readFileSync, writeFileSync, appendFileSync, mkdirSync, watch, type FSWatcher } from 'node:fs'; +import { existsSync, readFileSync, writeFileSync, appendFileSync, mkdirSync, copyFileSync, watch, type FSWatcher } from 'node:fs'; import { resolve, dirname } from 'node:path'; import type { AgentSession } from '../core/interfaces.js'; import type { CronJob, CronJobCreate, CronSchedule, CronConfig, HeartbeatConfig } from './types.js'; import { DEFAULT_HEARTBEAT_MESSAGES } from './types.js'; -import { getDataDir } from '../utils/paths.js'; +import { getCronDataDir, getCronLogPath, getCronStorePath, getLegacyCronStorePath } from '../utils/paths.js'; // Log file for cron events -const LOG_PATH = resolve(getDataDir(), 'cron-log.jsonl'); +const LOG_PATH = getCronLogPath(); function logEvent(event: string, data: Record): void { const entry = { @@ -61,10 +61,28 @@ export class CronService { this.bot = bot; this.config = config || {}; this.storePath = config?.storePath - ? resolve(getDataDir(), config.storePath) - : resolve(getDataDir(), 'cron-jobs.json'); + ? resolve(getCronDataDir(), config.storePath) + : getCronStorePath(); + this.migrateLegacyStoreIfNeeded(); this.loadJobs(); } + + private migrateLegacyStoreIfNeeded(): void { + // Explicit storePath overrides are already deterministic and should not auto-migrate. + if (this.config.storePath) return; + if (existsSync(this.storePath)) return; + + const legacyPath = getLegacyCronStorePath(); + if (legacyPath === this.storePath || !existsSync(legacyPath)) return; + + try { + mkdirSync(dirname(this.storePath), { recursive: true }); + copyFileSync(legacyPath, this.storePath); + logEvent('store_migrated', { from: legacyPath, to: this.storePath }); + } catch (e) { + console.error('[Cron] Failed to migrate legacy store:', e); + } + } private loadJobs(): void { try { diff --git a/src/utils/paths.test.ts b/src/utils/paths.test.ts new file mode 100644 index 0000000..aca8c41 --- /dev/null +++ b/src/utils/paths.test.ts @@ -0,0 +1,72 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { resolve } from 'node:path'; +import { + getCronDataDir, + getCronLogPath, + getCronStorePath, + getLegacyCronStorePath, +} from './paths.js'; + +const TEST_ENV_KEYS = [ + 'RAILWAY_VOLUME_MOUNT_PATH', + 'DATA_DIR', + 'WORKING_DIR', +] as const; + +const ORIGINAL_ENV: Record<(typeof TEST_ENV_KEYS)[number], string | undefined> = { + RAILWAY_VOLUME_MOUNT_PATH: process.env.RAILWAY_VOLUME_MOUNT_PATH, + DATA_DIR: process.env.DATA_DIR, + WORKING_DIR: process.env.WORKING_DIR, +}; + +function clearPathEnv(): void { + for (const key of TEST_ENV_KEYS) { + delete process.env[key]; + } +} + +describe('cron path resolution', () => { + beforeEach(() => { + clearPathEnv(); + }); + + afterEach(() => { + clearPathEnv(); + for (const [key, value] of Object.entries(ORIGINAL_ENV)) { + if (value !== undefined) { + process.env[key] = value; + } + } + }); + + it('prioritizes Railway volume path', () => { + process.env.RAILWAY_VOLUME_MOUNT_PATH = '/railway/volume'; + process.env.DATA_DIR = '/custom/data'; + process.env.WORKING_DIR = '/custom/work'; + + expect(getCronDataDir()).toBe('/railway/volume'); + }); + + it('uses DATA_DIR when Railway volume is not set', () => { + process.env.DATA_DIR = '/custom/data'; + process.env.WORKING_DIR = '/custom/work'; + + expect(getCronDataDir()).toBe('/custom/data'); + }); + + it('uses WORKING_DIR when DATA_DIR is not set', () => { + process.env.WORKING_DIR = '/custom/work'; + + expect(getCronDataDir()).toBe('/custom/work'); + }); + + it('falls back to /tmp/lettabot when no overrides are set', () => { + expect(getCronDataDir()).toBe('/tmp/lettabot'); + expect(getCronStorePath()).toBe('/tmp/lettabot/cron-jobs.json'); + expect(getCronLogPath()).toBe('/tmp/lettabot/cron-log.jsonl'); + }); + + it('keeps legacy cron path behavior for migration', () => { + expect(getLegacyCronStorePath()).toBe(resolve(process.cwd(), 'cron-jobs.json')); + }); +}); diff --git a/src/utils/paths.ts b/src/utils/paths.ts index 1ca9c32..3fab2d9 100644 --- a/src/utils/paths.ts +++ b/src/utils/paths.ts @@ -54,6 +54,56 @@ export function getWorkingDir(): string { return '/tmp/lettabot'; } +/** + * Get the canonical directory for cron state (cron-jobs.json / cron-log.jsonl). + * + * This is intentionally deterministic across server and CLI contexts, and does + * not depend on process.cwd(). + * + * Priority: + * 1. RAILWAY_VOLUME_MOUNT_PATH (Railway persistent volume) + * 2. DATA_DIR (explicit persistent data override) + * 3. WORKING_DIR (runtime workspace) + * 4. /tmp/lettabot (deterministic local fallback) + */ +export function getCronDataDir(): string { + if (process.env.RAILWAY_VOLUME_MOUNT_PATH) { + return process.env.RAILWAY_VOLUME_MOUNT_PATH; + } + + if (process.env.DATA_DIR) { + return process.env.DATA_DIR; + } + + if (process.env.WORKING_DIR) { + return process.env.WORKING_DIR; + } + + return '/tmp/lettabot'; +} + +/** + * Canonical cron store path. + */ +export function getCronStorePath(): string { + return resolve(getCronDataDir(), 'cron-jobs.json'); +} + +/** + * Canonical cron log path. + */ +export function getCronLogPath(): string { + return resolve(getCronDataDir(), 'cron-log.jsonl'); +} + +/** + * Legacy cron store path (used before deterministic cron path resolution). + * Kept for migration of existing local files. + */ +export function getLegacyCronStorePath(): string { + return resolve(getDataDir(), 'cron-jobs.json'); +} + /** * Check if running on Railway */