Persistence Layer 구현
Persistence Layer의 역할
섹션 제목: “Persistence Layer의 역할”에이전트가 재시작되거나 중단되었을 때 작업을 복구할 수 있어야 합니다. Persistence Layer는 세 가지 기능을 제공합니다.
| 기능 | 목적 | 구현 |
|---|---|---|
| 세션 저장 | 대화 히스토리 보존, 재개 | JsonSessionStore |
| 설정 해석 | 환경별 설정 오버라이드 | ConfigResolver |
| 운영 로그 | 파일 변경 추적, 롤백 지원 | OperationLog + git |
JsonSessionStore
섹션 제목: “JsonSessionStore”세션은 대화 히스토리와 메타데이터를 포함합니다. 자동저장 은 매 턴마다 파일에 기록해 프로세스 크래시에도 복구를 가능하게 합니다.
interface Session { id: string; createdAt: string; updatedAt: string; task: string; status: 'running' | 'completed' | 'failed' | 'paused'; conversationHistory: Message[]; metadata: Record<string, unknown>;}
class JsonSessionStore { constructor(private baseDir: string) {}
private sessionPath(id: string): string { return path.join(this.baseDir, `${id}.json`); }
async create(task: string): Promise<Session> { const session: Session = { id: crypto.randomUUID(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), task, status: 'running', conversationHistory: [], metadata: {}, };
await fs.mkdir(this.baseDir, { recursive: true }); await this.save(session); return session; }
async load(id: string): Promise<Session | null> { try { const raw = await fs.readFile(this.sessionPath(id), 'utf-8'); return JSON.parse(raw) as Session; } catch { return null; } }
async save(session: Session): Promise<void> { const updated = { ...session, updatedAt: new Date().toISOString() }; // 원자적 쓰기: 임시 파일에 먼저 쓴 후 이름 변경 const tmpPath = `${this.sessionPath(session.id)}.tmp`; await fs.writeFile(tmpPath, JSON.stringify(updated, null, 2), 'utf-8'); await fs.rename(tmpPath, this.sessionPath(session.id)); }
async list(): Promise<Pick<Session, 'id' | 'task' | 'status' | 'createdAt'>[]> { const files = await fs.readdir(this.baseDir).catch(() => []); const sessions = await Promise.all( files .filter(f => f.endsWith('.json') && !f.endsWith('.tmp.json')) .map(async f => { const raw = await fs.readFile(path.join(this.baseDir, f), 'utf-8'); const s = JSON.parse(raw) as Session; return { id: s.id, task: s.task, status: s.status, createdAt: s.createdAt }; }) ); return sessions.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); }}ConfigResolver: 4단계 우선순위 해석
섹션 제목: “ConfigResolver: 4단계 우선순위 해석”interface AgentConfig { model: string; maxTokens: number; temperature: number; requireApproval: string[]; sessionDir: string;}
const CONFIG_DEFAULTS: AgentConfig = { model: 'claude-sonnet-4-5', maxTokens: 8192, temperature: 0, requireApproval: ['write_file', 'delete_file', 'run_command'], sessionDir: '.agent/sessions',};
class ConfigResolver { private cache: Partial<AgentConfig> | null = null;
async resolve(): Promise<AgentConfig> { if (this.cache) return this.cache as AgentConfig;
const layers = await Promise.all([ this.loadProjectConfig(), // 1순위: 프로젝트 로컬 this.loadUserConfig(), // 2순위: 사용자 글로벌 this.loadEnvConfig(), // 3순위: 환경 변수 ]);
// 우선순위 순서로 머지 (앞 레이어가 우선) this.cache = Object.assign({}, CONFIG_DEFAULTS, ...layers.reverse()); return this.cache as AgentConfig; }
private async loadProjectConfig(): Promise<Partial<AgentConfig>> { const paths = ['.agent/config.json', '.agentrc.json']; for (const p of paths) { try { const raw = await fs.readFile(p, 'utf-8'); return JSON.parse(raw); } catch { /* 없으면 건너뜀 */ } } return {}; }
private async loadUserConfig(): Promise<Partial<AgentConfig>> { const configPath = path.join(os.homedir(), '.config', 'agent', 'config.json'); try { const raw = await fs.readFile(configPath, 'utf-8'); return JSON.parse(raw); } catch { return {}; } }
private loadEnvConfig(): Partial<AgentConfig> { const config: Partial<AgentConfig> = {}; if (process.env.AGENT_MODEL) config.model = process.env.AGENT_MODEL; if (process.env.AGENT_MAX_TOKENS) config.maxTokens = parseInt(process.env.AGENT_MAX_TOKENS, 10); if (process.env.AGENT_SESSION_DIR) config.sessionDir = process.env.AGENT_SESSION_DIR; return config; }}OperationLog: git 롤백 지원
섹션 제목: “OperationLog: git 롤백 지원”에이전트가 파일을 수정할 때마다 로그를 기록하고, git commit을 생성합니다. 문제가 발생하면 특정 시점으로 롤백할 수 있습니다.
interface OperationEntry { id: string; timestamp: string; sessionId: string; type: 'file_write' | 'file_delete' | 'command_run'; details: Record<string, unknown>; gitCommitHash?: string;}
class OperationLog { constructor( private logPath: string, private gitEnabled: boolean, ) {}
async record(entry: Omit<OperationEntry, 'id' | 'timestamp'>): Promise<OperationEntry> { const full: OperationEntry = { ...entry, id: crypto.randomUUID(), timestamp: new Date().toISOString(), };
if (this.gitEnabled && entry.type !== 'command_run') { full.gitCommitHash = await this.gitCommit(entry); }
await this.append(full); return full; }
async rollbackTo(operationId: string): Promise<void> { const entries = await this.loadAll(); const target = entries.find(e => e.id === operationId);
if (!target?.gitCommitHash) { throw new Error(`롤백 가능한 git 커밋이 없습니다: ${operationId}`); }
// git reset으로 해당 커밋 이전 상태로 복구 await execAsync(`git checkout ${target.gitCommitHash}^ -- .`); }
private async gitCommit(entry: Omit<OperationEntry, 'id' | 'timestamp'>): Promise<string> { const message = `[agent] ${entry.type}: ${JSON.stringify(entry.details).slice(0, 80)}`; await execAsync('git add -A'); await execAsync(`git commit -m "${message}" --no-verify`); const { stdout } = await execAsync('git rev-parse HEAD'); return stdout.trim(); }
private async append(entry: OperationEntry): Promise<void> { const line = JSON.stringify(entry) + '\n'; await fs.appendFile(this.logPath, line, 'utf-8'); }
private async loadAll(): Promise<OperationEntry[]> { try { const raw = await fs.readFile(this.logPath, 'utf-8'); return raw.trim().split('\n').map(line => JSON.parse(line)); } catch { return []; } }}세션 재개 패턴
섹션 제목: “세션 재개 패턴”async function resumeOrCreate(sessionId: string | null, task: string): Promise<Session> { const store = new JsonSessionStore(config.sessionDir);
if (sessionId) { const existing = await store.load(sessionId); if (existing && existing.status === 'paused') { console.log(`세션 재개: ${existing.id} (${existing.conversationHistory.length}개 메시지)`); return { ...existing, status: 'running' }; } }
return store.create(task);}세션 재개 시 conversationHistory 를 그대로 MainAgent 에 주입하면 에이전트가 중단된 지점부터 작업을 이어갑니다.