aios-core
Version:
Synkra AIOS: AI-Orchestrated System for Full Stack Development - Core Framework
616 lines (544 loc) • 16.8 kB
JavaScript
/**
* Context Manager - Persists workflow state between phases
*
* DETERMINISTIC: All operations use file system operations (fs-extra),
* no AI involvement in state management.
*
* Responsibilities:
* - Save phase outputs to JSON files
* - Provide context to subsequent phases
* - Track workflow execution state
* - Enable workflow resume from any phase
*
* @module core/orchestration/context-manager
* @version 1.0.0
*/
const fs = require('fs-extra');
const path = require('path');
/**
* Manages workflow execution context and state persistence
*/
class ContextManager {
/**
* @param {string} workflowId - Unique workflow identifier
* @param {string} projectRoot - Project root directory
*/
constructor(workflowId, projectRoot) {
this.workflowId = workflowId;
this.projectRoot = projectRoot;
// State file path
this.stateDir = path.join(projectRoot, '.aios', 'workflow-state');
this.statePath = path.join(this.stateDir, `${workflowId}.json`);
this.handoffDir = path.join(this.stateDir, 'handoffs');
this.confidenceDir = path.join(this.stateDir, 'confidence');
// In-memory cache
this._stateCache = null;
}
/**
* Ensure state directory exists
* DETERMINISTIC: Pure fs operation
*/
async ensureStateDir() {
await fs.ensureDir(this.stateDir);
await fs.ensureDir(this.handoffDir);
await fs.ensureDir(this.confidenceDir);
}
/**
* Initialize or load existing workflow state
* @returns {Promise<Object>} Current state
*/
async initialize() {
await this.ensureStateDir();
if (await fs.pathExists(this.statePath)) {
this._stateCache = await fs.readJson(this.statePath);
} else {
this._stateCache = this._createInitialState();
await this._saveState();
}
return this._stateCache;
}
/**
* Create initial state structure
* @private
*/
_createInitialState() {
return {
workflowId: this.workflowId,
status: 'initialized',
startedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
currentPhase: 0,
phases: {},
metadata: {
projectRoot: this.projectRoot,
delivery_confidence: null,
},
};
}
/**
* Load state from disk (or cache)
* @returns {Promise<Object>} Current state
*/
async loadState() {
if (this._stateCache) {
return this._stateCache;
}
if (await fs.pathExists(this.statePath)) {
this._stateCache = await fs.readJson(this.statePath);
return this._stateCache;
}
return this._createInitialState();
}
/**
* Save state to disk
* DETERMINISTIC: Pure fs operation
* @private
*/
async _saveState() {
await this.ensureStateDir();
this._stateCache.updatedAt = new Date().toISOString();
await fs.writeJson(this.statePath, this._stateCache, { spaces: 2 });
}
/**
* Save phase output and update state
* @param {number} phaseNum - Phase number
* @param {Object} output - Phase output data
*/
async savePhaseOutput(phaseNum, output, options = {}) {
const state = await this.loadState();
const completedAt = new Date().toISOString();
state.currentPhase = phaseNum;
state.status = 'in_progress';
const handoff = this._buildHandoffPackage(phaseNum, output, state, options, completedAt);
state.phases[phaseNum] = {
...output,
completedAt,
handoff,
};
state.metadata = state.metadata || {};
state.metadata.delivery_confidence = this._calculateDeliveryConfidence(state);
this._stateCache = state;
await this._saveState();
await this._saveHandoffFile(handoff);
await this._saveConfidenceFile(state.metadata.delivery_confidence);
}
/**
* Get context for a specific phase
* Includes outputs from all previous phases
* @param {number} phaseNum - Target phase number
* @returns {Promise<Object>} Context for the phase
*/
async getContextForPhase(phaseNum) {
const state = await this.loadState();
// Collect outputs from all previous phases
const previousPhases = {};
for (let i = 1; i < phaseNum; i++) {
if (state.phases[i]) {
previousPhases[i] = state.phases[i];
}
}
const previousHandoffs = {};
for (const [phaseId, phaseData] of Object.entries(previousPhases)) {
if (phaseData && phaseData.handoff) {
previousHandoffs[phaseId] = phaseData.handoff;
}
}
return {
workflowId: this.workflowId,
currentPhase: phaseNum,
previousPhases,
previousHandoffs,
metadata: state.metadata,
};
}
/**
* Get all previous phase outputs
* @returns {Object} Map of phase number to output
*/
getPreviousPhaseOutputs() {
if (!this._stateCache) {
return {};
}
return this._stateCache.phases || {};
}
/**
* Get output from a specific phase
* @param {number} phaseNum - Phase number
* @returns {Object|null} Phase output or null
*/
getPhaseOutput(phaseNum) {
const outputs = this.getPreviousPhaseOutputs();
return outputs[phaseNum] || null;
}
/**
* Mark workflow as completed
*/
async markCompleted() {
const state = await this.loadState();
state.status = 'completed';
state.completedAt = new Date().toISOString();
this._stateCache = state;
await this._saveState();
}
/**
* Mark workflow as failed
* @param {string} error - Error message
* @param {number} failedPhase - Phase that failed
*/
async markFailed(error, failedPhase) {
const state = await this.loadState();
state.status = 'failed';
state.error = error;
state.failedPhase = failedPhase;
state.failedAt = new Date().toISOString();
this._stateCache = state;
await this._saveState();
}
/**
* Update workflow metadata
* @param {Object} metadata - Metadata to merge
*/
async updateMetadata(metadata) {
const state = await this.loadState();
state.metadata = { ...state.metadata, ...metadata };
this._stateCache = state;
await this._saveState();
}
/**
* Get the last completed phase number
* @returns {number} Last completed phase number (0 if none)
*/
getLastCompletedPhase() {
const phases = this.getPreviousPhaseOutputs();
const phaseNums = Object.keys(phases).map(Number);
return phaseNums.length > 0 ? Math.max(...phaseNums) : 0;
}
/**
* Check if a specific phase was completed
* @param {number} phaseNum - Phase number
* @returns {boolean} True if phase was completed
*/
isPhaseCompleted(phaseNum) {
const output = this.getPhaseOutput(phaseNum);
return output !== null && output.completedAt !== undefined;
}
/**
* Get workflow execution summary
* @returns {Object} Execution summary
*/
getSummary() {
const state = this._stateCache || this._createInitialState();
const phases = Object.keys(state.phases || {}).map(Number);
return {
workflowId: state.workflowId,
status: state.status,
startedAt: state.startedAt,
completedAt: state.completedAt,
currentPhase: state.currentPhase,
completedPhases: phases,
totalPhases: phases.length,
deliveryConfidence: state.metadata?.delivery_confidence || null,
};
}
/**
* Get latest delivery confidence score metadata.
* @returns {Object|null} Confidence payload
*/
getDeliveryConfidence() {
return this._stateCache?.metadata?.delivery_confidence || null;
}
/**
* Build standardized handoff package for inter-agent transfer.
* @private
*/
_buildHandoffPackage(phaseNum, output, state, options, completedAt) {
const handoffTarget = options.handoffTarget || {};
const decision = this._extractDecisionLog(output);
const evidence = this._extractEvidenceLinks(output);
const risks = this._extractOpenRisks(output);
return {
version: '1.0.0',
workflow_id: this.workflowId,
generated_at: completedAt,
from: {
phase: phaseNum,
agent: output.agent || null,
action: output.action || null,
task: output.task || null,
},
to: {
phase: handoffTarget.phase || null,
agent: handoffTarget.agent || null,
},
context_snapshot: {
workflow_status: state.status,
current_phase: state.currentPhase,
metadata: state.metadata || {},
},
decision_log: decision,
evidence_links: evidence,
open_risks: risks,
};
}
/**
* Persist handoff package as a dedicated artifact.
* @private
*/
async _saveHandoffFile(handoff) {
const phase = handoff?.from?.phase || 'unknown';
const filePath = path.join(this.handoffDir, `${this.workflowId}-phase-${phase}.handoff.json`);
await fs.ensureDir(this.handoffDir);
await fs.writeJson(filePath, handoff, { spaces: 2 });
}
/**
* Persist confidence score as a dedicated artifact.
* @private
*/
async _saveConfidenceFile(confidence) {
if (!confidence) return;
const filePath = path.join(this.confidenceDir, `${this.workflowId}.delivery-confidence.json`);
await fs.ensureDir(this.confidenceDir);
await fs.writeJson(filePath, confidence, { spaces: 2 });
}
/**
* @private
*/
_extractDecisionLog(output = {}) {
const result = output.result || {};
const entries = Array.isArray(result.decisions)
? result.decisions
: Array.isArray(result.decision_log)
? result.decision_log
: [];
const sourcePaths = [];
if (result.decisionLogPath) sourcePaths.push(result.decisionLogPath);
if (result.decision_log_path) sourcePaths.push(result.decision_log_path);
return {
entries,
source_paths: sourcePaths,
count: entries.length,
};
}
/**
* @private
*/
_extractEvidenceLinks(output = {}) {
const evidence = [];
const result = output.result || {};
const validation = output.validation || {};
if (Array.isArray(result.evidence_links)) {
evidence.push(...result.evidence_links);
}
if (Array.isArray(validation.checks)) {
for (const check of validation.checks) {
if (check.path) evidence.push(check.path);
if (check.checklist) evidence.push(check.checklist);
}
}
return [...new Set(evidence)];
}
/**
* @private
*/
_extractOpenRisks(output = {}) {
const result = output.result || {};
const risks = [];
if (Array.isArray(result.open_risks)) {
risks.push(...result.open_risks);
}
if (Array.isArray(result.risks)) {
risks.push(...result.risks);
}
if (Array.isArray(result.risk_register)) {
risks.push(...result.risk_register);
}
return risks;
}
/**
* Compute delivery confidence score from workflow state.
* @private
*/
_calculateDeliveryConfidence(state) {
const phases = Object.values(state.phases || {});
const weights = {
test_coverage: 0.25,
ac_completion: 0.30,
risk_score_inv: 0.20,
debt_score_inv: 0.15,
regression_clear: 0.10,
};
const components = {
test_coverage: this._calculateTestCoverage(phases),
ac_completion: this._calculateAcCompletion(phases),
risk_score_inv: this._calculateRiskInverseScore(phases),
debt_score_inv: this._calculateDebtInverseScore(phases),
regression_clear: this._calculateRegressionClear(phases),
};
const scoreBase = Object.keys(weights).reduce(
(acc, key) => acc + (components[key] || 0) * weights[key],
0,
);
const score = Number((scoreBase * 100).toFixed(2));
const threshold = this._resolveConfidenceThreshold();
return {
version: '1.0.0',
calculated_at: new Date().toISOString(),
score,
threshold,
gate_passed: score >= threshold,
formula: {
expression:
'confidence = (test_coverage*0.25 + ac_completion*0.30 + risk_score_inv*0.20 + debt_score_inv*0.15 + regression_clear*0.10) * 100',
weights,
},
components,
phase_count: phases.length,
};
}
/**
* @private
*/
_resolveConfidenceThreshold() {
const raw = process.env.AIOS_DELIVERY_CONFIDENCE_THRESHOLD;
const parsed = Number(raw);
return Number.isFinite(parsed) ? parsed : 70;
}
/**
* @private
*/
_calculateTestCoverage(phases) {
let totalChecks = 0;
let passedChecks = 0;
for (const phase of phases) {
const checks = phase?.validation?.checks;
if (!Array.isArray(checks)) continue;
for (const check of checks) {
totalChecks += 1;
if (check?.passed === true) passedChecks += 1;
}
}
if (totalChecks === 0) {
return phases.length > 0 ? 1 : 0;
}
return passedChecks / totalChecks;
}
/**
* @private
*/
_calculateAcCompletion(phases) {
let total = 0;
let done = 0;
let hasExplicitData = false;
for (const phase of phases) {
const result = phase?.result || {};
if (Number.isFinite(result.ac_total) && Number.isFinite(result.ac_completed)) {
hasExplicitData = true;
total += Math.max(0, result.ac_total);
done += Math.min(Math.max(0, result.ac_completed), Math.max(0, result.ac_total));
} else if (Array.isArray(result.acceptance_criteria)) {
hasExplicitData = true;
total += result.acceptance_criteria.length;
done += result.acceptance_criteria.filter((item) => item?.done || item?.status === 'done').length;
}
}
if (hasExplicitData && total > 0) {
return done / total;
}
if (phases.length === 0) {
return 0;
}
const successful = phases.filter((phase) => phase?.result?.status !== 'failed').length;
return successful / phases.length;
}
/**
* @private
*/
_calculateRiskInverseScore(phases) {
const totalRisks = phases.reduce((sum, phase) => {
const handoffRisks = Array.isArray(phase?.handoff?.open_risks) ? phase.handoff.open_risks.length : 0;
const resultRisks = this._extractOpenRisks(phase).length;
return sum + Math.max(handoffRisks, resultRisks);
}, 0);
return Math.max(0, 1 - totalRisks / 10);
}
/**
* @private
*/
_calculateDebtInverseScore(phases) {
const totalDebt = phases.reduce((sum, phase) => {
const result = phase?.result || {};
const explicitCount = Number.isFinite(result.technical_debt_count)
? result.technical_debt_count
: Number.isFinite(result.debt_count)
? result.debt_count
: 0;
const listCount = [
result.technical_debt,
result.debt_items,
result.todos,
result.hacks,
].reduce((listSum, list) => listSum + (Array.isArray(list) ? list.length : 0), 0);
return sum + explicitCount + listCount;
}, 0);
return Math.max(0, 1 - totalDebt / 10);
}
/**
* @private
*/
_calculateRegressionClear(phases) {
let totalRegressionChecks = 0;
let passedRegressionChecks = 0;
for (const phase of phases) {
const checks = phase?.validation?.checks;
if (!Array.isArray(checks)) continue;
for (const check of checks) {
const type = String(check?.type || '').toLowerCase();
const pathValue = String(check?.path || '').toLowerCase();
const checklist = String(check?.checklist || '').toLowerCase();
const isRegression = type.includes('regression')
|| pathValue.includes('regression')
|| checklist.includes('regression');
if (!isRegression) continue;
totalRegressionChecks += 1;
if (check?.passed === true) {
passedRegressionChecks += 1;
}
}
}
if (totalRegressionChecks === 0) {
return this._calculateTestCoverage(phases);
}
return passedRegressionChecks / totalRegressionChecks;
}
/**
* Reset workflow state (for re-execution)
* @param {boolean} keepMetadata - Whether to preserve metadata
*/
async reset(keepMetadata = true) {
// Preserve metadata if requested, defaulting to empty object if cache is null
const savedMetadata = keepMetadata ? (this._stateCache?.metadata ?? {}) : {};
this._stateCache = this._createInitialState();
// Merge saved metadata with default metadata from _createInitialState
this._stateCache.metadata = {
...this._stateCache.metadata,
...savedMetadata,
};
await this._saveState();
}
/**
* Export state for external use
* @returns {Object} Complete state object
*/
exportState() {
return { ...this._stateCache };
}
/**
* Import state from external source
* @param {Object} state - State to import
*/
async importState(state) {
this._stateCache = state;
await this._saveState();
}
}
module.exports = ContextManager;