Files
connectai/src/features/company/resumeStore.ts
T

135 lines
5.5 KiB
TypeScript

/**
* Disk persistence for company-turn resume state.
*
* 각 turn의 sessionDir 안에 `_resume.json`을 두고, dispatcher가 매 의미 있는
* 시점(plan 확정 / 각 stage 직후 / abort 시점)에 현재 상태를 덮어쓴다.
* 재개 시점에는 이 파일을 읽어 `nextIndex` 부터 dispatch 재개.
*
* 쓰기 정책:
* - 같은 파일을 매번 덮어쓰지만, 부분쓰기로 깨지면 다음 재개가 실패하므로
* tmp → rename으로 원자성 보장 (POSIX rename은 atomic, Windows도 NTFS면 OK).
* - 실패는 로그만 남기고 turn 흐름은 절대 막지 않는다 (resume은 nice-to-have).
* - 자연 종료(completed/failed) 후에는 같은 파일에 status='completed'로 마킹.
* 물리적으로 지우진 않음 — 향후 분석/감사 용도로 유지.
*/
import * as fs from 'fs';
import * as path from 'path';
import * as vscode from 'vscode';
import { logError, logInfo } from '../../utils';
import { resolveCompanyBase } from './sessionStore';
import { CompanyResumeState } from './types';
const RESUME_FILE = '_resume.json';
/**
* Write the resume state atomically. tmp 파일에 쓰고 rename으로 덮어써서 부분
* 쓰기 도중 크래시가 나도 기존 _resume.json은 일관된 상태로 남도록 한다.
*/
export function writeResumeState(sessionDir: string, state: CompanyResumeState): void {
const target = path.join(sessionDir, RESUME_FILE);
const tmp = target + '.tmp';
try {
fs.mkdirSync(sessionDir, { recursive: true });
fs.writeFileSync(tmp, JSON.stringify(state, null, 2), 'utf8');
fs.renameSync(tmp, target);
} catch (e: any) {
logError('company.resumeStore: write failed.', {
sessionDir: path.basename(sessionDir),
error: e?.message ?? String(e),
});
}
}
/**
* 해당 세션의 resume 상태를 읽어온다. 파일이 없거나 파싱 실패 시 null.
* 호환성 검사: version이 일치하지 않으면 안전하게 거부 (재개 불가로 취급).
*/
export function readResumeState(sessionDir: string): CompanyResumeState | null {
const p = path.join(sessionDir, RESUME_FILE);
if (!fs.existsSync(p)) return null;
try {
const raw = fs.readFileSync(p, 'utf8');
const parsed = JSON.parse(raw) as CompanyResumeState;
if (!parsed || parsed.version !== 1) return null;
if (!parsed.timestamp || !parsed.userPrompt || !parsed.plan) return null;
if (!Array.isArray(parsed.agentOutputs)) return null;
if (typeof parsed.nextIndex !== 'number' || parsed.nextIndex < 0) return null;
if (!['in-progress', 'aborted', 'completed', 'failed'].includes(parsed.status)) return null;
return parsed;
} catch (e: any) {
logError('company.resumeStore: read failed.', {
sessionDir: path.basename(sessionDir),
error: e?.message ?? String(e),
});
return null;
}
}
/**
* 모든 세션 디렉터리를 스캔해서 "이어서 진행 가능한" 것만 골라낸다.
* 기준:
* - `_resume.json`이 존재
* - status === 'in-progress' || 'aborted' (자연 종료된 것 제외)
* - agentOutputs / nextIndex가 plan보다 짧음 (정말 미완)
* 결과는 lastUpdatedAt 내림차순 (최근에 멈춘 것이 위로).
*/
export function listResumableSessions(context: vscode.ExtensionContext): CompanyResumeState[] {
const base = path.join(resolveCompanyBase(context), 'sessions');
if (!fs.existsSync(base)) return [];
let entries: string[];
try {
entries = fs.readdirSync(base);
} catch (e: any) {
logError('company.resumeStore: list failed.', { error: e?.message ?? String(e) });
return [];
}
const out: CompanyResumeState[] = [];
for (const name of entries) {
const dir = path.join(base, name);
try {
if (!fs.statSync(dir).isDirectory()) continue;
} catch { continue; }
const state = readResumeState(dir);
if (!state) continue;
if (state.status !== 'in-progress' && state.status !== 'aborted') continue;
// sanity: nextIndex가 plan.tasks 길이 이상이면 사실상 완료 — skip.
const totalTasks = state.plan?.tasks?.length ?? 0;
if (totalTasks > 0 && state.nextIndex >= totalTasks) continue;
out.push(state);
}
out.sort((a, b) => (b.lastUpdatedAt || '').localeCompare(a.lastUpdatedAt || ''));
return out;
}
/**
* 세션의 resume 상태를 마킹. 자연 종료 시 status='completed' (또는 'failed')로
* 덮어써서 listResumable에서 자동으로 빠지게 한다.
*
* 파일을 물리적으로 지우지 않는 이유: 사용자의 _resume.json이 디버깅/감사
* 경로에서 유용할 수 있고, 디스크 용량도 미미함 (~수 KB).
*/
export function markResumeStatus(
sessionDir: string,
status: CompanyResumeState['status'],
abortReason?: string,
): void {
const cur = readResumeState(sessionDir);
if (!cur) return;
const next: CompanyResumeState = {
...cur,
status,
abortReason: abortReason ?? cur.abortReason,
lastUpdatedAt: new Date().toISOString(),
};
writeResumeState(sessionDir, next);
logInfo('company.resumeStore: status updated.', {
sessionDir: path.basename(sessionDir),
status,
});
}
/** 절대 세션 디렉터리 경로 헬퍼 — 재개 진입점이 timestamp만 받았을 때 사용. */
export function resolveSessionDir(context: vscode.ExtensionContext, timestamp: string): string {
return path.join(resolveCompanyBase(context), 'sessions', timestamp);
}