aios-core
Version:
Synkra AIOS: AI-Orchestrated System for Full Stack Development - Core Framework
876 lines (757 loc) • 24.7 kB
JavaScript
/**
* Session State Persistence Module
*
* Story 11.5: Projeto Bob - Session State Persistence
* ADR-011: Unified Session State (absorbs Workflow State from 11.3)
*
* Provides session state persistence to disk for:
* - Resume work days later without losing context
* - Crash recovery with state restoration
* - Progress tracking across epic/story development
*
* @module core/orchestration/session-state
* @version 1.1.0
*/
'use strict';
const fs = require('fs').promises;
const fsSync = require('fs');
const path = require('path');
const yaml = require('js-yaml');
// Constants
const SESSION_STATE_VERSION = '1.2';
const SESSION_STATE_FILENAME = '.session-state.yaml';
const CRASH_THRESHOLD_MINUTES = 30;
const LEGACY_WORKFLOW_STATE_DIR = '.aios/workflow-state';
/**
* Action types for session state tracking
* @enum {string}
*/
const ActionType = {
GO: 'GO',
PAUSE: 'PAUSE',
REVIEW: 'REVIEW',
ABORT: 'ABORT',
PHASE_CHANGE: 'PHASE_CHANGE',
EPIC_STARTED: 'EPIC_STARTED',
STORY_STARTED: 'STORY_STARTED',
STORY_COMPLETED: 'STORY_COMPLETED',
CHECKPOINT_REACHED: 'CHECKPOINT_REACHED',
ERROR_OCCURRED: 'ERROR_OCCURRED',
};
/**
* Phase names for development cycle
* @enum {string}
*/
const Phase = {
VALIDATION: 'validation',
DEVELOPMENT: 'development',
SELF_HEALING: 'self_healing',
QUALITY_GATE: 'quality_gate',
PUSH: 'push',
CHECKPOINT: 'checkpoint',
};
/**
* Resume options for session recovery
* @enum {string}
*/
const ResumeOption = {
CONTINUE: 'continue',
REVIEW: 'review',
RESTART: 'restart',
DISCARD: 'discard',
};
/**
* Session State Manager class
*/
class SessionState {
/**
* Creates a new SessionState instance
* @param {string} projectRoot - Project root directory
* @param {Object} options - Options
*/
constructor(projectRoot, options = {}) {
this.projectRoot = projectRoot;
this.options = {
debug: false,
autoMigrate: true,
...options,
};
this.stateFilePath = path.join(projectRoot, 'docs/stories', SESSION_STATE_FILENAME);
this.legacyStatePath = path.join(projectRoot, LEGACY_WORKFLOW_STATE_DIR);
this.state = null;
}
/**
* Gets the path to the session state file
* @returns {string} Session state file path
*/
getStateFilePath() {
return this.stateFilePath;
}
/**
* Checks if a session state file exists
* @returns {Promise<boolean>} True if state file exists
*/
async exists() {
try {
await fs.access(this.stateFilePath);
return true;
} catch {
return false;
}
}
/**
* Creates a new session state for an epic
* @param {Object} epicInfo - Epic information
* @param {string} epicInfo.id - Epic ID
* @param {string} epicInfo.title - Epic title
* @param {number} epicInfo.totalStories - Total number of stories
* @param {string[]} epicInfo.storyIds - Array of story IDs
* @param {string} branch - Git branch name
* @returns {Promise<Object>} Created session state
*/
async createSessionState(epicInfo, branch = 'main') {
const now = new Date().toISOString();
this.state = {
session_state: {
version: SESSION_STATE_VERSION,
last_updated: now,
// Epic Context (AC2)
epic: {
id: epicInfo.id,
title: epicInfo.title,
total_stories: epicInfo.totalStories,
},
// Progress Tracking (AC3)
progress: {
current_story: epicInfo.storyIds[0] || null,
stories_done: [],
stories_pending: [...epicInfo.storyIds],
},
// Workflow State (ADR-011 - migrated from 11.3)
workflow: {
current_phase: null,
attempt_count: 0,
phase_results: {},
started_at: now,
},
// Last Action (AC4)
last_action: {
type: ActionType.EPIC_STARTED,
timestamp: now,
story: epicInfo.storyIds[0] || null,
phase: null,
},
// Context Snapshot (AC5)
context_snapshot: {
files_modified: 0,
executor_distribution: {},
last_executor: null,
branch: branch,
},
// Resume Instructions (auto-generated)
resume_instructions: this.generateResumeInstructions({
epicTitle: epicInfo.title,
currentStory: epicInfo.storyIds[0],
storiesDone: 0,
totalStories: epicInfo.totalStories,
lastPhase: null,
lastExecutor: null,
}),
// Story 12.7: Session-level overrides (temporary, not persisted to user config)
overrides: {
educational_mode: null, // null = not overridden, true/false = session override
},
},
};
await this.save();
if (this.options.debug) {
console.log(`[SessionState] Created new session state: ${this.stateFilePath}`);
}
return this.state;
}
/**
* Loads session state from disk
* @returns {Promise<Object|null>} Session state or null if not found
*/
async loadSessionState() {
// Check for existing session state
if (await this.exists()) {
const content = await fs.readFile(this.stateFilePath, 'utf8');
this.state = yaml.load(content);
if (this.options.debug) {
console.log(`[SessionState] Loaded session state from: ${this.stateFilePath}`);
}
return this.state;
}
// Check for legacy workflow state and migrate (ADR-011)
if (this.options.autoMigrate) {
const migrated = await this.migrateFromWorkflowState();
if (migrated) {
return this.state;
}
}
return null;
}
/**
* Updates the session state
* @param {Object} updates - Fields to update
* @returns {Promise<Object>} Updated session state
*/
async updateSessionState(updates) {
if (!this.state) {
throw new Error('Session state not initialized. Call loadSessionState() or createSessionState() first.');
}
const now = new Date().toISOString();
// Update last_updated timestamp
this.state.session_state.last_updated = now;
// Apply updates to specific sections
if (updates.progress) {
this.state.session_state.progress = {
...this.state.session_state.progress,
...updates.progress,
};
}
if (updates.workflow) {
this.state.session_state.workflow = {
...this.state.session_state.workflow,
...updates.workflow,
};
}
if (updates.last_action) {
this.state.session_state.last_action = {
...updates.last_action,
timestamp: now,
};
}
if (updates.context_snapshot) {
this.state.session_state.context_snapshot = {
...this.state.session_state.context_snapshot,
...updates.context_snapshot,
};
}
// Story 12.7: Handle overrides updates
if (updates.overrides) {
this.state.session_state.overrides = {
...(this.state.session_state.overrides || {}),
...updates.overrides,
};
}
// Regenerate resume instructions
this.state.session_state.resume_instructions = this.generateResumeInstructions({
epicTitle: this.state.session_state.epic.title,
currentStory: this.state.session_state.progress.current_story,
storiesDone: this.state.session_state.progress.stories_done.length,
totalStories: this.state.session_state.epic.total_stories,
lastPhase: this.state.session_state.last_action.phase,
lastExecutor: this.state.session_state.context_snapshot.last_executor,
});
await this.save();
return this.state;
}
/**
* Records a phase change in the session state
* @param {string} phase - New phase name
* @param {string} storyId - Story ID
* @param {string} executor - Executor agent
* @returns {Promise<Object>} Updated session state
*/
async recordPhaseChange(phase, storyId, executor) {
const updates = {
workflow: {
current_phase: phase,
},
last_action: {
type: ActionType.PHASE_CHANGE,
story: storyId,
phase: phase,
},
context_snapshot: {
last_executor: executor,
},
};
// Update executor distribution
if (this.state && executor) {
const distribution = this.state.session_state.context_snapshot.executor_distribution || {};
distribution[executor] = (distribution[executor] || 0) + 1;
updates.context_snapshot.executor_distribution = distribution;
}
return this.updateSessionState(updates);
}
/**
* Records story completion
* @param {string} storyId - Completed story ID
* @param {string} nextStoryId - Next story ID (optional)
* @returns {Promise<Object>} Updated session state
*/
async recordStoryCompleted(storyId, nextStoryId = null) {
const storiesDone = [...this.state.session_state.progress.stories_done, storyId];
const storiesPending = this.state.session_state.progress.stories_pending.filter(
(id) => id !== storyId,
);
return this.updateSessionState({
progress: {
current_story: nextStoryId || (storiesPending[0] || null),
stories_done: storiesDone,
stories_pending: storiesPending,
},
workflow: {
current_phase: null,
attempt_count: 0,
phase_results: {},
},
last_action: {
type: ActionType.STORY_COMPLETED,
story: storyId,
phase: null,
},
});
}
/**
* Records a user pause action
* @param {string} storyId - Current story ID
* @param {string} phase - Current phase
* @returns {Promise<Object>} Updated session state
*/
async recordPause(storyId, phase) {
return this.updateSessionState({
last_action: {
type: ActionType.PAUSE,
story: storyId,
phase: phase,
},
});
}
/**
* Sets a session-level override (Story 12.7 - AC6)
* Session overrides are temporary and only last for the current session.
*
* @param {string} key - Override key (e.g., 'educational_mode')
* @param {*} value - Override value (null to clear)
* @returns {Promise<Object>} Updated session state
*/
async setSessionOverride(key, value) {
if (!this.state) {
throw new Error('Session state not initialized. Call loadSessionState() or createSessionState() first.');
}
const now = new Date().toISOString();
// Ensure overrides field exists (backward compatibility)
if (!this.state.session_state.overrides) {
this.state.session_state.overrides = {};
}
// Set the override
this.state.session_state.overrides[key] = value;
this.state.session_state.last_updated = now;
await this.save();
if (this.options.debug) {
console.log(`[SessionState] Set session override: ${key} = ${value}`);
}
return this.state;
}
/**
* Gets a session-level override (Story 12.7 - AC6)
*
* @param {string} key - Override key (e.g., 'educational_mode')
* @returns {*} Override value or null if not set
*/
getSessionOverride(key) {
if (!this.state?.session_state?.overrides) {
return null;
}
return this.state.session_state.overrides[key] ?? null;
}
/**
* Clears a session-level override (Story 12.7 - AC6)
*
* @param {string} key - Override key to clear
* @returns {Promise<Object>} Updated session state
*/
async clearSessionOverride(key) {
return this.setSessionOverride(key, null);
}
/**
* Gets all session overrides
* @returns {Object} All current overrides
*/
getSessionOverrides() {
if (!this.state?.session_state?.overrides) {
return {};
}
return { ...this.state.session_state.overrides };
}
/**
* Generates human-readable resume instructions
* @param {Object} context - Context for instructions
* @returns {string} Resume instructions text
*/
generateResumeInstructions(context) {
const { currentStory, storiesDone, totalStories, lastPhase, lastExecutor } = context;
let instructions = '';
if (currentStory) {
instructions += `Story ${currentStory} estava em fase de ${lastPhase || 'início'}.\n`;
}
if (lastExecutor) {
instructions += `${lastExecutor} estava trabalhando no desenvolvimento.\n`;
}
instructions += `Progresso: ${storiesDone} de ${totalStories} stories completas.\n`;
instructions += 'Próximo passo: continuar implementação ou revisar o que foi feito.';
return instructions;
}
/**
* Detects if session was interrupted by a crash
* @returns {Promise<Object>} Crash detection result
*/
async detectCrash() {
if (!this.state) {
await this.loadSessionState();
}
if (!this.state) {
return { isCrash: false, reason: 'No session state found' };
}
const lastUpdated = new Date(this.state.session_state.last_updated);
const lastActionType = this.state.session_state.last_action.type;
const now = new Date();
// Calculate minutes since last update
const minutesSinceUpdate = (now - lastUpdated) / (1000 * 60);
// Crash detected if:
// - last_updated > 30 min AND
// - last_action.type is NOT PAUSE or COMPLETE (STORY_COMPLETED)
const normalEndStates = [ActionType.PAUSE, ActionType.STORY_COMPLETED, ActionType.ABORT];
const isCrash = minutesSinceUpdate > CRASH_THRESHOLD_MINUTES && !normalEndStates.includes(lastActionType);
return {
isCrash,
minutesSinceUpdate: Math.round(minutesSinceUpdate),
lastActionType,
lastPhase: this.state.session_state.last_action.phase,
lastStory: this.state.session_state.last_action.story,
reason: isCrash
? `Session appears to have crashed ${Math.round(minutesSinceUpdate)} minutes ago during ${lastActionType}`
: 'Session ended normally',
};
}
/**
* Gets resume options menu
* @returns {Object} Resume options with labels
*/
getResumeOptions() {
return {
[ResumeOption.CONTINUE]: {
label: 'Continuar de onde parou',
description: 'Resume from last saved state',
},
[ResumeOption.REVIEW]: {
label: 'Revisar o que foi feito',
description: 'Show progress summary before continuing',
},
[ResumeOption.RESTART]: {
label: `Recomeçar story ${this.state?.session_state.progress.current_story} do zero`,
description: 'Restart current story from beginning',
},
[ResumeOption.DISCARD]: {
label: 'Iniciar novo épico (descarta sessão)',
description: 'Discard current session and start fresh',
},
};
}
/**
* Generates a formatted resume summary for display
* @returns {string} Formatted resume summary
*/
getResumeSummary() {
if (!this.state) {
return 'No session state loaded.';
}
const { epic, progress, last_action } = this.state.session_state;
return `🔄 Sessão anterior detectada!
Epic: ${epic.title}
Progresso: ${progress.stories_done.length} de ${epic.total_stories} stories completas
Último story: ${progress.current_story}
Fase quando pausou: ${last_action.phase || 'N/A'}
O que você quer fazer?
[1] Continuar de onde parou
[2] Revisar o que foi feito
[3] Recomeçar story ${progress.current_story} do zero
[4] Iniciar novo épico (descarta sessão)`;
}
/**
* Handles resume option selection
* @param {string} option - Selected resume option
* @returns {Promise<Object>} Resume action result
*/
async handleResumeOption(option) {
switch (option) {
case ResumeOption.CONTINUE:
return {
action: 'continue',
story: this.state.session_state.progress.current_story,
phase: this.state.session_state.workflow.current_phase,
};
case ResumeOption.REVIEW:
return {
action: 'review',
summary: this.getProgressSummary(),
};
case ResumeOption.RESTART:
// Reset workflow state but keep progress
await this.updateSessionState({
workflow: {
current_phase: null,
attempt_count: 0,
phase_results: {},
started_at: new Date().toISOString(),
},
last_action: {
type: ActionType.STORY_STARTED,
story: this.state.session_state.progress.current_story,
phase: null,
},
});
return {
action: 'restart',
story: this.state.session_state.progress.current_story,
};
case ResumeOption.DISCARD:
await this.discard();
return {
action: 'discard',
message: 'Session discarded. Ready for new epic.',
};
default:
throw new Error(`Unknown resume option: ${option}`);
}
}
/**
* Gets a detailed progress summary
* @returns {Object} Progress summary
*/
getProgressSummary() {
if (!this.state) {
return null;
}
const { epic, progress, workflow, context_snapshot } = this.state.session_state;
return {
epic: {
id: epic.id,
title: epic.title,
totalStories: epic.total_stories,
},
progress: {
completed: progress.stories_done.length,
total: epic.total_stories,
percentage: Math.round((progress.stories_done.length / epic.total_stories) * 100),
storiesDone: progress.stories_done,
storiesPending: progress.stories_pending,
currentStory: progress.current_story,
},
workflow: {
currentPhase: workflow.current_phase,
attemptCount: workflow.attempt_count,
phaseResults: workflow.phase_results,
},
context: {
filesModified: context_snapshot.files_modified,
executorDistribution: context_snapshot.executor_distribution,
branch: context_snapshot.branch,
},
};
}
/**
* Migrates from legacy workflow state (ADR-011)
* @returns {Promise<boolean>} True if migration occurred
*/
async migrateFromWorkflowState() {
try {
// Check if legacy workflow state directory exists
if (!fsSync.existsSync(this.legacyStatePath)) {
return false;
}
// Find state files in legacy directory
const files = await fs.readdir(this.legacyStatePath);
const stateFiles = files.filter((f) => f.endsWith('-state.yaml'));
if (stateFiles.length === 0) {
return false;
}
if (this.options.debug) {
console.log(`[SessionState] Found ${stateFiles.length} legacy workflow state files to migrate`);
}
// Read the most recent state file
const latestStateFile = stateFiles.sort().pop();
const legacyContent = await fs.readFile(
path.join(this.legacyStatePath, latestStateFile),
'utf8',
);
const legacyState = yaml.load(legacyContent);
// Create new session state from legacy
this.state = {
session_state: {
version: SESSION_STATE_VERSION,
last_updated: new Date().toISOString(),
// Create minimal epic context (will need to be updated)
epic: {
id: 'migrated',
title: 'Migrated from Workflow State',
total_stories: 1,
},
// Migrate progress
progress: {
current_story: legacyState.currentStory || null,
stories_done: [],
stories_pending: [],
},
// Migrate workflow state
workflow: {
current_phase: legacyState.currentPhase || null,
attempt_count: legacyState.attemptCount || 0,
phase_results: legacyState.phaseResults || {},
started_at: legacyState.startedAt || new Date().toISOString(),
},
// Create last action
last_action: {
type: ActionType.PHASE_CHANGE,
timestamp: legacyState.lastUpdated || new Date().toISOString(),
story: legacyState.currentStory || null,
phase: legacyState.currentPhase || null,
},
// Migrate context
context_snapshot: {
files_modified: 0,
executor_distribution: {},
last_executor: legacyState.executor || null,
branch: 'main',
},
resume_instructions: 'Migrated from legacy workflow state. Please review and continue.',
// Story 12.7: Initialize overrides (empty on migration)
overrides: {
educational_mode: null,
},
},
};
// Save migrated state
await this.save();
// Archive legacy files (rename, don't delete)
for (const file of stateFiles) {
const oldPath = path.join(this.legacyStatePath, file);
const newPath = path.join(this.legacyStatePath, `${file}.migrated`);
await fs.rename(oldPath, newPath);
}
if (this.options.debug) {
console.log('[SessionState] Migration complete. Legacy files archived.');
}
return true;
} catch (error) {
if (this.options.debug) {
console.log(`[SessionState] Migration failed: ${error.message}`);
}
return false;
}
}
/**
* Saves the current state to disk
* @returns {Promise<void>}
*/
async save() {
if (!this.state) {
throw new Error('No state to save');
}
// Ensure directory exists
const dir = path.dirname(this.stateFilePath);
await fs.mkdir(dir, { recursive: true });
// Write state file
const content = yaml.dump(this.state, {
lineWidth: 120,
noRefs: true,
});
await fs.writeFile(this.stateFilePath, content, 'utf8');
}
/**
* Discards the current session state
* @returns {Promise<void>}
*/
async discard() {
if (await this.exists()) {
// Archive instead of delete
const archivePath = `${this.stateFilePath}.discarded.${Date.now()}`;
await fs.rename(this.stateFilePath, archivePath);
if (this.options.debug) {
console.log(`[SessionState] Session archived to: ${archivePath}`);
}
}
this.state = null;
}
/**
* Validates session state schema
* @param {Object} state - State to validate
* @returns {Object} Validation result
*/
static validateSchema(state) {
const errors = [];
if (!state?.session_state) {
errors.push('Missing session_state root');
return { isValid: false, errors };
}
const ss = state.session_state;
// Validate version
if (!ss.version) {
errors.push('Missing version field');
}
// Validate epic (AC2)
if (!ss.epic?.id || !ss.epic?.title || ss.epic?.total_stories === undefined) {
errors.push('Invalid epic field: requires id, title, total_stories');
}
// Validate progress (AC3)
if (!ss.progress || !Array.isArray(ss.progress.stories_done) || !Array.isArray(ss.progress.stories_pending)) {
errors.push('Invalid progress field: requires current_story, stories_done[], stories_pending[]');
}
// Validate last_action (AC4)
if (!ss.last_action?.type || !ss.last_action?.timestamp) {
errors.push('Invalid last_action field: requires type, timestamp, story, phase');
}
// Validate context_snapshot (AC5)
if (ss.context_snapshot?.files_modified === undefined) {
errors.push('Invalid context_snapshot field: requires files_modified, executor_distribution, branch');
}
return {
isValid: errors.length === 0,
errors,
};
}
}
/**
* Creates a new SessionState instance
* @param {string} projectRoot - Project root directory
* @param {Object} options - Options
* @returns {SessionState} SessionState instance
*/
function createSessionState(projectRoot, options = {}) {
return new SessionState(projectRoot, options);
}
/**
* Checks if a session state exists for the project
* @param {string} projectRoot - Project root directory
* @returns {Promise<boolean>} True if session state exists
*/
async function sessionStateExists(projectRoot) {
const sessionState = new SessionState(projectRoot);
return sessionState.exists();
}
/**
* Loads session state from project
* @param {string} projectRoot - Project root directory
* @param {Object} options - Options
* @returns {Promise<Object|null>} Session state or null
*/
async function loadSessionState(projectRoot, options = {}) {
const sessionState = new SessionState(projectRoot, options);
return sessionState.loadSessionState();
}
module.exports = {
SessionState,
createSessionState,
sessionStateExists,
loadSessionState,
ActionType,
Phase,
ResumeOption,
SESSION_STATE_VERSION,
SESSION_STATE_FILENAME,
CRASH_THRESHOLD_MINUTES,
};