aios-core
Version:
Synkra AIOS: AI-Orchestrated System for Full Stack Development - Core Framework
651 lines (575 loc) • 20.1 kB
JavaScript
/**
* Workflow State Manager
*
* @deprecated Superseded by session-state.js (Story 11.5).
* Bob uses SessionState exclusively. This module is maintained for
* backward compatibility with MasterOrchestrator (Epic 0) only.
* New code should use: .aios-core/core/orchestration/session-state.js
*
* File-based state persistence for guided workflow automation.
* Tracks workflow execution progress across Claude Code sessions.
*
* State files are stored at: .aios/{instance-id}-state.yaml
*
* Design constraints:
* - Claude Code = one agent per session, no background processes
* - Human remains the orchestrator; system tracks state and guides
* - Each session picks up where the last left off via state file
*
* @module workflow-state-manager
* @version 2.0.0
*/
const fs = require('fs').promises;
const path = require('path');
const yaml = require('js-yaml');
const WORKFLOW_STATE_VERSION = '2.0';
class WorkflowStateManager {
/**
* @param {Object} [options={}]
* @param {string} [options.basePath] - Project root path (defaults to cwd)
* @param {boolean} [options.verbose=false] - Enable verbose logging
*/
constructor(options = {}) {
this.basePath = options.basePath || process.cwd();
this.verbose = options.verbose || false;
this.stateDir = path.join(this.basePath, '.aios');
}
/**
* @private
*/
_log(message) {
if (this.verbose) {
console.log(`[WorkflowStateManager] ${message}`);
}
}
// ============ State CRUD ============
/**
* Create initial state from a workflow definition
*
* @param {Object} workflowData - Parsed workflow YAML (the workflow: block)
* @param {Object} instanceConfig - Instance configuration
* @param {string} [instanceConfig.target_context='core']
* @param {string} [instanceConfig.squad_name]
* @returns {Promise<Object>} Created state object
*/
async createState(workflowData, instanceConfig = {}) {
const wf = workflowData.workflow || workflowData;
if (!wf || !wf.id) {
throw new Error('workflow.id is required to create workflow state');
}
const instanceId = this._generateInstanceId(wf.id);
const state = {
state_version: WORKFLOW_STATE_VERSION,
workflow_id: wf.id,
workflow_name: wf.name || wf.id,
instance_id: instanceId,
target_context: instanceConfig.target_context || 'core',
squad_name: instanceConfig.squad_name || null,
started_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
status: 'active',
current_phase: null,
current_step_index: 0,
steps: [],
artifacts: [],
decisions: [],
};
// Build steps from workflow sequence
if (wf.sequence && Array.isArray(wf.sequence)) {
state.steps = wf.sequence.map((step, index) => {
// Determine action description
let action = '';
if (step.creates) action = `creates: ${step.creates}`;
else if (step.updates) action = `updates: ${step.updates}`;
else if (step.validates) action = `validates: ${step.validates}`;
else if (step.action) action = step.action;
else if (step.repeat_development_cycle) action = 'repeat_development_cycle';
else if (step.workflow_end) action = 'workflow_end';
const isOptional = step.optional === true || !!step.condition;
return {
step_index: index,
phase: step.agent ? `${step.agent}: ${action}` : action,
agent: step.agent || null,
action: action,
status: 'pending',
optional: isOptional,
started_at: null,
completed_at: null,
artifacts_created: [],
notes: step.notes || null,
session_id: null,
};
});
// Set initial phase
if (state.steps.length > 0) {
state.current_phase = state.steps[0].phase;
}
// Build initial artifact registry
for (let i = 0; i < wf.sequence.length; i++) {
const step = wf.sequence[i];
if (step.creates) {
state.artifacts.push({
name: step.creates,
created_by_step: i,
path: null,
status: 'pending',
});
}
}
}
// Ensure .aios directory exists
await this._ensureStateDir();
// Save state
await this.saveState(state);
this._log(`State created: ${instanceId}`);
return state;
}
// ============ Runtime-First Next Action ============
/**
* Build normalized execution state from runtime signals.
* Deterministic priority:
* blocked > qa_rejected > ci_red > completed > in_development > ready_for_validation > unknown
*
* @param {Object} [signals={}]
* @param {string} [signals.story_status]
* @param {string} [signals.qa_status]
* @param {string} [signals.ci_status]
* @param {boolean} [signals.has_uncommitted_changes]
* @returns {{ state: string, reasons: string[], normalized: Object }}
*/
evaluateExecutionState(signals = {}) {
const storyStatus = String(signals.story_status || 'unknown').toLowerCase();
const qaStatus = String(signals.qa_status || 'unknown').toLowerCase();
const ciStatus = String(signals.ci_status || 'unknown').toLowerCase();
const hasUncommitted = Boolean(signals.has_uncommitted_changes);
const reasons = [];
let state = 'unknown';
if (storyStatus === 'blocked') {
state = 'blocked';
reasons.push('story status is blocked');
} else if (qaStatus === 'rejected' || qaStatus === 'fail' || qaStatus === 'failed') {
state = 'qa_rejected';
reasons.push(`qa status is ${qaStatus}`);
} else if (ciStatus === 'red' || ciStatus === 'failed' || ciStatus === 'error') {
state = 'ci_red';
reasons.push(`ci status is ${ciStatus}`);
} else if (storyStatus === 'done' || storyStatus === 'completed') {
state = 'completed';
reasons.push(`story status is ${storyStatus}`);
} else if (storyStatus === 'in_progress' || storyStatus === 'review') {
if (hasUncommitted) {
state = 'in_development';
reasons.push('story in progress with uncommitted changes');
} else {
state = 'ready_for_validation';
reasons.push('story in progress with clean working tree');
}
}
return {
state,
reasons,
normalized: {
story_status: storyStatus,
qa_status: qaStatus,
ci_status: ciStatus,
has_uncommitted_changes: hasUncommitted,
},
};
}
/**
* Deterministic next-action recommendation for runtime-first flows.
*
* @param {Object} [signals={}]
* @param {Object} [options={}]
* @param {string} [options.story]
* @returns {{ state: string, command: string, agent: string, rationale: string, confidence: number }}
*/
getNextActionRecommendation(signals = {}, options = {}) {
const result = this.evaluateExecutionState(signals);
const storyArg = options.story ? ` ${options.story}` : '';
const map = {
blocked: {
command: `*orchestrate-status${storyArg}`,
agent: '@po',
rationale: 'Story is blocked. Surface blockers and unblock path first.',
confidence: 0.95,
},
qa_rejected: {
command: `*apply-qa-fixes${storyArg}`,
agent: '@dev',
rationale: 'QA rejected the story. Apply fixes before progressing.',
confidence: 0.95,
},
ci_red: {
command: '*run-tests',
agent: '@dev',
rationale: 'CI is red. Reproduce and fix failing tests/checks first.',
confidence: 0.92,
},
completed: {
command: `*close-story${storyArg}`,
agent: '@po',
rationale: 'Story is complete. Close lifecycle and move to next item.',
confidence: 0.9,
},
in_development: {
command: '*run-tests',
agent: '@dev',
rationale: 'Code is in progress with local changes. Validate before QA handoff.',
confidence: 0.85,
},
ready_for_validation: {
command: `*review-build${storyArg}`,
agent: '@qa',
rationale: 'Development appears stable. Proceed to QA structured review.',
confidence: 0.82,
},
unknown: {
command: `*next${storyArg}`,
agent: '@dev',
rationale: 'Context is incomplete. Request explicit workflow guidance.',
confidence: 0.4,
},
};
const recommendation = map[result.state] || map.unknown;
return {
state: result.state,
command: recommendation.command,
agent: recommendation.agent,
rationale: recommendation.rationale,
confidence: recommendation.confidence,
};
}
/**
* Load state from file
* @param {string} instanceId
* @returns {Promise<Object|null>} State object or null if not found
*/
async loadState(instanceId) {
const statePath = this._resolveStatePath(instanceId);
try {
const content = await fs.readFile(statePath, 'utf-8');
const state = yaml.load(content);
this._log(`State loaded: ${instanceId}`);
return state;
} catch (error) {
if (error.code === 'ENOENT') {
this._log(`State not found: ${instanceId}`);
return null;
}
throw error;
}
}
/**
* Save state to file
* @param {Object} state
* @returns {Promise<void>}
*/
async saveState(state) {
state.updated_at = new Date().toISOString();
const statePath = this._resolveStatePath(state.instance_id);
await this._ensureStateDir();
const content = yaml.dump(state, { lineWidth: 120, noRefs: true });
await fs.writeFile(statePath, content, 'utf-8');
this._log(`State saved: ${state.instance_id}`);
}
/**
* List all active workflow state files
* @returns {Promise<Array>} Array of { instanceId, workflowId, status, updatedAt }
*/
async listActiveWorkflows() {
const results = [];
try {
const files = await fs.readdir(this.stateDir);
const stateFiles = files.filter((f) => f.endsWith('-state.yaml'));
for (const file of stateFiles) {
try {
const content = await fs.readFile(path.join(this.stateDir, file), 'utf-8');
const state = yaml.load(content);
if (state && state.status === 'active') {
results.push({
instanceId: state.instance_id,
workflowId: state.workflow_id,
workflowName: state.workflow_name,
status: state.status,
updatedAt: state.updated_at,
progress: this.getProgress(state),
});
}
} catch (err) {
this._log(`Warning: Could not parse state file ${file}: ${err.message}`);
}
}
} catch {
this._log('State directory does not exist yet');
}
return results;
}
// ============ State Transitions ============
/**
* Advance to the next pending step
* @param {Object} state
* @returns {Object} Updated state
*/
advanceStep(state) {
const currentIndex = state.current_step_index;
// Find next pending step after current
for (let i = currentIndex + 1; i < state.steps.length; i++) {
if (state.steps[i].status === 'pending') {
state.current_step_index = i;
state.current_phase = state.steps[i].phase;
this._log(`Advanced to step ${i}: ${state.steps[i].phase}`);
return state;
}
}
// No more pending steps — workflow is complete
state.status = 'completed';
state.current_phase = 'Workflow complete';
this._log('All steps completed');
return state;
}
/**
* Mark a step as completed
* @param {Object} state
* @param {number} stepIndex
* @param {Array<string>} [artifacts=[]] - Artifacts created in this step
* @returns {Object} Updated state
*/
markStepCompleted(state, stepIndex, artifacts = []) {
if (stepIndex < 0 || stepIndex >= state.steps.length) {
throw new Error(`Invalid step index: ${stepIndex}`);
}
const step = state.steps[stepIndex];
step.status = 'completed';
step.completed_at = new Date().toISOString();
step.artifacts_created = artifacts;
// Update artifact registry
for (const artifactName of artifacts) {
const artifact = state.artifacts.find((a) => a.name === artifactName);
if (artifact) {
artifact.status = 'created';
}
}
this._log(`Step ${stepIndex} marked completed`);
return state;
}
/**
* Mark a step as skipped (only if optional)
* @param {Object} state
* @param {number} stepIndex
* @returns {Object} Updated state
*/
markStepSkipped(state, stepIndex) {
if (stepIndex < 0 || stepIndex >= state.steps.length) {
throw new Error(`Invalid step index: ${stepIndex}`);
}
const step = state.steps[stepIndex];
if (!step.optional) {
throw new Error(`Step ${stepIndex} is not optional and cannot be skipped`);
}
step.status = 'skipped';
step.completed_at = new Date().toISOString();
this._log(`Step ${stepIndex} marked skipped`);
return state;
}
// ============ Queries ============
/**
* Get the current step object
* @param {Object} state
* @returns {Object|null} Current step or null if all complete
*/
getCurrentStep(state) {
if (state.status === 'completed' || state.status === 'aborted') {
return null;
}
return state.steps[state.current_step_index] || null;
}
/**
* Get progress summary
* @param {Object} state
* @returns {Object} { completed, total, percentage, currentPhase }
*/
getProgress(state) {
const total = state.steps.length;
const completed = state.steps.filter(
(s) => s.status === 'completed' || s.status === 'skipped',
).length;
const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
return {
completed,
total,
percentage,
currentPhase: state.current_phase,
};
}
/**
* Get artifact status overview
* @param {Object} state
* @returns {Object} { created: [], pending: [] }
*/
getArtifactStatus(state) {
const created = state.artifacts.filter((a) => a.status === 'created');
const pending = state.artifacts.filter((a) => a.status === 'pending');
return { created, pending };
}
// ============ Integration ============
/**
* Generate context string for session handoff continuity
* @param {Object} state
* @returns {string} Handoff context string
*/
generateHandoffContext(state) {
const progress = this.getProgress(state);
const currentStep = this.getCurrentStep(state);
const artifacts = this.getArtifactStatus(state);
const lines = [
'## Workflow Handoff Context',
'',
`**Workflow:** ${state.workflow_name} (${state.instance_id})`,
`**Status:** ${state.status}`,
`**Progress:** ${progress.completed}/${progress.total} steps (${progress.percentage}%)`,
`**Current Phase:** ${state.current_phase}`,
'',
];
if (currentStep) {
lines.push('### Current Step');
lines.push(`- **Step ${currentStep.step_index + 1}:** ${currentStep.phase}`);
if (currentStep.agent) {
lines.push(`- **Agent:** @${currentStep.agent}`);
}
lines.push(`- **Action:** ${currentStep.action}`);
if (currentStep.notes) {
lines.push(`- **Notes:** ${currentStep.notes}`);
}
lines.push('');
}
if (artifacts.created.length > 0) {
lines.push('### Completed Artifacts');
for (const a of artifacts.created) {
lines.push(`- ${a.name}${a.path ? ` (${a.path})` : ''}`);
}
lines.push('');
}
if (artifacts.pending.length > 0) {
lines.push('### Pending Artifacts');
for (const a of artifacts.pending) {
lines.push(`- ${a.name}`);
}
lines.push('');
}
if (state.decisions.length > 0) {
lines.push('### Key Decisions');
for (const d of state.decisions) {
lines.push(`- Step ${d.step_index + 1}: ${d.decision}`);
}
lines.push('');
}
lines.push('### Resume Command');
lines.push(`\`*run-workflow ${state.workflow_id} continue\``);
return lines.join('\n');
}
/**
* Generate formatted status report with visual progress bar
* @param {Object} state
* @returns {string} Formatted report
*/
generateStatusReport(state) {
const progress = this.getProgress(state);
// Build progress bar
const barLength = 20;
const filledLength = Math.round((progress.percentage / 100) * barLength);
const bar = '█'.repeat(filledLength) + '░'.repeat(barLength - filledLength);
const lines = [
`=== Workflow Status: ${state.workflow_name} ===`,
`Instance: ${state.instance_id}`,
`Status: ${state.status.toUpperCase()}`,
`Started: ${state.started_at}`,
`Updated: ${state.updated_at}`,
'',
`Progress: [${bar}] ${progress.percentage}% (${progress.completed}/${progress.total})`,
'',
'--- Steps ---',
];
for (const step of state.steps) {
let statusIcon;
switch (step.status) {
case 'completed': statusIcon = '[x]'; break;
case 'skipped': statusIcon = '[-]'; break;
case 'in_progress': statusIcon = '[>]'; break;
default: statusIcon = '[ ]';
}
const isCurrent = step.step_index === state.current_step_index && state.status === 'active';
const marker = isCurrent ? ' <-- current' : '';
const optional = step.optional ? ' (optional)' : '';
lines.push(` ${statusIcon} Step ${step.step_index + 1}: ${step.phase}${optional}${marker}`);
}
// Artifacts summary
if (state.artifacts.length > 0) {
lines.push('');
lines.push('--- Artifacts ---');
for (const a of state.artifacts) {
const icon = a.status === 'created' ? '[x]' : '[ ]';
lines.push(` ${icon} ${a.name}${a.path ? ` → ${a.path}` : ''}`);
}
}
// Decisions
if (state.decisions.length > 0) {
lines.push('');
lines.push('--- Decisions ---');
for (const d of state.decisions) {
lines.push(` Step ${d.step_index + 1}: ${d.decision}`);
}
}
return lines.join('\n');
}
// ============ Agent Path Resolution ============
/**
* Resolve agent directory paths based on workflow state context.
* For hybrid workflows, returns both core and squad paths.
*
* @param {Object} state - Workflow state object
* @returns {{ corePath: string, squadPath: string|null }} Agent directory paths
*/
resolveAgentPaths(state) {
const corePath = path.join(this.basePath, '.aios-core', 'development', 'agents');
if (state.target_context === 'core') {
return { corePath, squadPath: null };
}
// Sanitize squad_name to prevent path traversal
const squadName = state.squad_name;
if (squadName && (squadName.includes('..') || squadName.includes('/') || squadName.includes('\\'))) {
return { corePath, squadPath: null };
}
// Both 'squad' and 'hybrid' have a squad path
const squadPath = squadName
? path.join(this.basePath, 'squads', squadName, 'agents')
: null;
return { corePath, squadPath };
}
// ============ Helpers ============
/**
* @private
*/
_resolveStatePath(instanceId) {
return path.join(this.stateDir, `${instanceId}-state.yaml`);
}
/**
* @private
*/
_generateInstanceId(workflowId) {
// Sanitize workflowId to prevent path traversal
if (!workflowId || workflowId.includes('..') || workflowId.includes('/') || workflowId.includes('\\')) {
throw new Error('workflow.id contains invalid path characters');
}
const date = new Date().toISOString().slice(0, 10).replace(/-/g, '');
const random = Math.random().toString(36).substring(2, 8);
return `${workflowId}-${date}-${random}`;
}
/**
* @private
*/
async _ensureStateDir() {
await fs.mkdir(this.stateDir, { recursive: true });
}
}
module.exports = { WorkflowStateManager };