diff --git a/.env.example b/.env.example index 75266fd..7d09bf3 100644 --- a/.env.example +++ b/.env.example @@ -146,3 +146,11 @@ WEB_INTEL_SELF_URL=http://localhost:4002 WEB_INTEL_V2_SELF_URL=http://localhost:4003 ANALYSIS_AGENT_SELF_URL=http://localhost:4004 REPORT_AGENT_SELF_URL=http://localhost:4005 + +# Cluster Runtime Environments +PORT=3000 +REGISTRY_URL=http://localhost:3000 + +# Agent Cluster Core Policies +# Time-To-Live (TTL) ceiling boundary value in seconds to mark uncommunicative workers inactive. +AGENT_TTL_SECONDS=120 \ No newline at end of file diff --git a/packages/agent_shared_directory/src/server.ts b/packages/agent_shared_directory/src/server.ts new file mode 100644 index 0000000..bd09a0c --- /dev/null +++ b/packages/agent_shared_directory/src/server.ts @@ -0,0 +1,28 @@ +import axios from 'axios'; + +const REGISTRY_URL = process.env.REGISTRY_URL || 'http://localhost:3000'; +const AGENT_ID = process.env.AGENT_ID || 'agent-core-01'; + +/** + * Registers startup configuration and provisions automatic lifecycle loops + */ +export function initializeAgentHeartbeat() { + console.log(`[Lifecycle] Initializing background pulse telemetry for Agent: ${AGENT_ID}`); + + // Establish heartbeat cycle matching the 60-second execution window specifications + const heartbeatInterval = setInterval(async () => { + try { + await axios.post(`${REGISTRY_URL}/agents/${AGENT_ID}/heartbeat`); + } catch (error: any) { + console.warn(`[Lifecycle Warning] Pulse telemetry transmission dropped: ${error.message}`); + } + }, 60000); + + // Structural Cleanup Management: Clear event pools if process triggers termination signals + process.on('SIGTERM', () => { + clearInterval(heartbeatInterval); + }); + process.on('SIGINT', () => { + clearInterval(heartbeatInterval); + }); +} \ No newline at end of file diff --git a/packages/common/src/logger.ts b/packages/common/src/logger.ts index bfe4af5..8dca778 100644 --- a/packages/common/src/logger.ts +++ b/packages/common/src/logger.ts @@ -1,18 +1,82 @@ +import { trace } from '@opentelemetry/api'; + type LogLevel = 'info' | 'warn' | 'error' | 'debug'; +interface TraceContext { + traceId?: string; + spanId?: string; +} + +function getTraceContext(): TraceContext { + const span = trace.getActiveSpan(); + + if (!span) { + return {}; + } + + const spanContext = span.spanContext(); + + if (!spanContext.traceId) { + return {}; + } + + return { + traceId: spanContext.traceId, + spanId: spanContext.spanId, + }; +} + function log(level: LogLevel, message: string, data?: unknown) { const ts = new Date().toISOString(); + const traceContext = getTraceContext(); + const prefix = `[${ts}] [${level.toUpperCase()}]`; + + const context = + Object.keys(traceContext).length > 0 + ? traceContext + : undefined; + + if (data !== undefined && context !== undefined) { + console.log( + `${prefix} ${message}`, + { + ...context, + data, + }, + ); + return; + } + if (data !== undefined) { - console.log(`${prefix} ${message}`, data); - } else { - console.log(`${prefix} ${message}`); + console.log( + `${prefix} ${message}`, + data, + ); + return; } + + if (context !== undefined) { + console.log( + `${prefix} ${message}`, + context, + ); + return; + } + + console.log(`${prefix} ${message}`); } export const logger = { - info: (msg: string, data?: unknown) => log('info', msg, data), - warn: (msg: string, data?: unknown) => log('warn', msg, data), - error: (msg: string, data?: unknown) => log('error', msg, data), - debug: (msg: string, data?: unknown) => log('debug', msg, data), -}; + info: (msg: string, data?: unknown) => + log('info', msg, data), + + warn: (msg: string, data?: unknown) => + log('warn', msg, data), + + error: (msg: string, data?: unknown) => + log('error', msg, data), + + debug: (msg: string, data?: unknown) => + log('debug', msg, data), +}; \ No newline at end of file diff --git a/packages/common/src/tracing/attributes.ts b/packages/common/src/tracing/attributes.ts new file mode 100644 index 0000000..4bb6ca6 --- /dev/null +++ b/packages/common/src/tracing/attributes.ts @@ -0,0 +1,141 @@ +/** + * CleverCon OpenTelemetry semantic attribute names. + * + * Keep these attributes: + * - bounded + * - searchable + * - non-sensitive + * - low-cardinality where possible + */ + +export const TRACE_ATTRIBUTES = { + TASK_ID: 'clevercon.task.id', + STEP_ID: 'clevercon.step.id', + AGENT_ID: 'clevercon.agent.id', + ASSET: 'clevercon.asset', + AMOUNT: 'clevercon.amount', + + AGENT_PROTOCOL: 'clevercon.agent.protocol', + + VAULT_NETWORK: 'clevercon.vault.network', + VAULT_OPERATION: 'clevercon.vault.operation', +} as const; + +export type TraceAttributes = Record< + string, + string | number | boolean | undefined +>; + +function boundedString( + value: unknown, + maxLength = 128, +): string | undefined { + if (value === undefined || value === null) { + return undefined; + } + + const stringValue = String(value); + + if (!stringValue) { + return undefined; + } + + return stringValue.slice(0, maxLength); +} + +export function taskAttributes(taskId: unknown): TraceAttributes { + const value = boundedString(taskId); + + return value + ? { + [TRACE_ATTRIBUTES.TASK_ID]: value, + } + : {}; +} + +export function stepAttributes( + taskId: unknown, + stepId: unknown, +): TraceAttributes { + return { + ...taskAttributes(taskId), + ...(boundedString(stepId) + ? { + [TRACE_ATTRIBUTES.STEP_ID]: boundedString(stepId), + } + : {}), + }; +} + +export function agentAttributes( + taskId: unknown, + stepId: unknown, + agentId: unknown, + protocol?: unknown, +): TraceAttributes { + return { + ...stepAttributes(taskId, stepId), + + ...(boundedString(agentId) + ? { + [TRACE_ATTRIBUTES.AGENT_ID]: boundedString(agentId), + } + : {}), + + ...(boundedString(protocol) + ? { + [TRACE_ATTRIBUTES.AGENT_PROTOCOL]: boundedString(protocol, 32), + } + : {}), + }; +} + +export function vaultAttributes(options: { + taskId?: unknown; + stepId?: unknown; + agentId?: unknown; + asset?: unknown; + amount?: unknown; + network?: unknown; + operation?: unknown; +}): TraceAttributes { + return { + ...stepAttributes(options.taskId, options.stepId), + + ...(boundedString(options.agentId) + ? { + [TRACE_ATTRIBUTES.AGENT_ID]: boundedString(options.agentId), + } + : {}), + + ...(boundedString(options.asset, 32) + ? { + [TRACE_ATTRIBUTES.ASSET]: boundedString(options.asset, 32), + } + : {}), + + ...(options.amount !== undefined && options.amount !== null + ? { + [TRACE_ATTRIBUTES.AMOUNT]: boundedString(options.amount, 64), + } + : {}), + + ...(boundedString(options.network, 32) + ? { + [TRACE_ATTRIBUTES.VAULT_NETWORK]: boundedString( + options.network, + 32, + ), + } + : {}), + + ...(boundedString(options.operation, 64) + ? { + [TRACE_ATTRIBUTES.VAULT_OPERATION]: boundedString( + options.operation, + 64, + ), + } + : {}), + }; +} \ No newline at end of file diff --git a/packages/common/src/tracing/config.ts b/packages/common/src/tracing/config.ts new file mode 100644 index 0000000..75c68e4 --- /dev/null +++ b/packages/common/src/tracing/config.ts @@ -0,0 +1,54 @@ +/** + * OpenTelemetry configuration. + * + * Tracing is intentionally disabled by default. + * + * Environment variables: + * + * OTEL_TRACING_ENABLED=true + * OTEL_SERVICE_NAME=clevercon-orchestrator + * OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318/v1/traces + * OTEL_TRACING_EXPORTER=console|otlp + */ + +export type TracingExporter = 'console' | 'otlp'; + +export interface TracingConfig { + enabled: boolean; + serviceName: string; + exporter: TracingExporter; + otlpEndpoint?: string; +} + +function parseBoolean(value: string | undefined): boolean { + if (!value) { + return false; + } + + return ['true', '1', 'yes', 'on'].includes(value.toLowerCase()); +} + +function getExporter(): TracingExporter { + const configured = process.env.OTEL_TRACING_EXPORTER?.toLowerCase(); + + if (configured === 'otlp') { + return 'otlp'; + } + + if (configured === 'console') { + return 'console'; + } + + return process.env.OTEL_EXPORTER_OTLP_ENDPOINT ? 'otlp' : 'console'; +} + +export function getTracingConfig(): TracingConfig { + return { + enabled: parseBoolean(process.env.OTEL_TRACING_ENABLED), + serviceName: + process.env.OTEL_SERVICE_NAME?.trim() || 'clevercon-service', + exporter: getExporter(), + otlpEndpoint: + process.env.OTEL_EXPORTER_OTLP_ENDPOINT?.trim() || undefined, + }; +} \ No newline at end of file diff --git a/packages/common/src/tracing/index.ts b/packages/common/src/tracing/index.ts new file mode 100644 index 0000000..dd2470f --- /dev/null +++ b/packages/common/src/tracing/index.ts @@ -0,0 +1,34 @@ +import { + context, + propagation, + trace, + Span, + SpanStatusCode, + type Context, +} from '@opentelemetry/api'; + +export const TRACER_NAME = 'clevercon'; + +export const tracer = trace.getTracer(TRACER_NAME); + +export function startSpan( + name: string, + attributes: Record, + fn: (span: Span) => Promise | T, +): Promise | T { + return tracer.startActiveSpan(name, { attributes }, async (span) => { + try { + return await fn(span); + } catch (error) { + span.recordException(error as Error); + span.setStatus({ + code: SpanStatusCode.ERROR, + message: error instanceof Error ? error.message : String(error), + }); + + throw error; + } finally { + span.end(); + } + }); +} \ No newline at end of file diff --git a/packages/common/src/tracing/propagation.ts b/packages/common/src/tracing/propagation.ts new file mode 100644 index 0000000..4795155 --- /dev/null +++ b/packages/common/src/tracing/propagation.ts @@ -0,0 +1,56 @@ +import { + context, + propagation, + type Context, + type TextMapGetter, + type TextMapSetter, +} from '@opentelemetry/api'; + +const headerGetter: TextMapGetter> = { + keys(carrier) { + return Object.keys(carrier); + }, + + get(carrier, key) { + const value = carrier[key]; + + if (Array.isArray(value)) { + return value[0]; + } + + return value as string | undefined; + }, +}; + +const headerSetter: TextMapSetter> = { + set(carrier, key, value) { + carrier[key] = value; + }, +}; + +export function injectTraceContext( + headers: Record = {}, +): Record { + propagation.inject(context.active(), headers, headerSetter); + + return headers; +} + +export function extractTraceContext( + headers: Record, +): Context { + try { + return propagation.extract( + context.active(), + headers, + headerGetter, + ); + } catch { + /** + * A malformed traceparent must never break an agent request. + * + * Return the current context so the caller can start a new root span. + */ + return context.active(); + } +} \ No newline at end of file diff --git a/packages/common/src/tracing/sdk.ts b/packages/common/src/tracing/sdk.ts new file mode 100644 index 0000000..bced394 --- /dev/null +++ b/packages/common/src/tracing/sdk.ts @@ -0,0 +1,72 @@ +import { + ConsoleSpanExporter, + SimpleSpanProcessor, +} from '@opentelemetry/sdk-trace-node'; + +import { + OTLPTraceExporter, +} from '@opentelemetry/exporter-trace-otlp-proto'; + +import { + NodeSDK, +} from '@opentelemetry/sdk-node'; + +import { + getTracingConfig, +} from './config'; + +let sdk: NodeSDK | undefined; + +let initialized = false; + +export function initializeTracing(): NodeSDK | undefined { + if (initialized) { + return sdk; + } + + initialized = true; + + const config = getTracingConfig(); + + /** + * Tracing disabled: + * + * Do not register a provider. + * + * @opentelemetry/api therefore uses its no-op implementation. + */ + if (!config.enabled) { + return undefined; + } + + const exporter = + config.exporter === 'otlp' + ? new OTLPTraceExporter({ + ...(config.otlpEndpoint + ? { + url: config.otlpEndpoint, + } + : {}), + }) + : new ConsoleSpanExporter(); + + sdk = new NodeSDK({ + spanProcessor: new SimpleSpanProcessor(exporter), + serviceName: config.serviceName, + }); + + sdk.start(); + + return sdk; +} + +export async function shutdownTracing(): Promise { + if (!sdk) { + return; + } + + await sdk.shutdown(); + + sdk = undefined; + initialized = false; +} \ No newline at end of file diff --git a/packages/common/src/tracing/tracer.ts b/packages/common/src/tracing/tracer.ts new file mode 100644 index 0000000..0ddf5ed --- /dev/null +++ b/packages/common/src/tracing/tracer.ts @@ -0,0 +1,119 @@ +import { + context, + Span, + SpanStatusCode, + trace, + type Attributes, + type Context, + type SpanOptions, +} from '@opentelemetry/api'; + +export const TRACER_NAME = 'clevercon'; + +export const tracer = trace.getTracer(TRACER_NAME); + +export type SpanAttributeValue = + | string + | number + | boolean + | undefined; + +export type SpanAttributes = Record< + string, + SpanAttributeValue +>; + +function sanitizeAttributes( + attributes?: SpanAttributes, +): Attributes { + if (!attributes) { + return {}; + } + + return Object.fromEntries( + Object.entries(attributes).filter( + ([, value]) => value !== undefined, + ), + ) as Attributes; +} + +export interface RunSpanOptions { + attributes?: SpanAttributes; + context?: Context; + spanOptions?: SpanOptions; +} + +export async function runInSpan( + name: string, + options: RunSpanOptions, + operation: (span: Span) => Promise | T, +): Promise { + const parentContext = options.context ?? context.active(); + + return tracer.startActiveSpan( + name, + { + ...(options.spanOptions ?? {}), + attributes: sanitizeAttributes(options.attributes), + }, + parentContext, + async (span) => { + try { + return await operation(span); + } catch (error) { + recordSpanError(span, error); + throw error; + } finally { + span.end(); + } + }, + ); +} + +export function recordSpanError( + span: Span, + error: unknown, +): void { + if (error instanceof Error) { + span.recordException(error); + span.setStatus({ + code: SpanStatusCode.ERROR, + message: error.message, + }); + + return; + } + + const message = String(error); + + span.recordException(new Error(message)); + + span.setStatus({ + code: SpanStatusCode.ERROR, + message, + }); +} + +export function getActiveTraceId(): string | undefined { + const activeSpan = trace.getActiveSpan(); + + if (!activeSpan) { + return undefined; + } + + return activeSpan.spanContext().traceId; +} + +export function getActiveSpanId(): string | undefined { + const activeSpan = trace.getActiveSpan(); + + if (!activeSpan) { + return undefined; + } + + return activeSpan.spanContext().spanId; +} + +export function getActiveSpan(): Span | undefined { + return trace.getActiveSpan(); +} \ No newline at end of file diff --git a/packages/registry/src/store.ts b/packages/registry/src/store.ts index 0c1f896..d6b1e58 100644 --- a/packages/registry/src/store.ts +++ b/packages/registry/src/store.ts @@ -27,6 +27,17 @@ let consecutiveWriteFailures = 0; /** * Load all registered agents from `data/registry.json`. * Returns an empty array if the file doesn't exist or contains invalid JSON. +<<<<<<< HEAD + * * Process state conversions automatically to evaluate agent freshness. + */ +export function loadAgents(includeInactive: boolean = false): AgentRecord[] { + ensureDataDir(); + if (!fs.existsSync(REGISTRY_FILE)) return []; + + let agents: AgentRecord[] = []; + try { + agents = JSON.parse(fs.readFileSync(REGISTRY_FILE, 'utf-8')); +======= * Uses an in-memory cache so that concurrent readers see the latest state * even if a queued write hasn't flushed to disk yet. */ @@ -38,10 +49,41 @@ export function loadAgents(): AgentRecord[] { return cache; } cache = JSON.parse(fs.readFileSync(REGISTRY_FILE, 'utf-8')) as AgentRecord[]; +>>>>>>> 89372532fad87628ab8db0c7b20d6fb598abd553 } catch { cache = []; } +<<<<<<< HEAD + + const nowMs = Date.now(); + const ttlSeconds = Number(process.env.AGENT_TTL_SECONDS) || 120; + const ttlThresholdMs = ttlSeconds * 1000; + let mutated = false; + + // Process live lifecycle updates based on absolute timestamp drift + const updatedAgents = agents.map((agent) => { + const lastSeenMs = new Date(agent.last_seen).getTime(); + const isStale = nowMs - lastSeenMs > ttlThresholdMs; + + if (isStale && agent.status === 'active') { + mutated = true; + return { ...agent, status: 'inactive' as const }; + } + return agent; + }); + + // Automatically sync back to JSON storage file if any statuses collapsed to inactive + if (mutated) { + saveAgents(updatedAgents); + } + + if (includeInactive) { + return updatedAgents; + } + return updatedAgents.filter((a) => a.status === 'active'); +======= return cache; +>>>>>>> 89372532fad87628ab8db0c7b20d6fb598abd553 } /** Overwrite `data/registry.json` with the given list of agents. */ @@ -65,7 +107,8 @@ export function saveAgents(agents: AgentRecord[]): void { /** Find a single agent by its `agent_id`, or `undefined` if not registered. */ export function findAgent(agentId: string): AgentRecord | undefined { - return loadAgents().find((a) => a.agent_id === agentId); + // Pass true to verify matching references across inactive items as well + return loadAgents(true).find((a) => a.agent_id === agentId); } /** @@ -73,7 +116,7 @@ export function findAgent(agentId: string): AgentRecord | undefined { * Returns the agent that was stored. */ export function upsertAgent(agent: AgentRecord): AgentRecord { - const agents = loadAgents(); + const agents = loadAgents(true); const idx = agents.findIndex((a) => a.agent_id === agent.agent_id); if (idx >= 0) { agents[idx] = agent; @@ -86,9 +129,9 @@ export function upsertAgent(agent: AgentRecord): AgentRecord { /** Remove an agent by `agent_id`. Returns `true` if an agent was removed. */ export function removeAgent(agentId: string): boolean { - const agents = loadAgents(); + const agents = loadAgents(true); const filtered = agents.filter((a) => a.agent_id !== agentId); if (filtered.length === agents.length) return false; saveAgents(filtered); return true; -} +} \ No newline at end of file