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-5-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 롤백 지원”에이전트 전용의 격리된 worktree에서 변경 파일을 명시해 스냅샷을 기록합니다. 공유 저장소 전체를 git add -A로 스테이징하거나 훅을 건너뛰지 않습니다. 아래 예제는 대상 작업의 파일을 이전 내용으로 되돌리기 전에 diff와 승인을 확인합니다.
import { execFile } from 'node:child_process';import { promisify } from 'node:util';const execFileAsync = promisify(execFile);
interface OperationEntry { id: string; timestamp: string; sessionId: string; type: 'file_write' | 'file_delete' | 'command_run'; details: Record<string, unknown>; paths: string[]; // 이 작업이 소유한 저장소 상대 경로 gitCommitHash?: string;}
class OperationLog { constructor( private logPath: string, private gitEnabled: boolean, private approvalManager: ApprovalManager, ) {}
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}`); }
const source = `${target.gitCommitHash}^`; const { stdout: diff } = await execFileAsync('git', ['diff', source, '--', ...target.paths]); const approved = await this.approvalManager.request({ toolName: 'restore_snapshot', input: { source, paths: target.paths, diff }, description: '검토한 파일 변경을 스냅샷 이전 내용으로 복구합니다.', }); if (!approved) return; await execFileAsync('git', ['restore', '--source', source, '--staged', '--worktree', '--', ...target.paths]); }
private async gitCommit(entry: Omit<OperationEntry, 'id' | 'timestamp'>): Promise<string> { const message = `[agent] ${entry.type}: ${JSON.stringify(entry.details).slice(0, 80)}`; await execFileAsync('git', ['add', '--', ...entry.paths]); await execFileAsync('git', ['commit', '--only', '-m', message, '--', ...entry.paths]); const { stdout } = await execFileAsync('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 []; } }}각 작업의 paths는 비어 있지 않은 허용 경로 목록으로 검증합니다. 이 예제는 직렬 실행·전용 worktree를 전제합니다. 다른 작업이 같은 파일을 수정했으면 자동 복구하지 않습니다. 승인한 diff 이후 파일이 바뀌었는지도 다시 확인해야 합니다. 네트워크 전송·DB 수정·명령 실행 효과는 Git으로 되돌릴 수 없습니다.
세션 재개 패턴
섹션 제목: “세션 재개 패턴”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 에 주입하면 에이전트가 중단된 지점부터 작업을 이어갑니다.