UNPKG

bc-webclient-mcp

Version:

Model Context Protocol (MCP) server for Microsoft Dynamics 365 Business Central via WebUI protocol. Enables AI assistants to interact with BC through the web client protocol, supporting Card, List, and Document pages with full line item support and server

913 lines 67.7 kB
/** * Write Page Data MCP Tool * * Creates or updates records on a BC page by setting field values. * Uses the real SaveValue protocol captured from BC traffic. * * Usage workflow: * 1. Call get_page_metadata to open the page * 2. Call execute_action with "Edit" (for updates) or "New" (for creates) * 3. Call write_page_data with field values */ import { BaseMCPTool } from './base-tool.js'; import { ok, err, isOk } from '../core/result.js'; import { InputValidationError, ProtocolError } from '../core/errors.js'; import { ConnectionManager } from '../connection/connection-manager.js'; import { createToolLogger, logger as moduleLogger } from '../core/logger.js'; import { ControlParser } from '../parsers/control-parser.js'; import { PageContextCache } from '../services/page-context-cache.js'; import { createWorkflowIntegration } from '../services/workflow-integration.js'; import { isPropertyChange, isPropertyChanges } from '../types/bc-protocol-types.js'; import { isDataRefreshChangeType, isDataRowInsertedType, isPropertyChangesType, isPropertyChangeType, } from '../types/bc-type-discriminators.js'; /** * Type guard to check if a handler is a LogicalClientChangeHandler */ function isLogicalClientChangeHandler(handler) { return handler.handlerType === 'DN.LogicalClientChangeHandler'; } /** * Get the handlerType from any handler (standard or generic) */ function getHandlerType(handler) { return handler.handlerType; } /** * Get parameters from any handler (standard or generic) */ function getHandlerParams(handler) { return handler.parameters; } /** * MCP Tool: write_page_data * * Writes data to a BC page (sets field values on current record). * * Prerequisites: * - Page must be open (call get_page_metadata first) * - Record must be in edit mode (call execute_action with "Edit" or "New") */ export class WritePageDataTool extends BaseMCPTool { connection; bcConfig; name = 'write_page_data'; description = 'Sets field values on the current Business Central record with immediate validation. Requires pageContextId from get_page_metadata. ' + 'Prerequisites: Record MUST be in edit mode (call execute_action with "Edit" for existing records or "New" for new records first). ' + 'fields: Provide as simple map {"FieldName": value} where keys are field names/captions (case-insensitive), ' + 'OR as array [{name: "FieldName", value: value, controlPath?: "path"}] for precise targeting. ' + 'Field references use field name or caption from get_page_metadata.fields. ' + 'SUBPAGE/LINE OPERATIONS (NEW): To write to document lines (Sales Orders, Purchase Orders, etc.), provide subpage parameter with line identifier. ' + 'subpage (optional): Subpage/repeater name (e.g., "SalesLines") for line item operations. ' + 'lineBookmark (optional): Bookmark of specific line to update (most reliable). ' + 'lineNo (optional): Line number to update (1-based, resolved to bookmark internally). ' + 'If neither lineBookmark nor lineNo provided, creates NEW line. Provide EITHER lineBookmark OR lineNo, not both. ' + 'stopOnError (default true): stops processing remaining fields on first validation error. ' + 'immediateValidation (default true): runs Business Central OnValidate triggers immediately and surfaces validation messages. ' + 'Returns: {updatedFields: [], failedFields: [{field, error, validationMessage}], saved: false}. ' + 'IMPORTANT: This call does NOT commit/post to the database. Field changes are held in memory. ' + 'Use execute_action("Save") or execute_action("Post") to persist changes to the database.'; inputSchema = { type: 'object', properties: { pageContextId: { type: 'string', description: 'Required page context ID from get_page_metadata', }, fields: { oneOf: [ { type: 'object', description: 'Simple map: field name → value (e.g., {"Name": "Test", "Credit Limit (LCY)": 5000})', additionalProperties: true, }, { type: 'array', description: 'Advanced: array with controlPath support [{name, value, controlPath?}]', items: { type: 'object', properties: { name: { type: 'string' }, value: { type: ['string', 'number', 'boolean', 'null'], description: 'Field value (null to clear)' }, controlPath: { type: 'string' }, }, required: ['name', 'value'], }, }, ], }, stopOnError: { type: 'boolean', description: 'Stop on first validation error (default: true)', default: true, }, immediateValidation: { type: 'boolean', description: 'Parse BC handlers for validation errors (default: true)', default: true, }, subpage: { type: 'string', description: 'Optional: Subpage/repeater name for line item operations (e.g., "SalesLines")', }, lineBookmark: { type: 'string', description: 'Optional: Bookmark of specific line to update (most reliable method)', }, lineNo: { type: 'number', description: 'Optional: Line number to update (1-based, resolved to bookmark internally)', minimum: 1, }, workflowId: { type: 'string', description: 'Optional workflow ID to track this operation as part of a multi-step business process. ' + 'When provided, tracks unsaved field changes and records operation result.', }, }, required: ['pageContextId', 'fields'], }; // Consent configuration - Write operation requiring user approval requiresConsent = true; sensitivityLevel = 'medium'; consentPrompt = 'Write field values to a Business Central record? This will modify data in your Business Central database.'; constructor(connection, bcConfig, auditLogger) { super({ auditLogger }); this.connection = connection; this.bcConfig = bcConfig; } /** * Validates and extracts input with field normalization. * NOTE: write-page-data-tool uses legacy validation due to complex field format normalization. * Zod migration deferred pending refactoring. */ validateInput(input) { const baseResult = super.validateInput(input); if (!isOk(baseResult)) { return baseResult; } const pageContextIdResult = this.getRequiredString(input, 'pageContextId'); if (!isOk(pageContextIdResult)) { return pageContextIdResult; } const fieldsValue = input.fields; if (!fieldsValue) { return err(new InputValidationError('fields parameter is required', 'fields', ['Must provide fields'])); } // Normalize fields to internal format: Record<string, {value, controlPath?}> let fields; if (typeof fieldsValue === 'object' && !Array.isArray(fieldsValue)) { fields = {}; for (const [name, value] of Object.entries(fieldsValue)) { // Check if value is already wrapped with {value, controlPath?} structure if (typeof value === 'object' && value !== null && 'value' in value) { // Already wrapped - use as-is fields[name] = value; } else { // Primitive or other format - wrap it fields[name] = { value }; } } } else { return err(new InputValidationError('fields must be an object', 'fields', ['Expected object format'])); } const stopOnErrorValue = input.stopOnError; const stopOnError = typeof stopOnErrorValue === 'boolean' ? stopOnErrorValue : true; const immediateValidationValue = input.immediateValidation; const immediateValidation = typeof immediateValidationValue === 'boolean' ? immediateValidationValue : true; // Extract and validate subpage/line parameters const subpage = input.subpage; const lineBookmark = input.lineBookmark; const lineNo = input.lineNo; // Validate: Cannot provide both lineBookmark AND lineNo if (lineBookmark && lineNo) { return err(new InputValidationError('Cannot provide both lineBookmark and lineNo - use one or the other', 'lineBookmark/lineNo', ['Provide EITHER lineBookmark OR lineNo, not both'])); } // Validate: lineBookmark/lineNo require subpage parameter if ((lineBookmark || lineNo) && !subpage) { return err(new InputValidationError('lineBookmark or lineNo requires subpage parameter to be specified', 'subpage', ['Must provide subpage name when using lineBookmark or lineNo'])); } return ok({ pageContextId: pageContextIdResult.value, fields, stopOnError, immediateValidation, subpage, lineBookmark, lineNo, }); } /** * Builds a map of field names to metadata from cached LogicalForm. * Uses ControlParser to extract all fields from the control tree. * * @param logicalForm - Cached LogicalForm from pageContext * @returns Map of field name → FieldMetadata (case-insensitive keys) */ buildFieldMap(logicalForm) { const parser = new ControlParser(); const controls = parser.walkControls(logicalForm); const fields = parser.extractFields(controls); const fieldMap = new Map(); for (const field of fields) { // Add field by all possible names (case-insensitive) const names = [ field.name, field.caption, field.controlId, ].filter((n) => !!n); for (const name of names) { const key = name.toLowerCase().trim(); if (!fieldMap.has(key)) { fieldMap.set(key, field); } } } return fieldMap; } /** * Validates that a field exists and is editable using cached metadata. * Provides helpful error messages for common issues. * * @param fieldName - Field name to validate * @param fieldMap - Map of available fields from buildFieldMap() * @returns Result with field metadata or validation error */ validateFieldExists(fieldName, fieldMap) { const key = fieldName.toLowerCase().trim(); const field = fieldMap.get(key); if (!field) { // Field doesn't exist - provide helpful error const availableFields = Array.from(new Set(Array.from(fieldMap.values()) .map(f => f.caption || f.name) .filter((n) => !!n))).slice(0, 10); return err(new InputValidationError(`Field "${fieldName}" not found on page`, fieldName, [ `Field "${fieldName}" does not exist on this page.`, `Available fields: ${availableFields.join(', ')}${fieldMap.size > 10 ? ', ...' : ''}`, `Hint: Field names are case-insensitive. Check spelling and use caption or name.` ])); } // Check if field is visible if (!field.visible) { return err(new InputValidationError(`Field "${fieldName}" is not visible`, fieldName, [ `Field "${fieldName}" exists but is not visible on the page.`, `Hidden fields cannot be edited.` ])); } // Check if field is enabled if (!field.enabled) { return err(new InputValidationError(`Field "${fieldName}" is disabled`, fieldName, [ `Field "${fieldName}" exists but is disabled.`, `Disabled fields cannot be edited.` ])); } // Check if field is readonly if (field.readonly) { return err(new InputValidationError(`Field "${fieldName}" is read-only`, fieldName, [ `Field "${fieldName}" is marked as read-only.`, `Read-only fields cannot be modified.` ])); } return ok(field); } /** * Executes the tool to write page data. * Uses legacy validation with field normalization. * * Sets field values on the current record using SaveValue interactions. */ async executeInternal(input) { const inputObj = input; const logger = createToolLogger('write_page_data', inputObj?.pageContextId); // Validate and normalize input const validatedInput = this.validateInput(input); if (!isOk(validatedInput)) { return validatedInput; } const validatedData = validatedInput.value; const { pageContextId, fields, stopOnError, immediateValidation, subpage, lineBookmark, lineNo, workflowId } = validatedData; // Create workflow integration if workflowId provided const workflow = createWorkflowIntegration(workflowId); const fieldNames = Object.keys(fields); logger.info(`Writing ${fieldNames.length} fields using pageContext: "${pageContextId}"`); logger.info(`Fields: ${fieldNames.join(', ')}`); logger.info(`Options: stopOnError=${stopOnError}, immediateValidation=${immediateValidation}`); if (subpage) { logger.info(`Line operation: subpage="${subpage}", lineBookmark="${lineBookmark || 'N/A'}", lineNo=${lineNo || 'N/A'}`); } const manager = ConnectionManager.getInstance(); let connection; let actualSessionId; let pageId; // Extract sessionId and pageId from pageContextId (format: sessionId:page:pageId:timestamp) const contextParts = pageContextId.split(':'); if (contextParts.length < 3) { return err(new ProtocolError(`Invalid pageContextId format: ${pageContextId}`, { pageContextId })); } const sessionId = contextParts[0]; pageId = contextParts[2]; // Try to reuse existing session from pageContextId const existing = manager.getSession(sessionId); if (existing) { logger.info(`Reusing session from pageContext: ${sessionId}`); connection = existing; actualSessionId = sessionId; // Check if the page context is still valid in memory const connWithCtx = connection; let pageContext = connWithCtx.pageContexts?.get(pageContextId); // If not in memory, try restoring from persistent cache if (!pageContext) { logger.info(`Page context not in memory, checking persistent cache...`); try { const cache = PageContextCache.getInstance(); const cachedContext = await cache.load(pageContextId); if (cachedContext) { logger.info(`Restored pageContext from cache: ${pageContextId}`); // Restore to memory if (!connWithCtx.pageContexts) { connWithCtx.pageContexts = new Map(); } // Cast through unknown since CachedPageContext may have different optional fields connWithCtx.pageContexts.set(pageContextId, cachedContext); pageContext = cachedContext; } } catch (error) { logger.warn(`Failed to load from cache: ${error}`); } } // If still not found, return error if (!pageContext) { logger.info(`Page context not found in memory or cache`); return err(new ProtocolError(`Page context ${pageContextId} not found. Page may have been closed. Please call get_page_metadata again.`, { pageContextId })); } } else { return err(new ProtocolError(`Session ${sessionId} from pageContext not found. Please call get_page_metadata first.`, { pageContextId, sessionId })); } // Check if page is open if (!connection.isPageOpen(pageId)) { return err(new ProtocolError(`Page ${pageId} is not open in session ${actualSessionId}. Call get_page_metadata first to open the page.`, { pageId, fields: fieldNames, sessionId: actualSessionId })); } // Get formId for this page const formId = connection.getOpenFormId(pageId); if (!formId) { return err(new ProtocolError(`No formId found for page ${pageId} in session ${actualSessionId}. Page may not be properly opened.`, { pageId, fields: fieldNames, sessionId: actualSessionId })); } logger.info(`Using formId: ${formId}`); // OPTIMIZATION: Use cached LogicalForm for client-side field validation // This follows the caching pattern: extract metadata once in get_page_metadata, reuse here const pageContext = connection.pageContexts?.get(pageContextId); let fieldMap = null; let targetRowBookmark; // Bookmark for existing row modification in subpages if (pageContext?.logicalForm && !subpage) { // Only validate header fields if NOT in line mode logger.info(`Using cached LogicalForm for client-side field validation`); const headerFieldMap = this.buildFieldMap(pageContext.logicalForm); fieldMap = headerFieldMap; // Store for later use in field-writing loop logger.info(` Field map contains ${headerFieldMap.size} field entries`); // Pre-validate all fields before making BC API calls for (const fieldName of fieldNames) { const validationResult = this.validateFieldExists(fieldName, headerFieldMap); if (!isOk(validationResult)) { logger.info(`Pre-validation failed for field "${fieldName}": ${validationResult.error.message}`); return validationResult; } logger.info(`Pre-validated field "${fieldName}"`); } } else if (!subpage) { logger.info(`No cached LogicalForm available, skipping client-side validation`); } // LINE/SUBPAGE OPERATION: Handle line operations if subpage is provided if (subpage) { logger.info(`Handling line operation for subpage "${subpage}"`); // Find repeater by subpage name (uses PageState if available) const repeaterResult = await this.findRepeaterBySubpage(pageContextId, pageContext, subpage); if (!isOk(repeaterResult)) { return repeaterResult; } const repeater = repeaterResult.value; logger.info(`Found repeater: ${repeater.caption || repeater.name} at ${repeater.controlPath}`); // If lineBookmark or lineNo provided, we're updating an existing line if (lineBookmark || lineNo) { // Determine which bookmark to use targetRowBookmark = lineBookmark; // If lineNo provided, resolve to bookmark from PageState cache if (lineNo && !lineBookmark) { const cache = PageContextCache.getInstance(); const pageState = await cache.getPageState(pageContextId); if (!pageState) { return err(new InputValidationError(`lineNo parameter requires cached page state - call read_page_data first`, 'lineNo', [`Page state not found in cache for pageContextId: ${pageContextId}`])); } // Find the repeater in PageState by matching name/caption let repeaterState; for (const [, rs] of pageState.repeaters) { // Match by caption (user-facing name like "SalesLines") or name if (rs.caption?.toLowerCase().includes(subpage.toLowerCase()) || rs.name.toLowerCase().includes(subpage.toLowerCase())) { repeaterState = rs; break; } } if (!repeaterState) { return err(new InputValidationError(`Repeater "${subpage}" not found in cached page state`, 'subpage', [`Available repeaters: ${Array.from(pageState.repeaters.keys()).join(', ')}`])); } // Get bookmark from rowOrder array (1-indexed lineNo to 0-indexed array) const rowIndex = lineNo - 1; if (rowIndex < 0 || rowIndex >= repeaterState.rowOrder.length) { return err(new InputValidationError(`lineNo ${lineNo} is out of range - only ${repeaterState.rowOrder.length} rows available`, 'lineNo', [`Valid range: 1 to ${repeaterState.rowOrder.length}`])); } targetRowBookmark = repeaterState.rowOrder[rowIndex]; logger.info(`Resolved lineNo ${lineNo} to bookmark: ${targetRowBookmark}`); } // PROTOCOL FIX: Instead of using SetCurrentRowAndRowsSelection, we pass the bookmark // directly in SaveValue's 'key' parameter. This is more reliable and matches how // BC web client handles cell edits in existing rows. logger.info(`Updating existing line with bookmark: ${targetRowBookmark} (using SaveValue key)`); } else { // Neither lineBookmark nor lineNo provided - writing to draft row (new line creation) logger.info(`Writing to draft row in subpage "${subpage}"`); // CORRECT APPROACH from decompiled BC code analysis: // BC uses DraftLinePattern/MultipleNewLinesPattern to PRE-CREATE draft rows during LoadForm. // Document subforms (Sales Lines, Purchase Lines) have 15+ draft rows at the end. // // PROTOCOL SEQUENCE: // 1. Draft rows already exist from LoadForm (in DataRefreshChange) // 2. User clicks into draft row OR we find first available draft row // 3. SaveValue populates fields (marks row as Dirty | Draft) // 4. AutoInsertPattern automatically commits when user tabs to next field // // See decompiled: // - Microsoft.Dynamics.Nav.Client.UI/Nav/Client/UIPatterns/DraftLinePattern.cs // - Microsoft.Dynamics.Nav.Client.UI/Nav/Client/UIPatterns/MultipleNewLinesPattern.cs // - Microsoft.Dynamics.Nav.Client.UI/Nav/Client/UIPatterns/AutoInsertPattern.cs // // SIMPLIFIED APPROACH: // Since draft rows exist and BC's web client uses SetCurrentRow with Delta to navigate, // we can simply send SaveValue directly to the repeater. BC will: // - Position to the next available draft row automatically // - Apply the values and mark row as dirty // - AutoInsertPattern commits when we move on // // No explicit row selection needed for NEW lines - BC handles positioning. logger.info(`Draft rows should exist from LoadForm - proceeding directly with field writes`); } // Build column field map from repeater metadata for field validation fieldMap = this.buildColumnFieldMap(repeater); logger.info(`Built column field map with ${fieldMap.size} columns`); } // Set each field value using SaveValue interaction const updatedFields = []; const failedFields = []; for (const [fieldName, fieldSpec] of Object.entries(fields)) { let { value: fieldValue, controlPath } = fieldSpec; // CRITICAL: Look up controlPath from fieldMap if not provided // This is essential for cache updates to work properly if (!controlPath && fieldMap) { const lookupKey = fieldName.toLowerCase().trim(); const fieldMeta = fieldMap.get(lookupKey); if (fieldMeta?.controlPath) { controlPath = fieldMeta.controlPath; logger.info(`Resolved controlPath for "${fieldName}": ${controlPath}`); } else { // Debug: show what keys are available and check if key exists const hasKey = fieldMap.has(lookupKey); const fieldMetaDebug = fieldMap.get(lookupKey); const availableKeys = Array.from(fieldMap.keys()).slice(0, 10).join(', '); logger.error(`[CACHE DEBUG] controlPath lookup failed for "${fieldName}": hasKey=${hasKey}, fieldMeta exists=${!!fieldMetaDebug}, fieldMeta.controlPath=${fieldMetaDebug?.controlPath}`); logger.warn(`Could not resolve controlPath for "${fieldName}" (key="${lookupKey}") from fieldMap. Available keys (first 10): ${availableKeys}`); } } logger.info(`Setting field "${fieldName}" = "${fieldValue}"${controlPath ? ` (controlPath: ${controlPath})` : ''}...`); const result = await this.setFieldValue(connection, formId, fieldName, fieldValue, pageContextId, controlPath, immediateValidation, targetRowBookmark // Pass bookmark for existing row modification ); if (isOk(result)) { updatedFields.push(fieldName); logger.info(`Field "${fieldName}" updated successfully`); } else { const errorMsg = result.error.message; const errorWithCtx = result.error; const validationMsg = errorWithCtx.context?.validationMessage; failedFields.push({ field: fieldName, error: errorMsg, validationMessage: validationMsg, }); logger.info(`Field "${fieldName}" failed: ${errorMsg}`); // Stop on first error if stopOnError is true if (stopOnError) { logger.info(`Stopping on first error (stopOnError=true)`); break; } } } // NOTE: needsRefresh flag is now set conditionally in setFieldValue() method // Only set if PropertyChanges cache update fails - avoids broken LoadForm on Card pages // Return result if (failedFields.length === 0) { // All fields updated successfully // Track unsaved changes and record operation in workflow if (workflow) { workflow.trackUnsavedChanges(fields); workflow.recordOperation('write_page_data', { pageContextId, fields, subpage, lineBookmark, lineNo }, { success: true, data: { updatedFields, fieldCount: updatedFields.length } }); } return ok({ success: true, pageContextId, saved: false, // This tool never saves - caller must use execute_action("Save") message: `Successfully updated ${updatedFields.length} field(s): ${updatedFields.join(', ')}`, updatedFields, }); } else if (updatedFields.length > 0) { // Partial success // Track partial unsaved changes and record operation with errors in workflow if (workflow) { // Track only the fields that succeeded const successfulFieldsObj = {}; for (const fieldName of updatedFields) { successfulFieldsObj[fieldName] = fields[fieldName]; } workflow.trackUnsavedChanges(successfulFieldsObj); // Record operation with partial success workflow.recordOperation('write_page_data', { pageContextId, fields, subpage, lineBookmark, lineNo }, { success: false, error: `Partially updated ${updatedFields.length} field(s). Failed: ${failedFields.map(f => f.field).join(', ')}`, data: { updatedFields, failedFields } }); // Record failed fields as errors for (const failed of failedFields) { workflow.recordError(`Field "${failed.field}": ${failed.error}${failed.validationMessage ? ` - ${failed.validationMessage}` : ''}`); } } return ok({ success: false, pageContextId, saved: false, // This tool never saves message: `Partially updated ${updatedFields.length} field(s). Failed: ${failedFields.map(f => f.field).join(', ')}`, updatedFields, failedFields, // Structured: [{ field, error, validationMessage? }] }); } else { // Complete failure // Record operation failure and errors in workflow if (workflow) { const errorMsg = `Failed to update any fields. Errors: ${failedFields.map(f => `${f.field}: ${f.error}`).join('; ')}`; workflow.recordOperation('write_page_data', { pageContextId, fields, subpage, lineBookmark, lineNo }, { success: false, error: errorMsg, data: { failedFields } }); // Record each field failure as an error for (const failed of failedFields) { workflow.recordError(`Field "${failed.field}": ${failed.error}${failed.validationMessage ? ` - ${failed.validationMessage}` : ''}`); } } return err(new ProtocolError(`Failed to update any fields. Errors: ${failedFields.map(f => `${f.field}: ${f.error}`).join('; ')}`, { pageId, formId, failedFields })); } } /** * Finds a repeater (subpage) by name using PageState (preferred) or LogicalForm (fallback). * Searches by both caption and design name (case-insensitive). * * Phase 1: Uses PageState if available, falls back to LogicalForm * Phase 2: PageState will be required */ async findRepeaterBySubpage(pageContextId, pageContext, subpageName) { const logger = moduleLogger.child({ method: 'findRepeaterBySubpage' }); // Try PageState first (Phase 1: Dual-state approach) try { const cache = PageContextCache.getInstance(); const pageState = await cache.getPageState(pageContextId); if (pageState) { logger.info(`Using PageState for repeater lookup`); // Search repeaters Map by name (case-insensitive) const searchKey = subpageName.toLowerCase().trim(); let foundRepeater; for (const [_key, repeater] of pageState.repeaters.entries()) { if (repeater.caption?.toLowerCase().trim() === searchKey || repeater.name?.toLowerCase().trim() === searchKey) { foundRepeater = repeater; break; } } if (foundRepeater) { // Convert PageState RepeaterState to RepeaterMetadata for compatibility const repeaterMeta = { name: foundRepeater.name, caption: foundRepeater.caption, controlPath: foundRepeater.controlPath, formId: foundRepeater.formId, columns: Array.from(foundRepeater.columns.values()).map(col => ({ caption: col.caption, designName: col.designName, controlPath: col.controlPath, index: col.index, controlId: col.controlId, visible: col.visible, editable: col.editable, columnBinderPath: col.columnBinderPath, })), }; logger.info(`Found repeater "${repeaterMeta.caption || repeaterMeta.name}" via PageState`); logger.info(` - controlPath: ${repeaterMeta.controlPath}`); logger.info(` - formId: ${repeaterMeta.formId || 'undefined'}`); logger.info(` - columns.length: ${repeaterMeta.columns.length}`); logger.info(` - totalRowCount: ${foundRepeater.totalRowCount || 'undefined'}`); logger.info(` - loaded rows: ${foundRepeater.rows.size}`); logger.info(` - pendingOperations: ${foundRepeater.pendingOperations}`); logger.info(` - isDirty: ${foundRepeater.isDirty}`); return ok(repeaterMeta); } // Not found in PageState - fall through to LogicalForm logger.info(`Repeater "${subpageName}" not found in PageState, trying LogicalForm fallback`); } else { logger.info(`No PageState available, using LogicalForm for repeater lookup`); } } catch (error) { logger.warn(`PageState lookup failed: ${error}, falling back to LogicalForm`); } // Fallback: Use LogicalForm (original implementation) const logicalForm = pageContext?.logicalForm; if (!logicalForm) { return err(new ProtocolError(`No cached LogicalForm or PageState available for repeater lookup`, { subpageName })); } // Extract repeaters from logicalForm using ControlParser const parser = new ControlParser(); const controls = parser.walkControls(logicalForm); const repeaters = parser.extractRepeaters(controls); // Search by name (case-insensitive) const searchKey = subpageName.toLowerCase().trim(); const found = repeaters.find(r => r.caption?.toLowerCase().trim() === searchKey || r.name?.toLowerCase().trim() === searchKey); if (!found) { const availableNames = repeaters .map(r => r.caption || r.name) .filter(Boolean) .join(', '); return err(new InputValidationError(`Subpage "${subpageName}" not found on page`, 'subpage', [ `The subpage/repeater "${subpageName}" does not exist on this page.`, `Available subpages: ${availableNames || 'none'}`, ])); } // DIAGNOSTIC: Log found repeater metadata logger.info(`Found repeater "${found.caption || found.name}" via LogicalForm`); logger.info(` - controlPath: ${found.controlPath}`); logger.info(` - formId: ${found.formId || 'undefined'}`); logger.info(` - columns.length: ${found.columns.length}`); if (found.columns.length > 0) { logger.info(` - First 3 columns: ${found.columns.slice(0, 3).map(c => c.caption || c.designName).join(', ')}`); } else { logger.warn(` WARNING: Repeater has ZERO columns! This will cause "Cannot find controlPath" error.`); } return ok(found); } /** * Builds a field map from repeater column metadata. * Maps column captions and design names to column metadata. */ buildColumnFieldMap(repeater) { const logger = moduleLogger.child({ method: 'buildColumnFieldMap' }); const map = new Map(); // DIAGNOSTIC: Log what we're building from logger.info(`Building field map from ${repeater.columns.length} columns`); if (repeater.columns.length === 0) { logger.warn(` ⚠️ PROBLEM: No columns to build map from!`); return map; } for (const column of repeater.columns) { // Add by caption if (column.caption) { const key = column.caption.toLowerCase().trim(); map.set(key, column); } // Add by design name if (column.designName) { const key = column.designName.toLowerCase().trim(); map.set(key, column); } } logger.info(` Built map with ${map.size} field name mappings`); if (map.size > 0) { const sampleKeys = Array.from(map.keys()).slice(0, 5); logger.info(` Sample keys: ${sampleKeys.join(', ')}`); } return map; } /** * Selects a line in a repeater using SetCurrentRowAndRowsSelection. * This sets server-side focus to the target row before field updates. */ async selectLine(connection, formId, repeaterPath, bookmark) { const logger = createToolLogger('write_page_data', formId); logger.info(`Selecting line with bookmark "${bookmark}" in repeater ${repeaterPath}`); try { await connection.invoke({ interactionName: 'SetCurrentRowAndRowsSelection', namedParameters: { key: bookmark, selectAll: false, rowsToSelect: [bookmark], unselectAll: true, rowsToUnselect: [], }, controlPath: repeaterPath, formId, callbackId: '', // Empty callback for synchronous operations }); logger.info(`Successfully selected line`); return ok(undefined); } catch (error) { logger.error(`Failed to select line: ${error}`); return err(new ProtocolError(`Failed to select line in subpage: ${error}`, { repeaterPath, bookmark, error })); } } /** * Extracts bookmark from DataRefreshChange event (for new line creation). * BC sends this async event when a new line is inserted with the new bookmark. */ extractBookmarkFromDataRefresh(handlers) { const logger = createToolLogger('write_page_data', 'bookmark-extraction'); // Look for LogicalClientChangeHandler with DataRefreshChange for (const handler of handlers) { if (isLogicalClientChangeHandler(handler)) { const changes = handler.parameters?.[1]; if (Array.isArray(changes)) { for (const change of changes) { // BC27+ uses full type name 'DataRefreshChange' instead of shorthand 'drch' if (isDataRefreshChangeType(change.t)) { // Look for DataRowInserted with bookmark const dataRefresh = change; const rowChanges = dataRefresh.RowChanges || []; for (const rowChange of rowChanges) { // BC27+ uses full type name 'DataRowInserted' instead of shorthand 'drich' if (isDataRowInsertedType(rowChange.t)) { // DataRowInsertedChange has Row: ClientDataRow with Bookmark property const rowData = rowChange.Row; const bookmark = rowData?.Bookmark; if (bookmark) { logger.info(`Extracted bookmark from new line: ${bookmark}`); return bookmark; } } } } } } } } logger.warn(`No bookmark found in DataRefreshChange handlers`); return undefined; } /** * Sets a field value using the SaveValue interaction. * Uses the real BC protocol captured from traffic. * Optionally inspects handlers for validation errors. * * Supports null values for clearing fields (converted to empty string). */ async setFieldValue(connection, formId, fieldName, value, pageContextId, controlPath, immediateValidation = true, rowBookmark // Bookmark for existing row modification (null for header/new rows) ) { // Handle null values (clear field) let actualValue; if (value === null || value === undefined) { actualValue = ''; // Clear field by setting to empty string } else if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { actualValue = value; } else { return err(new InputValidationError(`Field '${fieldName}' value must be a string, number, boolean, or null`, fieldName, [`Expected string|number|boolean|null, got ${typeof value}`])); } // Build SaveValue interaction using real BC protocol // This matches the protocol we captured and verified const interaction = { interactionName: 'SaveValue', skipExtendingSessionLifetime: false, namedParameters: JSON.stringify({ key: rowBookmark || null, // Use bookmark for existing row, null for header/new rows newValue: actualValue, alwaysCommitChange: true, notifyBusy: 1, telemetry: { 'Control name': fieldName, 'QueuedTime': new Date().toISOString(), }, }), callbackId: '', // Will be set by connection controlPath: controlPath || undefined, // Use provided controlPath or let BC find it formId, }; // ===== CRITICAL: SaveValue uses async handler pattern ===== // BC sends PropertyChanges via async Message events OR in synchronous response // We must wait for async LogicalClientChangeHandler but abort if found in sync response // Create AbortController to cancel async wait if PropertyChanges found in sync response const abortController = new AbortController(); // Set up async handler listener BEFORE sending interaction const asyncHandlerPromise = connection.waitForHandlers((handlers) => { // Look for LogicalClientChangeHandler with PropertyChanges for our field const logicalHandler = handlers.find((h) => isLogicalClientChangeHandler(h)); if (logicalHandler) { moduleLogger.info(`[PropertyChanges] Found async LogicalClientChangeHandler after SaveValue`); return { matched: true, data: handlers }; } return { matched: false }; }, { timeoutMs: 1000, signal: abortController.signal } // Pass abort signal ); // Send interaction const result = await connection.invoke(interaction); if (!result.ok) { abortController.abort(); // Cancel async wait on error // Suppress the AbortedError from the promise since we're returning early asyncHandlerPromise.catch(() => { }); return err(new ProtocolError(`Failed to set field "${fieldName}": ${result.error.message}`, { fieldName, value, formId, controlPath, originalError: result.error })); } // Check if PropertyChanges are in synchronous response let foundPropertyChangesInSync = false; moduleLogger.info(`[PropertyChanges] Checking ${result.value.length} synchronous handlers for PropertyChanges`); for (const handler of result.value) { moduleLogger.info(`[PropertyChanges] Sync handler type: ${handler.handlerType}`); if (isLogicalClientChangeHandler(handler)) { const params = handler.parameters; moduleLogger.info(`[PropertyChanges] Found LogicalClientChangeHandler in sync response, params.length=${Array.isArray(params) ? params.length : 'not array'}`); if (Array.isArray(params) && params.length >= 2) { const changes = params[1]; moduleLogger.info(`[PropertyChanges] Changes is array: ${Array.isArray(changes)}, length: ${changes.length}`); for (const change of changes) { moduleLogger.info(`[PropertyChanges] Change type: ${change?.t}`); // BC uses both "prc" (PropertyChanges) and "prch" (PropertyChange) type ids if (isPropertyChanges(change) || isPropertyChange(change)) { foundPropertyChangesInSync = true; moduleLogger.info(`[PropertyChanges] Found PropertyChange(s) in sync response!`); break; } } } } if (foundPropertyChangesInSync) break; } // If PropertyChanges found in sync response, cancel async wait to avoid timeout delay if (foundPropertyChangesInSync) { abortController.abort(); moduleLogger.info(`[PropertyChanges] Found in synchronous response, cancelled async wait`); } else { moduleLogger.info(`[PropertyChanges] PropertyChanges NOT in sync response, waiting for async...`); } // Wait for async PropertyChanges handlers (will be aborted if already found in sync) // Use Promise.race with setTimeout as a defensive fallback in case AbortSignal.timeout fails const FALLBACK_TIMEOUT_MS = 2000; // Slightly longer than the 1000ms primary timeout let asyncHandlers = []; try { const fallbackTimeoutPromise = new Promise((_, reject) => { setTimeout(() => reject(new Error('Fallback timeout')), FALLBACK_TIMEOUT_MS); }); asyncHandlers = await Promise.race([asyncHandlerPromise, fallbackTimeoutPromise]); moduleLogger.info(`[PropertyChanges] Received ${asyncHandlers.length} async handlers after SaveValue`); } catch (error) { // AbortError means we found PropertyChanges in sync response - this is good! // Timeout (either AbortSignal or fallback) means no async handlers - this is also OK for some fields const errorObj = error; if (errorObj?.name === 'AbortError') { moduleLogger.info(`[PropertyChanges] Async wait aborted (PropertyChanges already in sync response)`); } else if (errorObj?.message === 'Fallback timeout') { moduleLogger.info(`[PropertyChanges] Fallback timeout - no async handlers received`); } else { moduleLogger.info(`[PropertyChanges] No async handlers received (timeout) - this is OK for some fields`); } } // If immediateValidation is enabled, inspect handlers for errors and other messages if (immediateValidation) { // Cast to GenericBCHandler for checking special handler types not in standard union const handlers = result.value; // Check for BC error messages (blocking errors) const errorHandler = handlers.find((h) => h.handlerType === 'DN.ErrorMessageProperties' || h.handlerType === 'DN.ErrorDialogProperties'); if (errorHandler) { const errorParams = errorHandler.parameters?.[0]; const errorMessage = String(errorParams?.Message || errorParams?.ErrorMessage || 'Unknown error'); return err(new ProtocolError(`BC error: ${errorMessage}`, { fieldName, value, formId, controlPath, errorHandler, validationMessage: errorMessage, handlerType: 'error' })); } // Check for validation errors (blocking validation) const validationHandler = handlers.find((h) => h.handlerType === 'DN.ValidationMessageProperties'); if (validationHandler) { const validationParams = validationHandler.parameters?.[0]; const validationMessage = String(validationParams?.Message || 'Validation failed'); return err(new ProtocolError(`BC validation error: ${validationMessage}`, { fieldName, value, formId, controlPath, validationHandler, validationMessage, handlerType: 'validation' })); } // Check for confirmation dialogs (require user interaction) const confirmHandler = handlers.find((h) => h.handlerType === 'DN.ConfirmDialogProperties' || h.handlerType === 'DN.YesNoDialogProperties'); if (confirmHandler) { const confirmParams = confirmHandler.parameters?.[0]; const confirmMessage = String(confirmParams?.Message || confirmParams?.ConfirmText || 'Confirmation required'); return err(new ProtocolError(`BC confirmation required: ${confirmMessage}`, { fieldName, value, formId, controlPath, confirmHandler, validationMessage: confirmMessage, handlerType: 'confirm' })); } // Note: Info messages and busy states are non-blocking, so we don't fail on t