diff --git a/.aiox-core/core/orchestration/context-manager.js b/.aiox-core/core/orchestration/context-manager.js index 893616941e..af2b743496 100644 --- a/.aiox-core/core/orchestration/context-manager.js +++ b/.aiox-core/core/orchestration/context-manager.js @@ -16,6 +16,31 @@ const fs = require('fs-extra'); const path = require('path'); +const { isPlainObject } = require('../config/merge-utils'); + +function mergeMetadata(target, source) { + const base = isPlainObject(target) ? target : {}; + + if (!isPlainObject(source)) { + return { ...base }; + } + + const result = { ...base }; + + for (const [key, value] of Object.entries(source)) { + result[key] = isPlainObject(value) && isPlainObject(result[key]) + ? mergeMetadata(result[key], value) + : value; + } + + return result; +} + +function describeMetadataInput(value) { + if (value === null) return 'null'; + if (Array.isArray(value)) return 'array'; + return typeof value; +} /** * Manages workflow execution context and state persistence @@ -224,8 +249,14 @@ class ContextManager { * @param {Object} metadata - Metadata to merge */ async updateMetadata(metadata) { + if (!isPlainObject(metadata)) { + throw new TypeError( + `updateMetadata expects a plain object, received: ${describeMetadataInput(metadata)}`, + ); + } + const state = await this.loadState(); - state.metadata = { ...state.metadata, ...metadata }; + state.metadata = mergeMetadata(state.metadata, metadata); this._stateCache = state; await this._saveState(); } diff --git a/.aiox-core/install-manifest.yaml b/.aiox-core/install-manifest.yaml index e61ee0b297..d8fad22563 100644 --- a/.aiox-core/install-manifest.yaml +++ b/.aiox-core/install-manifest.yaml @@ -8,7 +8,7 @@ # - File types for categorization # version: 5.1.15 -generated_at: "2026-05-08T04:49:28.006Z" +generated_at: "2026-05-08T05:14:31.637Z" generator: scripts/generate-install-manifest.js file_count: 1111 files: @@ -845,9 +845,9 @@ files: type: core size: 12722 - path: core/orchestration/context-manager.js - hash: sha256:7bf273723a2c08cb84e670b9d4c55aacec51819b1fbd5f3b0c46c4ccfa2ec192 + hash: sha256:b6ed0e4eb069558998f95ab0c68fa040b1b8c2aab74ab44e26ab682fe9e247b4 type: core - size: 16842 + size: 17620 - path: core/orchestration/dashboard-integration.js hash: sha256:8f2dd7d3885a9eaf58957505d312923e149f2771ae3ca0cda879631683f826c9 type: core diff --git a/tests/core/orchestration/context-manager.test.js b/tests/core/orchestration/context-manager.test.js index 2d31f5d2ea..5326839975 100644 --- a/tests/core/orchestration/context-manager.test.js +++ b/tests/core/orchestration/context-manager.test.js @@ -567,6 +567,58 @@ describe('ContextManager', () => { expect(manager._stateCache.metadata.projectRoot).toBe('/outro/caminho'); }); + test('preserva metadados aninhados ao aplicar merge profundo', async () => { + await manager.updateMetadata({ + observability: { + attempts: { + total: 1, + lastPhase: 'planning', + }, + tags: ['initial'], + nullable: 'keep', + }, + }); + + await manager.updateMetadata({ + observability: { + attempts: { + failed: 1, + }, + lastError: { + code: 'EACCES', + }, + tags: ['latest'], + nullable: null, + }, + }); + + expect(manager._stateCache.metadata.observability).toEqual({ + attempts: { + total: 1, + lastPhase: 'planning', + failed: 1, + }, + lastError: { + code: 'EACCES', + }, + tags: ['latest'], + nullable: null, + }); + }); + + test.each([ + ['null', null], + ['undefined', undefined], + ['array', []], + ['string', 'metadata'], + ])('rejeita metadata inválido: %s', async (_label, invalidMetadata) => { + await expect(manager.updateMetadata(invalidMetadata)).rejects.toThrow( + 'updateMetadata expects a plain object', + ); + + expect(fs.writeJson).not.toHaveBeenCalled(); + }); + test('persiste estado no disco', async () => { await manager.updateMetadata({ foo: 'bar' });