fix(skills): persist Railway skill directories across redeploys (#582)
Co-authored-by: Letta Code <noreply@letta.com>
This commit is contained in:
@@ -104,7 +104,7 @@ The Railway template includes a persistent volume mounted at `/data`. This is se
|
|||||||
|
|
||||||
- **Agent ID** - No need to set `LETTA_AGENT_ID` manually after first run
|
- **Agent ID** - No need to set `LETTA_AGENT_ID` manually after first run
|
||||||
- **Cron jobs** - Scheduled tasks survive restarts
|
- **Cron jobs** - Scheduled tasks survive restarts
|
||||||
- **Skills** - Downloaded skills persist
|
- **Skills** - Agent-scoped (`.letta/agents/.../skills`) and working-dir (`WORKING_DIR/.skills`) skills persist
|
||||||
- **Attachments** - Downloaded media files
|
- **Attachments** - Downloaded media files
|
||||||
|
|
||||||
### Volume Size
|
### Volume Size
|
||||||
@@ -123,6 +123,8 @@ If you deploy manually from a fork instead of using the template, you'll need to
|
|||||||
|
|
||||||
LettaBot automatically detects `RAILWAY_VOLUME_MOUNT_PATH` and uses it for persistent data.
|
LettaBot automatically detects `RAILWAY_VOLUME_MOUNT_PATH` and uses it for persistent data.
|
||||||
|
|
||||||
|
By default (when `WORKING_DIR` is unset), LettaBot uses `$RAILWAY_VOLUME_MOUNT_PATH/data` as the working directory, so `WORKING_DIR/.skills` is persisted across redeploys. Agent-scoped skills are also stored under `$RAILWAY_VOLUME_MOUNT_PATH/.letta/agents/.../skills`.
|
||||||
|
|
||||||
## Remote Pairing Approval
|
## Remote Pairing Approval
|
||||||
|
|
||||||
When using `pairing` DM policy on a cloud deployment, you need a way to approve new users without CLI access.
|
When using `pairing` DM policy on a cloud deployment, you need a way to approve new users without CLI access.
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ LettaBot scans these directories in priority order. Same-name skills at higher p
|
|||||||
| 4 | `skills/` (in lettabot repo) | Bundled | Ships with lettabot |
|
| 4 | `skills/` (in lettabot repo) | Bundled | Ships with lettabot |
|
||||||
| 5 (lowest) | `~/.agents/skills/` | skills.sh | Installed via [skills.sh](https://skills.sh) |
|
| 5 (lowest) | `~/.agents/skills/` | skills.sh | Installed via [skills.sh](https://skills.sh) |
|
||||||
|
|
||||||
|
On Railway with a mounted volume, LettaBot stores `.letta` skill paths under `$RAILWAY_VOLUME_MOUNT_PATH/.letta/` by default, so agent-scoped and global skills survive redeploys.
|
||||||
|
|
||||||
Feature-gated skills are copied from source directories into the agent-scoped directory (`~/.letta/agents/{id}/skills/`) when a session is first acquired. The copy is idempotent -- skills already present in the target are skipped.
|
Feature-gated skills are copied from source directories into the agent-scoped directory (`~/.letta/agents/{id}/skills/`) when a session is first acquired. The copy is idempotent -- skills already present in the target are skipped.
|
||||||
|
|
||||||
## Feature-gated skills
|
## Feature-gated skills
|
||||||
@@ -114,6 +116,10 @@ When a session starts, `prependSkillDirsToPath()` in `src/skills/loader.ts` prep
|
|||||||
1. **Agent-scoped skills** (`~/.letta/agents/{id}/skills/`) — feature-gated skills installed by `installSkillsToAgent()` on startup.
|
1. **Agent-scoped skills** (`~/.letta/agents/{id}/skills/`) — feature-gated skills installed by `installSkillsToAgent()` on startup.
|
||||||
2. **Working-dir skills** (`WORKING_DIR/.skills/`) — skills enabled via `lettabot skills enable <name>` or the interactive `lettabot skills` wizard.
|
2. **Working-dir skills** (`WORKING_DIR/.skills/`) — skills enabled via `lettabot skills enable <name>` or the interactive `lettabot skills` wizard.
|
||||||
|
|
||||||
|
On Railway with a mounted volume, `WORKING_DIR` defaults to `$RAILWAY_VOLUME_MOUNT_PATH/data` when not explicitly set, so working-dir skills are persisted on the volume by default.
|
||||||
|
|
||||||
|
On Railway with a mounted volume, agent-scoped skills are stored under `$RAILWAY_VOLUME_MOUNT_PATH/.letta/agents/{id}/skills/`.
|
||||||
|
|
||||||
Only directories containing at least one non-`.md` file are added. The prepend is idempotent — directories already on PATH are not duplicated. PATH is not restored after the call; the augmented PATH persists for the lifetime of the process, which is correct because the subprocess retains its inherited environment.
|
Only directories containing at least one non-`.md` file are added. The prepend is idempotent — directories already on PATH are not duplicated. PATH is not restored after the call; the augmented PATH persists for the lifetime of the process, which is correct because the subprocess retains its inherited environment.
|
||||||
|
|
||||||
To verify skill directories are present after startup, check the child subprocess's `/proc/[pid]/environ` (not the parent lettabot process, which shares the same augmented PATH).
|
To verify skill directories are present after startup, check the child subprocess's `/proc/[pid]/environ` (not the parent lettabot process, which shares the same augmented PATH).
|
||||||
|
|||||||
@@ -12,8 +12,66 @@ import {
|
|||||||
isVoiceMemoConfigured,
|
isVoiceMemoConfigured,
|
||||||
} from './loader.js';
|
} from './loader.js';
|
||||||
|
|
||||||
|
const ORIGINAL_WORKING_DIR = process.env.WORKING_DIR;
|
||||||
|
const ORIGINAL_RAILWAY_VOLUME_MOUNT_PATH = process.env.RAILWAY_VOLUME_MOUNT_PATH;
|
||||||
|
|
||||||
|
async function importFreshLoader() {
|
||||||
|
vi.resetModules();
|
||||||
|
return import('./loader.js');
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreLoaderPathEnv() {
|
||||||
|
if (ORIGINAL_WORKING_DIR === undefined) {
|
||||||
|
delete process.env.WORKING_DIR;
|
||||||
|
} else {
|
||||||
|
process.env.WORKING_DIR = ORIGINAL_WORKING_DIR;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ORIGINAL_RAILWAY_VOLUME_MOUNT_PATH === undefined) {
|
||||||
|
delete process.env.RAILWAY_VOLUME_MOUNT_PATH;
|
||||||
|
} else {
|
||||||
|
process.env.RAILWAY_VOLUME_MOUNT_PATH = ORIGINAL_RAILWAY_VOLUME_MOUNT_PATH;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe('skills loader', () => {
|
describe('skills loader', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
restoreLoaderPathEnv();
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('working directory resolution', () => {
|
||||||
|
it('uses Railway volume-backed working dir when WORKING_DIR is not set', async () => {
|
||||||
|
process.env.RAILWAY_VOLUME_MOUNT_PATH = '/railway-volume';
|
||||||
|
delete process.env.WORKING_DIR;
|
||||||
|
|
||||||
|
const mod = await importFreshLoader();
|
||||||
|
|
||||||
|
expect(mod.WORKING_DIR).toBe('/railway-volume/data');
|
||||||
|
expect(mod.WORKING_SKILLS_DIR).toBe('/railway-volume/data/.skills');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers explicit WORKING_DIR over Railway volume mount', async () => {
|
||||||
|
process.env.RAILWAY_VOLUME_MOUNT_PATH = '/railway-volume';
|
||||||
|
process.env.WORKING_DIR = '/custom/workdir';
|
||||||
|
|
||||||
|
const mod = await importFreshLoader();
|
||||||
|
|
||||||
|
expect(mod.WORKING_DIR).toBe('/custom/workdir');
|
||||||
|
expect(mod.WORKING_SKILLS_DIR).toBe('/custom/workdir/.skills');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('getAgentSkillsDir', () => {
|
describe('getAgentSkillsDir', () => {
|
||||||
|
it('uses Railway volume path for agent-scoped skills when mounted', async () => {
|
||||||
|
process.env.RAILWAY_VOLUME_MOUNT_PATH = '/railway-volume';
|
||||||
|
|
||||||
|
const mod = await importFreshLoader();
|
||||||
|
const dir = mod.getAgentSkillsDir('agent-railway');
|
||||||
|
|
||||||
|
expect(dir).toBe('/railway-volume/.letta/agents/agent-railway/skills');
|
||||||
|
});
|
||||||
|
|
||||||
it('returns path containing agent ID', () => {
|
it('returns path containing agent ID', () => {
|
||||||
const agentId = 'agent-test-123';
|
const agentId = 'agent-test-123';
|
||||||
const dir = getAgentSkillsDir(agentId);
|
const dir = getAgentSkillsDir(agentId);
|
||||||
|
|||||||
@@ -7,13 +7,17 @@ import { execSync } from 'node:child_process';
|
|||||||
import { join, resolve, delimiter } from 'node:path';
|
import { join, resolve, delimiter } from 'node:path';
|
||||||
import matter from 'gray-matter';
|
import matter from 'gray-matter';
|
||||||
import type { SkillEntry, ClawdbotMetadata } from './types.js';
|
import type { SkillEntry, ClawdbotMetadata } from './types.js';
|
||||||
|
import { getWorkingDir } from '../utils/paths.js';
|
||||||
|
|
||||||
// Skills directories (in priority order: project > agent > global > bundled > skills.sh)
|
// Skills directories (in priority order: project > agent > global > bundled > skills.sh)
|
||||||
const HOME = process.env.HOME || process.env.USERPROFILE || '';
|
const HOME = process.env.HOME || process.env.USERPROFILE || '';
|
||||||
export const WORKING_DIR = process.env.WORKING_DIR || '/tmp/lettabot';
|
const LETTA_HOME = process.env.RAILWAY_VOLUME_MOUNT_PATH
|
||||||
|
? join(process.env.RAILWAY_VOLUME_MOUNT_PATH, '.letta')
|
||||||
|
: join(HOME, '.letta');
|
||||||
|
export const WORKING_DIR = getWorkingDir();
|
||||||
export const PROJECT_SKILLS_DIR = resolve(process.cwd(), '.skills');
|
export const PROJECT_SKILLS_DIR = resolve(process.cwd(), '.skills');
|
||||||
export const WORKING_SKILLS_DIR = join(WORKING_DIR, '.skills'); // skills enabled via CLI
|
export const WORKING_SKILLS_DIR = join(WORKING_DIR, '.skills'); // skills enabled via CLI
|
||||||
export const GLOBAL_SKILLS_DIR = join(HOME, '.letta', 'skills');
|
export const GLOBAL_SKILLS_DIR = join(LETTA_HOME, 'skills');
|
||||||
export const SKILLS_SH_DIR = join(HOME, '.agents', 'skills'); // skills.sh global installs
|
export const SKILLS_SH_DIR = join(HOME, '.agents', 'skills'); // skills.sh global installs
|
||||||
|
|
||||||
// Bundled skills from the lettabot repo itself
|
// Bundled skills from the lettabot repo itself
|
||||||
@@ -29,7 +33,7 @@ export const BUNDLED_SKILLS_DIR = resolve(__dirname, '../../skills'); // lettabo
|
|||||||
* Get the agent-scoped skills directory for a specific agent
|
* Get the agent-scoped skills directory for a specific agent
|
||||||
*/
|
*/
|
||||||
export function getAgentSkillsDir(agentId: string): string {
|
export function getAgentSkillsDir(agentId: string): string {
|
||||||
return join(HOME, '.letta', 'agents', agentId, 'skills');
|
return join(LETTA_HOME, 'agents', agentId, 'skills');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,7 +73,7 @@ export function getWorkingSkillExecutableDirs(): string[] {
|
|||||||
* Permanently prepend skill directories to PATH so that subprocesses
|
* Permanently prepend skill directories to PATH so that subprocesses
|
||||||
* spawned subsequently inherit them.
|
* spawned subsequently inherit them.
|
||||||
*
|
*
|
||||||
* Includes both agent-scoped skills (~/.letta/agents/{id}/skills/) and
|
* Includes both agent-scoped skills (.letta/agents/{id}/skills) and
|
||||||
* working-dir skills (WORKING_DIR/.skills/) so that skills enabled via
|
* working-dir skills (WORKING_DIR/.skills/) so that skills enabled via
|
||||||
* `lettabot skills enable` are available without needing a feature-gate.
|
* `lettabot skills enable` are available without needing a feature-gate.
|
||||||
*
|
*
|
||||||
@@ -411,7 +415,7 @@ export function installSkillsToWorkingDir(workingDir: string, config: SkillsInst
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Install feature-gated skills to the agent-scoped skills directory
|
* Install feature-gated skills to the agent-scoped skills directory
|
||||||
* (~/.letta/agents/{agentId}/skills/)
|
* (.letta/agents/{agentId}/skills)
|
||||||
*
|
*
|
||||||
* This aligns with Letta Code CLI which uses agent-scoped skills.
|
* This aligns with Letta Code CLI which uses agent-scoped skills.
|
||||||
* Called after agent creation in bot.ts.
|
* Called after agent creation in bot.ts.
|
||||||
|
|||||||
Reference in New Issue
Block a user