519 lines
14 KiB
JavaScript
519 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
/**
|
|
* Context Bus - In-Memory Context Management with Schema Validation
|
|
*
|
|
* Replaces file-based context with structured data store featuring:
|
|
* - Type-safe context access and updates
|
|
* - Automatic validation against schemas
|
|
* - Reactive updates (pub/sub pattern)
|
|
* - Transaction semantics with rollback
|
|
* - Context snapshots for debugging
|
|
* - Cross-agent data propagation
|
|
*
|
|
* @version 2.0.0
|
|
* @date 2025-11-13
|
|
*/
|
|
|
|
import fs from 'fs/promises';
|
|
import path from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
import Ajv from 'ajv';
|
|
import addFormats from 'ajv-formats';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
const PROJECT_ROOT = path.resolve(__dirname, '../../..');
|
|
|
|
// ============================================================================
|
|
// Context Bus Implementation
|
|
// ============================================================================
|
|
|
|
class ContextBus {
|
|
constructor(sessionSchema) {
|
|
// Core state
|
|
this.context = {};
|
|
this.schema = sessionSchema;
|
|
this.subscribers = new Map(); // path → Set<callback>
|
|
this.history = []; // For time-travel debugging
|
|
this.checkpoints = []; // For rollback
|
|
this.validationCache = new Map(); // Memoize validation results
|
|
|
|
// Setup validator
|
|
this.ajv = new Ajv({ allErrors: true, strict: false });
|
|
addFormats(this.ajv);
|
|
|
|
// Initialize context from schema
|
|
if (sessionSchema) {
|
|
this.context = this.initializeFromSchema(sessionSchema);
|
|
}
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Initialization
|
|
// ==========================================================================
|
|
|
|
/**
|
|
* Initialize context structure from JSON schema
|
|
*/
|
|
initializeFromSchema(schema) {
|
|
const context = {};
|
|
|
|
if (schema.properties) {
|
|
for (const [key, propSchema] of Object.entries(schema.properties)) {
|
|
if (propSchema.default !== undefined) {
|
|
context[key] = propSchema.default;
|
|
} else if (propSchema.type === 'object') {
|
|
context[key] = this.initializeFromSchema(propSchema);
|
|
} else if (propSchema.type === 'array') {
|
|
context[key] = [];
|
|
} else if (propSchema.type === 'string') {
|
|
context[key] = '';
|
|
} else if (propSchema.type === 'number' || propSchema.type === 'integer') {
|
|
context[key] = 0;
|
|
} else if (propSchema.type === 'boolean') {
|
|
context[key] = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
return context;
|
|
}
|
|
|
|
/**
|
|
* Load context from file (for migration from file-based system)
|
|
*/
|
|
async loadFromFile(filePath) {
|
|
try {
|
|
const content = await fs.readFile(filePath, 'utf-8');
|
|
const data = JSON.parse(content);
|
|
this.context = data;
|
|
this.recordHistory('load_from_file', null, null, data);
|
|
return this.context;
|
|
} catch (error) {
|
|
throw new Error(`Failed to load context from file: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Save context to file (for persistence)
|
|
*/
|
|
async saveToFile(filePath) {
|
|
try {
|
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
await fs.writeFile(filePath, JSON.stringify(this.context, null, 2), 'utf-8');
|
|
return true;
|
|
} catch (error) {
|
|
throw new Error(`Failed to save context to file: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Context Access (Type-Safe)
|
|
// ==========================================================================
|
|
|
|
/**
|
|
* Get value at path using dot notation
|
|
* Example: get('agent_contexts.analyst.outputs.project_brief')
|
|
*/
|
|
get(path) {
|
|
if (!path) return this.context;
|
|
|
|
const parts = path.split('.');
|
|
let value = this.context;
|
|
|
|
for (const part of parts) {
|
|
if (value === null || value === undefined) {
|
|
return undefined;
|
|
}
|
|
value = value[part];
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
/**
|
|
* Set value at path with validation
|
|
* Example: set('agent_contexts.analyst.status', 'completed', statusSchema)
|
|
*/
|
|
set(path, value, schema = null) {
|
|
// Validate if schema provided
|
|
if (schema) {
|
|
const isValid = this.validate(value, schema);
|
|
if (!isValid) {
|
|
const errors = this.ajv.errors;
|
|
throw new ContextValidationError(path, value, schema, errors);
|
|
}
|
|
}
|
|
|
|
// Get old value for change notification
|
|
const oldValue = this.get(path);
|
|
|
|
// Update value
|
|
const parts = path.split('.');
|
|
let current = this.context;
|
|
|
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
const part = parts[i];
|
|
if (!(part in current)) {
|
|
current[part] = {};
|
|
}
|
|
current = current[part];
|
|
}
|
|
|
|
const lastPart = parts[parts.length - 1];
|
|
current[lastPart] = value;
|
|
|
|
// Record history
|
|
this.recordHistory('set', path, oldValue, value);
|
|
|
|
// Notify subscribers
|
|
this.notifySubscribers(path, value, oldValue);
|
|
|
|
return value;
|
|
}
|
|
|
|
/**
|
|
* Update object at path (merge with existing)
|
|
*/
|
|
update(path, updates, schema = null) {
|
|
const existing = this.get(path) || {};
|
|
|
|
if (typeof existing !== 'object' || Array.isArray(existing)) {
|
|
throw new Error(`Cannot update non-object at path: ${path}`);
|
|
}
|
|
|
|
const merged = { ...existing, ...updates };
|
|
return this.set(path, merged, schema);
|
|
}
|
|
|
|
/**
|
|
* Push item to array at path
|
|
*/
|
|
push(path, item) {
|
|
const array = this.get(path);
|
|
|
|
if (!Array.isArray(array)) {
|
|
throw new Error(`Cannot push to non-array at path: ${path}`);
|
|
}
|
|
|
|
array.push(item);
|
|
this.recordHistory('push', path, null, item);
|
|
this.notifySubscribers(path, array, array);
|
|
|
|
return array;
|
|
}
|
|
|
|
/**
|
|
* Delete value at path
|
|
*/
|
|
delete(path) {
|
|
const parts = path.split('.');
|
|
let current = this.context;
|
|
|
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
current = current[parts[i]];
|
|
if (!current) return false;
|
|
}
|
|
|
|
const lastPart = parts[parts.length - 1];
|
|
const oldValue = current[lastPart];
|
|
delete current[lastPart];
|
|
|
|
this.recordHistory('delete', path, oldValue, undefined);
|
|
this.notifySubscribers(path, undefined, oldValue);
|
|
|
|
return true;
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Validation
|
|
// ==========================================================================
|
|
|
|
/**
|
|
* Validate value against schema
|
|
*/
|
|
validate(value, schema) {
|
|
// Check cache
|
|
const cacheKey = JSON.stringify({ value, schema });
|
|
if (this.validationCache.has(cacheKey)) {
|
|
return this.validationCache.get(cacheKey);
|
|
}
|
|
|
|
// Perform validation
|
|
const validate = this.ajv.compile(schema);
|
|
const isValid = validate(value);
|
|
|
|
// Cache result
|
|
this.validationCache.set(cacheKey, isValid);
|
|
|
|
return isValid;
|
|
}
|
|
|
|
/**
|
|
* Get validation errors
|
|
*/
|
|
getValidationErrors() {
|
|
return this.ajv.errors || [];
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Cross-Agent Data Propagation
|
|
// ==========================================================================
|
|
|
|
/**
|
|
* Propagate data from one agent to another
|
|
* Example: propagate('analyst', 'pm', { 'outputs.project_brief': 'inputs.project_brief' })
|
|
*/
|
|
propagate(sourceAgent, targetAgent, mapping) {
|
|
const propagated = {};
|
|
|
|
for (const [sourcePath, targetPath] of Object.entries(mapping)) {
|
|
const fullSourcePath = `agent_contexts.${sourceAgent}.${sourcePath}`;
|
|
const fullTargetPath = `agent_contexts.${targetAgent}.${targetPath}`;
|
|
|
|
const sourceData = this.get(fullSourcePath);
|
|
|
|
if (sourceData !== undefined) {
|
|
this.set(fullTargetPath, sourceData);
|
|
propagated[targetPath] = sourceData;
|
|
}
|
|
}
|
|
|
|
this.recordHistory('propagate', `${sourceAgent} → ${targetAgent}`, null, propagated);
|
|
|
|
return propagated;
|
|
}
|
|
|
|
/**
|
|
* Auto-propagate based on workflow dependencies
|
|
*/
|
|
autopropagate(workflowConfig, completedAgent) {
|
|
const propagations = workflowConfig.propagations || [];
|
|
|
|
for (const rule of propagations) {
|
|
if (rule.source === completedAgent) {
|
|
this.propagate(rule.source, rule.target, rule.mapping);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Reactive Updates (Pub/Sub)
|
|
// ==========================================================================
|
|
|
|
/**
|
|
* Subscribe to changes at path
|
|
*/
|
|
subscribe(path, callback) {
|
|
if (!this.subscribers.has(path)) {
|
|
this.subscribers.set(path, new Set());
|
|
}
|
|
this.subscribers.get(path).add(callback);
|
|
|
|
// Return unsubscribe function
|
|
return () => {
|
|
const subscribers = this.subscribers.get(path);
|
|
if (subscribers) {
|
|
subscribers.delete(callback);
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Notify subscribers of change
|
|
*/
|
|
notifySubscribers(path, newValue, oldValue) {
|
|
// Notify exact path subscribers
|
|
const exactSubscribers = this.subscribers.get(path);
|
|
if (exactSubscribers) {
|
|
for (const callback of exactSubscribers) {
|
|
callback(newValue, oldValue, path);
|
|
}
|
|
}
|
|
|
|
// Notify wildcard subscribers (path.*)
|
|
for (const [subscribedPath, callbacks] of this.subscribers.entries()) {
|
|
if (subscribedPath.endsWith('.*') && path.startsWith(subscribedPath.slice(0, -2))) {
|
|
for (const callback of callbacks) {
|
|
callback(newValue, oldValue, path);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Transactions & Checkpoints
|
|
// ==========================================================================
|
|
|
|
/**
|
|
* Create checkpoint for rollback
|
|
*/
|
|
checkpoint(label = null) {
|
|
const id = `checkpoint-${Date.now()}`;
|
|
const snapshot = JSON.parse(JSON.stringify(this.context));
|
|
|
|
this.checkpoints.push({
|
|
id,
|
|
label,
|
|
timestamp: new Date().toISOString(),
|
|
context: snapshot
|
|
});
|
|
|
|
return id;
|
|
}
|
|
|
|
/**
|
|
* Restore from checkpoint
|
|
*/
|
|
restore(checkpointId) {
|
|
const checkpoint = this.checkpoints.find(cp => cp.id === checkpointId);
|
|
|
|
if (!checkpoint) {
|
|
throw new Error(`Checkpoint not found: ${checkpointId}`);
|
|
}
|
|
|
|
const oldContext = this.context;
|
|
this.context = checkpoint.context;
|
|
|
|
this.recordHistory('restore', checkpointId, oldContext, this.context);
|
|
this.notifySubscribers('', this.context, oldContext);
|
|
|
|
return this.context;
|
|
}
|
|
|
|
/**
|
|
* List all checkpoints
|
|
*/
|
|
listCheckpoints() {
|
|
return this.checkpoints.map(cp => ({
|
|
id: cp.id,
|
|
label: cp.label,
|
|
timestamp: cp.timestamp
|
|
}));
|
|
}
|
|
|
|
// ==========================================================================
|
|
// History & Debugging
|
|
// ==========================================================================
|
|
|
|
/**
|
|
* Record change in history
|
|
*/
|
|
recordHistory(operation, path, oldValue, newValue) {
|
|
this.history.push({
|
|
timestamp: new Date().toISOString(),
|
|
operation,
|
|
path,
|
|
oldValue,
|
|
newValue
|
|
});
|
|
|
|
// Limit history size
|
|
if (this.history.length > 1000) {
|
|
this.history.shift();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get change history
|
|
*/
|
|
getHistory(filter = null) {
|
|
if (!filter) return this.history;
|
|
|
|
return this.history.filter(entry => {
|
|
if (filter.operation && entry.operation !== filter.operation) return false;
|
|
if (filter.path && !entry.path.includes(filter.path)) return false;
|
|
if (filter.since && new Date(entry.timestamp) < new Date(filter.since)) return false;
|
|
return true;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Export context for debugging
|
|
*/
|
|
export() {
|
|
return {
|
|
context: this.context,
|
|
history: this.history,
|
|
checkpoints: this.listCheckpoints(),
|
|
subscribers: Array.from(this.subscribers.keys())
|
|
};
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Custom Errors
|
|
// ============================================================================
|
|
|
|
class ContextValidationError extends Error {
|
|
constructor(path, value, schema, errors) {
|
|
super(`Validation failed for path: ${path}`);
|
|
this.name = 'ContextValidationError';
|
|
this.path = path;
|
|
this.value = value;
|
|
this.schema = schema;
|
|
this.errors = errors;
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Factory Function
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Create new context bus instance
|
|
*/
|
|
async function createContextBus(schemaPath = null) {
|
|
let schema = null;
|
|
|
|
if (schemaPath) {
|
|
const schemaContent = await fs.readFile(schemaPath, 'utf-8');
|
|
schema = JSON.parse(schemaContent);
|
|
}
|
|
|
|
return new ContextBus(schema);
|
|
}
|
|
|
|
// ============================================================================
|
|
// CLI Entry Point (for testing)
|
|
// ============================================================================
|
|
|
|
async function main() {
|
|
const args = process.argv.slice(2);
|
|
|
|
if (args.includes('--help')) {
|
|
console.log(`
|
|
Context Bus - In-Memory Context Management
|
|
|
|
Usage:
|
|
node context-bus.mjs --schema <schema.json> --input <context.json> --operation <op>
|
|
|
|
Operations:
|
|
get <path> Get value at path
|
|
set <path> <value> Set value at path
|
|
checkpoint <label> Create checkpoint
|
|
restore <id> Restore from checkpoint
|
|
history Show change history
|
|
export Export full context state
|
|
`);
|
|
process.exit(0);
|
|
}
|
|
|
|
// Example usage
|
|
const schemaPath = path.join(PROJECT_ROOT, '.claude/schemas/context_state.schema.json');
|
|
const bus = await createContextBus(schemaPath);
|
|
|
|
console.log('Context Bus initialized');
|
|
console.log('Schema loaded:', schemaPath);
|
|
console.log('Initial context:', JSON.stringify(bus.context, null, 2));
|
|
}
|
|
|
|
// Run if called directly
|
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
main().catch(console.error);
|
|
}
|
|
|
|
// Export
|
|
export { ContextBus, createContextBus, ContextValidationError };
|