import fetch, { Response } from 'node-fetch';
import { readFileSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import { homedir } from 'os';
import { CookieJar } from 'tough-cookie';
import {
UpdateSetOperationRequest,
UpdateSetOperationResult,
UpdateSetErrorResponse,
UpdateSetRecord,
XMLRecord,
WorkingSetState,
UpdateSetContents,
RecentXMLActivity,
UpdateSetDiff,
XMLSummary,
XMLDetectionResult,
XMLReassignmentResult,
UpdateSetFilters,
UpdateSetClientConfig,
DEFAULT_UPDATE_SET_CONFIG,
loadUpdateSetConfig,
ServiceNowUpdateSetError,
UPDATE_SET_ERROR_CODES,
isWorkingSetState,
isUpdateSetRecord,
isXMLRecord,
} from './updateSetTypes.js';
/**
* Load credentials from MCP-ACE specific .env file
* Reuses the same credential loading logic as other ServiceNow clients
* Checks in order: project directory → home directory → system directory
*/
function loadEnvFile(): Record<string, string> {
const envVars: Record<string, string> = {};
// Try project directory first (project-specific config, highest priority)
try {
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Go up from src/servicenow to project root
const projectRoot = resolve(__dirname, '../..');
const projectEnvFile = resolve(projectRoot, '.servicenow-ace.env');
const content = readFileSync(projectEnvFile, 'utf-8');
content.split('\n').forEach((line) => {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith('#')) {
const [key, ...valueParts] = trimmed.split('=');
const value = valueParts.join('=').trim();
if (key && value) {
envVars[key.trim()] = value;
}
}
});
return envVars; // Successfully loaded from project file, return early
} catch (error) {
// Project .env file not found - try user home directory
}
// Try user home directory (MCP-ACE specific)
const userEnvFile = resolve(homedir(), '.servicenow-ace.env');
try {
const content = readFileSync(userEnvFile, 'utf-8');
content.split('\n').forEach((line) => {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith('#')) {
const [key, ...valueParts] = trimmed.split('=');
const value = valueParts.join('=').trim();
if (key && value) {
envVars[key.trim()] = value;
}
}
});
return envVars; // Successfully loaded from user file, return early
} catch (error) {
// User .env file not found or can't be read - try shared system location
}
// Try shared system credential file as fallback (MCP-ACE specific)
const sharedEnvFile = '/etc/csadmin/servicenow-ace.env';
try {
const content = readFileSync(sharedEnvFile, 'utf-8');
content.split('\n').forEach((line) => {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith('#')) {
const [key, ...valueParts] = trimmed.split('=');
const value = valueParts.join('=').trim();
if (key && value) {
envVars[key.trim()] = value;
}
}
});
} catch (error) {
// Shared .env file not found either - that's okay, env vars might be set
}
return envVars;
}
/**
* ServiceNow Update Set Management Client
*
* Handles all update set lifecycle operations, XML detection and reassignment,
* and working set state management using background scripts and Table API.
*/
export class ServiceNowUpdateSetClient {
private instance: string;
private username: string;
private password: string;
private baseUrl: string;
private clientConfig: Required<UpdateSetClientConfig>;
private cookieJar: CookieJar;
private workingUpdateSet: WorkingSetState | null = null;
constructor(clientConfig: UpdateSetClientConfig = {}) {
// Load MCP-ACE specific credentials (same as other clients)
// Priority order: 1) process.env (from MCP config or system), 2) .env files
let instance = process.env.SERVICENOW_ACE_INSTANCE;
let username = process.env.SERVICENOW_ACE_USERNAME;
let password = process.env.SERVICENOW_ACE_PASSWORD;
// If env vars are missing, try .env file (project directory → home directory → system directory)
if (!instance || !username || !password) {
const envFileVars = loadEnvFile();
instance = instance || envFileVars.SERVICENOW_ACE_INSTANCE;
username = username || envFileVars.SERVICENOW_ACE_USERNAME;
password = password || envFileVars.SERVICENOW_ACE_PASSWORD;
}
if (!instance || !username || !password) {
throw new ServiceNowUpdateSetError(
UPDATE_SET_ERROR_CODES.MISSING_CREDENTIALS,
undefined,
'Missing required credentials. Set SERVICENOW_ACE_INSTANCE, SERVICENOW_ACE_USERNAME, SERVICENOW_ACE_PASSWORD as environment variables or create ~/.servicenow-ace.env'
);
}
this.instance = instance;
this.username = username;
this.password = password;
this.baseUrl = `https://${instance}`;
// Load from environment variables first, then apply any overrides
const envConfig = loadUpdateSetConfig();
this.clientConfig = { ...envConfig, ...clientConfig };
this.cookieJar = new CookieJar();
}
/**
* Execute an update set operation (main entry point)
*
* @param request - Update set operation request
* @returns Promise resolving to operation result
*/
async executeUpdateSetOperation(request: UpdateSetOperationRequest): Promise<UpdateSetOperationResult> {
const startTime = Date.now();
try {
// Validate input
this.validateUpdateSetRequest(request);
// Route to appropriate operation
let result: any;
let metadata: any = {
operation: request.operation,
executionTime: 0,
timestamp: new Date().toISOString(),
working_set: this.workingUpdateSet,
};
switch (request.operation) {
case 'create':
result = await this.createUpdateSet(request);
if (request.set_as_working && result.update_set) {
this.setWorkingSet(result.update_set);
result.working_set = this.workingUpdateSet;
}
break;
case 'set_working':
if (!request.update_set_sys_id) {
throw new ServiceNowUpdateSetError(
UPDATE_SET_ERROR_CODES.MISSING_PARAMETER,
undefined,
'update_set_sys_id is required for set_working operation'
);
}
const updateSet = await this.getUpdateSetInfo(request.update_set_sys_id);
this.setWorkingSet(updateSet);
result = { working_set: this.workingUpdateSet };
break;
case 'show_working':
result = { working_set: this.workingUpdateSet };
break;
case 'clear_working':
this.clearWorkingSet();
result = { working_set: null };
break;
case 'insert':
if (!request.table || !request.data) {
throw new ServiceNowUpdateSetError(
UPDATE_SET_ERROR_CODES.MISSING_PARAMETER,
undefined,
'table and data are required for insert operation'
);
}
result = await this.insertWithReassignment(
request.table,
request.data as Record<string, any>,
request.update_set_sys_id,
request.custom_timestamp_before
);
break;
case 'update':
if (!request.table || !request.sys_id || !request.data) {
throw new ServiceNowUpdateSetError(
UPDATE_SET_ERROR_CODES.MISSING_PARAMETER,
undefined,
'table, sys_id, and data are required for update operation'
);
}
result = await this.updateWithReassignment(
request.table,
request.sys_id,
request.data as Record<string, any>,
request.update_set_sys_id,
request.custom_timestamp_before
);
break;
case 'rehome':
if (!request.xml_sys_ids && !request.query) {
throw new ServiceNowUpdateSetError(
UPDATE_SET_ERROR_CODES.MISSING_PARAMETER,
undefined,
'xml_sys_ids or query is required for rehome operation'
);
}
if (request.xml_sys_ids) {
result = await this.rehomeXMLByIds(
request.xml_sys_ids,
request.update_set_sys_id || this.getWorkingSetId(),
request.force || false
);
} else {
result = await this.rehomeXMLByQuery(
request.query!,
request.update_set_sys_id || this.getWorkingSetId(),
request.force || false
);
}
break;
case 'contents':
result = await this.getUpdateSetContents(
request.update_set_sys_id || this.getWorkingSetId(),
request.response_mode
);
break;
case 'recent':
result = await this.getRecentXML(request.limit || 50, request.response_mode);
break;
case 'list':
result = await this.listUpdateSets(request.filters, request.limit, request.offset);
break;
case 'info':
if (!request.update_set_sys_id) {
throw new ServiceNowUpdateSetError(
UPDATE_SET_ERROR_CODES.MISSING_PARAMETER,
undefined,
'update_set_sys_id is required for info operation'
);
}
result = { update_set: await this.getUpdateSetInfo(request.update_set_sys_id) };
break;
case 'complete':
if (!request.update_set_sys_id) {
throw new ServiceNowUpdateSetError(
UPDATE_SET_ERROR_CODES.MISSING_PARAMETER,
undefined,
'update_set_sys_id is required for complete operation'
);
}
result = await this.completeUpdateSet(request.update_set_sys_id);
break;
case 'reopen':
if (!request.update_set_sys_id) {
throw new ServiceNowUpdateSetError(
UPDATE_SET_ERROR_CODES.MISSING_PARAMETER,
undefined,
'update_set_sys_id is required for reopen operation'
);
}
// Check if state changes are disabled
if (process.env.SKYENET_UPDATESET_DISABLE_STATE_CHANGES === 'true') {
throw new ServiceNowUpdateSetError(
UPDATE_SET_ERROR_CODES.OPERATION_DISABLED,
undefined,
'Reopen operation is disabled due to ServiceNow business rule limitations. Use create, list, contents, recent, diff, and complete operations instead.'
);
}
result = await this.reopenUpdateSet(request.update_set_sys_id);
break;
case 'delete':
if (!request.update_set_sys_id) {
throw new ServiceNowUpdateSetError(
UPDATE_SET_ERROR_CODES.MISSING_PARAMETER,
undefined,
'update_set_sys_id is required for delete operation'
);
}
// Check if state changes are disabled
if (process.env.SKYENET_UPDATESET_DISABLE_STATE_CHANGES === 'true') {
throw new ServiceNowUpdateSetError(
UPDATE_SET_ERROR_CODES.OPERATION_DISABLED,
undefined,
'Delete operation is disabled due to ServiceNow business rule limitations. Use create, list, contents, recent, diff, and complete operations instead.'
);
}
result = await this.deleteUpdateSet(request.update_set_sys_id);
break;
case 'diff_default':
result = await this.diffAgainstDefault(
request.update_set_sys_id || this.getWorkingSetId(),
request.response_mode
);
break;
default:
throw new ServiceNowUpdateSetError(
UPDATE_SET_ERROR_CODES.INVALID_OPERATION,
undefined,
`Invalid operation: ${request.operation}`
);
}
const executionTime = Date.now() - startTime;
metadata.executionTime = executionTime;
// Estimate response size for metadata
const responseSize = JSON.stringify(result).length;
metadata.responseSize = responseSize;
metadata.contextOverflowPrevention = responseSize > 40000; // 40KB threshold
// Handle quiet mode for update operations - ultra-minimal response
if (request.quiet && (request.operation === 'insert' || request.operation === 'update' || request.operation === 'set_working')) {
return {
success: true,
data: {
message: 'Update accepted'
},
metadata: {
operation: request.operation,
executionTime: 0,
timestamp: new Date().toISOString(),
quiet_mode: true
}
};
}
return {
success: true,
data: result,
metadata,
};
} catch (error) {
if (error instanceof ServiceNowUpdateSetError) {
throw error;
}
throw new ServiceNowUpdateSetError(
UPDATE_SET_ERROR_CODES.NETWORK_ERROR,
undefined,
`Update set operation failed: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
// ============================================================================
// STATE MANAGEMENT
// ============================================================================
/**
* Set the working update set
*/
setWorkingSet(updateSet: UpdateSetRecord): void {
this.workingUpdateSet = {
sys_id: updateSet.sys_id,
name: updateSet.name,
scope: updateSet.scope,
state: updateSet.state,
created_on: updateSet.created_on,
};
}
/**
* Get the working update set
*/
getWorkingSet(): WorkingSetState | null {
return this.workingUpdateSet;
}
/**
* Clear the working update set
*/
clearWorkingSet(): void {
this.workingUpdateSet = null;
}
/**
* Get working set ID or throw error if not set
*/
private getWorkingSetId(): string {
if (!this.workingUpdateSet) {
throw new ServiceNowUpdateSetError(
UPDATE_SET_ERROR_CODES.WORKING_SET_NOT_SET,
undefined,
'No working update set is set. Use set_working operation first.'
);
}
return this.workingUpdateSet.sys_id;
}
// ============================================================================
// LIFECYCLE OPERATIONS
// ============================================================================
/**
* Create a new update set
*/
async createUpdateSet(request: UpdateSetOperationRequest): Promise<{ update_set: UpdateSetRecord }> {
if (!request.name) {
throw new ServiceNowUpdateSetError(
UPDATE_SET_ERROR_CODES.MISSING_PARAMETER,
undefined,
'name is required for create operation'
);
}
const scope = request.scope || this.clientConfig.defaultScope;
const description = request.description || `Created by SkyeNet MCP ACE on ${new Date().toISOString()}`;
// Create update set via background script
const script = `
var updateSet = new GlideRecord('sys_update_set');
updateSet.initialize();
updateSet.setValue('name', '${request.name}');
updateSet.setValue('description', '${description}');
updateSet.setValue('scope', '${scope}');
updateSet.setValue('state', 'in_progress');
updateSet.setValue('created_by', gs.getUserID());
var sysId = updateSet.insert();
if (sysId) {
gs.info('SUCCESS: ' + sysId);
gs.info('NAME: ' + updateSet.getValue('name'));
gs.info('SCOPE: ' + updateSet.getValue('scope'));
gs.info('STATE: ' + updateSet.getValue('state'));
} else {
gs.warn('ERROR: Failed to create update set');
}
`;
// Run in global scope to avoid cross-scope security issues
const result = await this.executeBackgroundScript(script, 'global');
if (!result.success || !result.output.text.includes('SUCCESS:')) {
throw new ServiceNowUpdateSetError(
UPDATE_SET_ERROR_CODES.SCRIPT_EXECUTION_ERROR,
undefined,
'Failed to create update set: ' + (result.output.text || 'Unknown error')
);
}
// Extract sys_id from output
const sysIdMatch = result.output.text.match(/SUCCESS:\s*([a-f0-9]{32})/);
if (!sysIdMatch) {
throw new ServiceNowUpdateSetError(
UPDATE_SET_ERROR_CODES.SCRIPT_EXECUTION_ERROR,
undefined,
'Could not extract update set sys_id from script output'
);
}
const sysId = sysIdMatch[1];
const updateSet = await this.getUpdateSetInfo(sysId);
return { update_set: updateSet };
}
/**
* Complete an update set
*/
async completeUpdateSet(sysId: string): Promise<{ update_set: UpdateSetRecord }> {
// First check if update set has any in-progress XML
const contents = await this.getUpdateSetContents(sysId);
if (contents.total_count > 0) {
// Check if any XML records are in progress
const inProgressXML = contents.by_type?.['in_progress'] || { count: 0 };
if (inProgressXML.count > 0) {
throw new ServiceNowUpdateSetError(
UPDATE_SET_ERROR_CODES.UPDATE_SET_HAS_XML,
undefined,
`Cannot complete update set: ${inProgressXML.count} XML records are still in progress`
);
}
}
// Update the update set state to complete
const updateData = { state: 'complete' };
await this.updateTableRecord('sys_update_set', sysId, updateData);
const updateSet = await this.getUpdateSetInfo(sysId);
return { update_set: updateSet };
}
/**
* Reopen an update set
*/
async reopenUpdateSet(sysId: string): Promise<{ update_set: UpdateSetRecord }> {
try {
const updateData = { state: 'in_progress' };
await this.updateTableRecord('sys_update_set', sysId, updateData);
// Add a small delay to allow ServiceNow to process the state change
await new Promise(resolve => setTimeout(resolve, 1000));
const updateSet = await this.getUpdateSetInfo(sysId);
// Check if the state change was successful
if (updateSet.state !== 'in_progress') {
throw new ServiceNowUpdateSetError(
UPDATE_SET_ERROR_CODES.STATE_TRANSITION_FAILED,
undefined,
`Failed to reopen update set. Current state: ${updateSet.state}. This may be due to ServiceNow business rules that prevent reopening completed update sets. Check your instance's business rules and ACLs.`
);
}
return { update_set: updateSet };
} catch (error) {
if (error instanceof ServiceNowUpdateSetError) {
throw error;
}
throw new ServiceNowUpdateSetError(
UPDATE_SET_ERROR_CODES.STATE_TRANSITION_FAILED,
undefined,
`Failed to reopen update set: ${error instanceof Error ? error.message : 'Unknown error'}. This may be due to ServiceNow business rules or insufficient permissions.`
);
}
}
/**
* Delete an update set (soft delete)
*/
async deleteUpdateSet(sysId: string): Promise<{ update_set: UpdateSetRecord }> {
try {
const updateData = { state: 'deleted' };
await this.updateTableRecord('sys_update_set', sysId, updateData);
// Add a small delay to allow ServiceNow to process the state change
await new Promise(resolve => setTimeout(resolve, 1000));
const updateSet = await this.getUpdateSetInfo(sysId);
// Check if the state change was successful
if (updateSet.state !== 'deleted') {
throw new ServiceNowUpdateSetError(
UPDATE_SET_ERROR_CODES.STATE_TRANSITION_FAILED,
undefined,
`Failed to delete update set. Current state: ${updateSet.state}. This may be due to ServiceNow business rules that prevent deleting update sets. Check your instance's business rules and ACLs.`
);
}
return { update_set: updateSet };
} catch (error) {
if (error instanceof ServiceNowUpdateSetError) {
throw error;
}
throw new ServiceNowUpdateSetError(
UPDATE_SET_ERROR_CODES.STATE_TRANSITION_FAILED,
undefined,
`Failed to delete update set: ${error instanceof Error ? error.message : 'Unknown error'}. This may be due to ServiceNow business rules or insufficient permissions.`
);
}
}
/**
* List update sets with optional filters, limit, and offset
*/
async listUpdateSets(filters?: UpdateSetFilters, limit?: number, offset?: number): Promise<{ update_sets: UpdateSetRecord[] }> {
let query = '';
const queryParts: string[] = [];
if (filters?.scope) {
queryParts.push(`scope=${filters.scope}`);
}
if (filters?.state) {
queryParts.push(`state=${filters.state}`);
}
if (filters?.created_by) {
queryParts.push(`created_by=${filters.created_by}`);
}
if (filters?.sys_created_on) {
queryParts.push(`sys_created_on${filters.sys_created_on}`);
}
if (queryParts.length > 0) {
query = queryParts.join('^');
}
// Apply limit and offset for pagination
const queryParams: Record<string, any> = {
sysparm_query: query,
sysparm_fields: 'sys_id,name,description,scope,state,created_by,created_on,updated_on',
};
// Use provided limit or default to 50 for context bloat prevention
queryParams.sysparm_limit = limit || 50;
if (offset) {
queryParams.sysparm_offset = offset;
}
const records = await this.queryTableRecords('sys_update_set', queryParams);
const updateSets = records.map(record => this.mapToUpdateSetRecord(record));
return { update_sets: updateSets };
}
/**
* Get update set information with XML count
*/
async getUpdateSetInfo(sysId: string): Promise<UpdateSetRecord> {
const record = await this.getTableRecord('sys_update_set', sysId);
const updateSet = this.mapToUpdateSetRecord(record);
// Get XML count
const xmlRecords = await this.queryTableRecords('sys_update_xml', {
sysparm_query: `update_set=${sysId}`,
sysparm_limit: 1,
});
updateSet.xml_count = xmlRecords.length;
return updateSet;
}
// ============================================================================
// XML DETECTION AND REASSIGNMENT
// ============================================================================
/**
* Detect newly created XML records after an operation
*/
async detectNewXML(
recordSysId: string,
tableName: string,
timestampBefore: number,
timestampAfter: number
): Promise<XMLDetectionResult> {
const detectionStartTime = Date.now();
try {
// Get the Default update set sys_id (personal update set)
const defaultUpdateSet = await this.getDefaultUpdateSet();
// Primary strategy: name LIKE recordSysId% AND in Default update set
let query = `nameLIKE${recordSysId}%^update_set=${defaultUpdateSet.sys_id}^sys_created_on>=${new Date(timestampBefore).toISOString()}^sys_created_on<=${new Date(timestampAfter).toISOString()}`;
let xmlRecords = await this.queryTableRecords('sys_update_xml', {
sysparm_query: query,
sysparm_fields: 'sys_id,name,table,type,update_set,created_by,created_on,updated_on,target_name,sys_update_name',
sysparm_limit: 100,
});
if (xmlRecords.length > 0) {
return {
found: true,
xml_records: xmlRecords.map(record => this.mapToXMLRecord(record)),
detection_strategy: 'primary',
detection_time: Date.now() - detectionStartTime,
};
}
// Fallback strategy: created_on timestamp + created_by + name contains table + in Default update set
query = `sys_created_on>=${new Date(timestampBefore).toISOString()}^sys_created_on<=${new Date(timestampAfter).toISOString()}^created_by=admin^nameLIKE${tableName}^update_set=${defaultUpdateSet.sys_id}`;
xmlRecords = await this.queryTableRecords('sys_update_xml', {
sysparm_query: query,
sysparm_fields: 'sys_id,name,table,type,update_set,created_by,created_on,updated_on,target_name,sys_update_name',
sysparm_limit: 100,
});
return {
found: xmlRecords.length > 0,
xml_records: xmlRecords.map(record => this.mapToXMLRecord(record)),
detection_strategy: 'fallback',
detection_time: Date.now() - detectionStartTime,
message: xmlRecords.length === 0 ? 'No XML records detected using either strategy' : undefined,
};
} catch (error) {
return {
found: false,
xml_records: [],
detection_strategy: 'fallback',
detection_time: Date.now() - detectionStartTime,
message: `XML detection failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
};
}
}
/**
* Reassign XML records to a target update set
*/
async reassignXML(
xmlSysIds: string[],
targetUpdateSetSysId: string,
force: boolean = false
): Promise<XMLReassignmentResult> {
const results: Array<{
xml_sys_id: string;
success: boolean;
error?: string;
old_update_set?: string;
new_update_set: string;
}> = [];
let successCount = 0;
let failureCount = 0;
for (const xmlSysId of xmlSysIds) {
try {
// Get current XML record to check update set
const xmlRecord = await this.getTableRecord('sys_update_xml', xmlSysId);
const currentUpdateSet = xmlRecord.update_set;
// Extract the actual update set ID from the object structure
const currentUpdateSetId = typeof currentUpdateSet === 'string'
? currentUpdateSet
: currentUpdateSet?.value || currentUpdateSet;
// Check if we can move this record
if (!force && currentUpdateSetId !== this.getDefaultUpdateSetId()) {
results.push({
xml_sys_id: xmlSysId,
success: false,
error: `XML record is not in Default update set (current: ${currentUpdateSetId})`,
old_update_set: currentUpdateSet,
new_update_set: targetUpdateSetSysId,
});
failureCount++;
continue;
}
// Reassign the XML record
await this.updateTableRecord('sys_update_xml', xmlSysId, {
update_set: targetUpdateSetSysId,
});
results.push({
xml_sys_id: xmlSysId,
success: true,
old_update_set: currentUpdateSet,
new_update_set: targetUpdateSetSysId,
});
successCount++;
} catch (error) {
results.push({
xml_sys_id: xmlSysId,
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
new_update_set: targetUpdateSetSysId,
});
failureCount++;
}
}
return {
success: failureCount === 0,
reassigned_count: successCount,
failed_count: failureCount,
results,
message: failureCount > 0 ? `${failureCount} XML records failed to reassign` : undefined,
};
}
// ============================================================================
// RECORD OPERATIONS WITH AUTO-REASSIGNMENT
// ============================================================================
/**
* Insert record with automatic XML reassignment
*/
async insertWithReassignment(
table: string,
data: Record<string, any>,
updateSetSysId?: string,
customTimestampBefore?: number
): Promise<{
record: Record<string, any>;
xml_reassignment?: XMLReassignmentResult;
detection_result?: XMLDetectionResult;
}> {
const targetUpdateSet = updateSetSysId || this.getWorkingSetId();
const timestampBefore = customTimestampBefore || Date.now();
// Insert the record
const record = await this.createTableRecord(table, data);
const recordSysId = record.sys_id;
// Wait a moment for XML to be created
await new Promise(resolve => setTimeout(resolve, 1000));
const timestampAfter = Date.now() + this.clientConfig.xmlDetectionWindowMs;
// Detect new XML records
const detectionResult = await this.detectNewXML(
recordSysId,
table,
timestampBefore,
timestampAfter
);
let reassignmentResult: XMLReassignmentResult | undefined;
if (detectionResult.found && detectionResult.xml_records.length > 0) {
// Reassign XML records
const xmlSysIds = detectionResult.xml_records.map(xml => xml.sys_id);
reassignmentResult = await this.reassignXML(xmlSysIds, targetUpdateSet, false);
}
return {
record,
xml_reassignment: reassignmentResult,
detection_result: detectionResult,
};
}
/**
* Update record with automatic XML reassignment
*/
async updateWithReassignment(
table: string,
sysId: string,
data: Record<string, any>,
updateSetSysId?: string,
customTimestampBefore?: number
): Promise<{
record: Record<string, any>;
xml_reassignment?: XMLReassignmentResult;
detection_result?: XMLDetectionResult;
}> {
const targetUpdateSet = updateSetSysId || this.getWorkingSetId();
const timestampBefore = customTimestampBefore || Date.now();
// Update the record
const record = await this.updateTableRecord(table, sysId, data);
// Wait a moment for XML to be created
await new Promise(resolve => setTimeout(resolve, 1000));
const timestampAfter = Date.now() + this.clientConfig.xmlDetectionWindowMs;
// Detect new XML records
const detectionResult = await this.detectNewXML(
sysId,
table,
timestampBefore,
timestampAfter
);
let reassignmentResult: XMLReassignmentResult | undefined;
if (detectionResult.found && detectionResult.xml_records.length > 0) {
// Reassign XML records
const xmlSysIds = detectionResult.xml_records.map(xml => xml.sys_id);
reassignmentResult = await this.reassignXML(xmlSysIds, targetUpdateSet, false);
}
return {
record,
xml_reassignment: reassignmentResult,
detection_result: detectionResult,
};
}
// ============================================================================
// VERIFICATION AND REPORTING
// ============================================================================
/**
* Get update set contents grouped by type
*/
async getUpdateSetContents(updateSetSysId: string, responseMode?: string): Promise<UpdateSetContents> {
const xmlRecords = await this.queryTableRecords('sys_update_xml', {
sysparm_query: `update_set=${updateSetSysId}`,
sysparm_fields: 'sys_id,name,table,type,update_set,created_by,created_on,updated_on,target_name,sys_update_name',
sysparm_limit: 1000,
});
const updateSet = await this.getUpdateSetInfo(updateSetSysId);
const xmlRecordsMapped = xmlRecords.map(record => this.mapToXMLRecord(record));
// For minimal mode, return simplified structure
if (responseMode === 'minimal') {
return {
update_set_sys_id: updateSetSysId,
update_set_name: updateSet.name,
total_count: xmlRecordsMapped.length,
records: xmlRecordsMapped, // Simple flat array
// Remove by_type and by_table for minimal mode
};
}
// Group by type
const byType: Record<string, { count: number; records: XMLRecord[] }> = {};
const byTable: Record<string, { count: number; records: XMLRecord[] }> = {};
for (const xml of xmlRecordsMapped) {
// Group by type
if (!byType[xml.type]) {
byType[xml.type] = { count: 0, records: [] };
}
byType[xml.type].count++;
byType[xml.type].records.push(xml);
// Group by table
if (!byTable[xml.table]) {
byTable[xml.table] = { count: 0, records: [] };
}
byTable[xml.table].count++;
byTable[xml.table].records.push(xml);
}
return {
update_set_sys_id: updateSetSysId,
update_set_name: updateSet.name,
total_count: xmlRecordsMapped.length,
by_type: byType,
by_table: byTable,
};
}
/**
* Get recent XML activity across all update sets
*/
async getRecentXML(limit: number = 50, responseMode?: string): Promise<RecentXMLActivity> {
const xmlRecords = await this.queryTableRecords('sys_update_xml', {
sysparm_query: 'sys_created_on>=javascript:gs.daysAgo(7)',
sysparm_fields: 'sys_id,name,table,type,update_set,created_by,created_on,updated_on,target_name,sys_update_name',
sysparm_limit: limit,
sysparm_orderby: 'sys_created_on',
sysparm_display_value: responseMode === 'minimal' ? 'true' : 'all', // Minimal mode only gets display_value
});
// Map records and properly resolve update_set_name
const xmlRecordsMapped = xmlRecords.map(record => {
const mapped = this.mapToXMLRecord(record);
// Properly extract update set name from reference field
let updateSetName = 'Unknown';
if (record.update_set) {
if (typeof record.update_set === 'string') {
updateSetName = record.update_set;
} else if (record.update_set.display_value) {
updateSetName = record.update_set.display_value;
} else if (record.update_set.value) {
updateSetName = record.update_set.value;
}
}
// For minimal mode, clean up duplicate value/display_value pairs and apply hard truncation
const cleanedRecord: any = { ...mapped };
if (responseMode === 'minimal') {
// Remove duplicate value/display_value pairs, keep only display_value
Object.keys(cleanedRecord).forEach(key => {
if (cleanedRecord[key] && typeof cleanedRecord[key] === 'object' && cleanedRecord[key].display_value) {
cleanedRecord[key] = cleanedRecord[key].display_value;
}
});
// Apply hard truncation to long fields for minimal mode
const longFields = ['name', 'target_name', 'sys_update_name'];
longFields.forEach(field => {
if (cleanedRecord[field] && typeof cleanedRecord[field] === 'string' && cleanedRecord[field].length > 50) {
cleanedRecord[field] = cleanedRecord[field].substring(0, 50) + '...[truncated]';
}
});
// Remove redundant update_set field in minimal mode (we have update_set_name)
delete cleanedRecord.update_set;
}
return {
...cleanedRecord,
update_set_name: updateSetName,
is_active_set: (record.update_set?.value || record.update_set) === this.workingUpdateSet?.sys_id,
};
});
// For minimal mode, return only the flat array without grouping
const limitedRecords = xmlRecordsMapped.length > 5 ? xmlRecordsMapped.slice(0, 5) : xmlRecordsMapped;
return {
total_count: xmlRecordsMapped.length,
records: limitedRecords, // Flat array with update_set_name and is_active_set
count: limitedRecords.length,
summary: xmlRecordsMapped.length > 5
? `5 of ${xmlRecordsMapped.length} records`
: undefined,
// Remove by_update_set grouping to eliminate [object Object] issue
};
}
/**
* Compare working set against Default to find strays
*/
async diffAgainstDefault(updateSetSysId: string, responseMode?: string): Promise<UpdateSetDiff> {
const workingSet = await this.getUpdateSetInfo(updateSetSysId);
const defaultSet = await this.getUpdateSetInfo(this.getDefaultUpdateSetId());
// Find XML records that should be in working set but are in Default
const strayRecords = await this.queryTableRecords('sys_update_xml', {
sysparm_query: `update_set=${this.getDefaultUpdateSetId()}^sys_created_on>=javascript:gs.daysAgo(1)`,
sysparm_fields: 'sys_id,name,table,type,update_set,created_by,created_on,updated_on,target_name,sys_update_name',
sysparm_limit: 100,
});
let processedStrayRecords = strayRecords.map(record => this.mapToXMLRecord(record));
// Apply minimal mode transformations to reduce payload size
if (responseMode === 'minimal') {
// Limit to first 5 records and truncate long fields
processedStrayRecords = processedStrayRecords.slice(0, 5).map(record => {
const truncated: any = { ...record };
// Truncate long fields
const longFields = ['name', 'target_name', 'sys_update_name'];
longFields.forEach(field => {
if (truncated[field] && typeof truncated[field] === 'string' && truncated[field].length > 50) {
truncated[field] = truncated[field].substring(0, 50) + '...[truncated]';
}
});
// Remove redundant fields in minimal mode
delete truncated.update_set;
delete truncated.created_by;
delete truncated.updated_on;
return truncated;
});
}
return {
working_set: {
sys_id: workingSet.sys_id,
name: workingSet.name,
count: workingSet.xml_count || 0,
},
default_set: {
sys_id: defaultSet.sys_id,
name: defaultSet.name,
count: defaultSet.xml_count || 0,
},
stray_records: processedStrayRecords,
stray_count: strayRecords.length,
message: strayRecords.length > 0 ? `${strayRecords.length} recent records found in Default that may belong to working set` : 'No stray records found',
// Add summary for minimal mode when records are truncated
...(responseMode === 'minimal' && strayRecords.length > 5 && {
summary: `Showing 5 of ${strayRecords.length} stray records. Use full mode to see all records.`
})
};
}
/**
* Get XML summary for an update set
*/
async getXMLSummary(updateSetSysId: string): Promise<XMLSummary> {
const contents = await this.getUpdateSetContents(updateSetSysId);
const updateSet = await this.getUpdateSetInfo(updateSetSysId);
// Aggregate counts
const byType: Record<string, number> = {};
const byTable: Record<string, number> = {};
const byCreatedBy: Record<string, number> = {};
let lastCreated = '';
let lastUpdated = '';
let activityCount = 0;
if (contents.by_type) {
for (const [type, xml] of Object.entries(contents.by_type)) {
for (const record of xml.records) {
// Count by type
byType[record.type] = (byType[record.type] || 0) + 1;
// Count by table
byTable[record.table] = (byTable[record.table] || 0) + 1;
// Count by created by
byCreatedBy[record.created_by] = (byCreatedBy[record.created_by] || 0) + 1;
// Track activity
if (!lastCreated || record.created_on > lastCreated) {
lastCreated = record.created_on;
}
if (!lastUpdated || record.updated_on > lastUpdated) {
lastUpdated = record.updated_on;
}
activityCount++;
}
}
}
return {
update_set_sys_id: updateSetSysId,
update_set_name: updateSet.name,
total_count: contents.total_count,
by_type: byType,
by_table: byTable,
by_created_by: byCreatedBy,
recent_activity: {
last_created: lastCreated,
last_updated: lastUpdated,
activity_count: activityCount,
},
};
}
// ============================================================================
// REHOMING OPERATIONS
// ============================================================================
/**
* Rehome XML records by sys_ids
*/
async rehomeXMLByIds(
xmlSysIds: string[],
targetUpdateSetSysId: string,
force: boolean = false
): Promise<XMLReassignmentResult> {
return await this.reassignXML(xmlSysIds, targetUpdateSetSysId, force);
}
/**
* Rehome XML records by query
*/
async rehomeXMLByQuery(
query: string,
targetUpdateSetSysId: string,
force: boolean = false
): Promise<XMLReassignmentResult> {
// First find XML records matching the query
const xmlRecords = await this.queryTableRecords('sys_update_xml', {
sysparm_query: query,
sysparm_fields: 'sys_id',
sysparm_limit: 1000,
});
const xmlSysIds = xmlRecords.map(record => record.sys_id);
if (xmlSysIds.length === 0) {
return {
success: true,
reassigned_count: 0,
failed_count: 0,
results: [],
message: 'No XML records found matching the query',
};
}
return await this.reassignXML(xmlSysIds, targetUpdateSetSysId, force);
}
// ============================================================================
// HELPER METHODS
// ============================================================================
/**
* Execute background script using the existing background script client
*/
private async executeBackgroundScript(script: string, scope: string): Promise<any> {
// Import and use the existing background script client
const { ServiceNowBackgroundScriptClient } = await import('./client.js');
const backgroundScriptClient = new ServiceNowBackgroundScriptClient();
const result = await backgroundScriptClient.executeScript({
script,
scope,
});
return result;
}
/**
* Get table record by sys_id
*/
private async getTableRecord(table: string, sysId: string): Promise<Record<string, any>> {
const url = `${this.baseUrl}/api/now/table/${table}/${sysId}`;
const headers = {
'Authorization': `Basic ${Buffer.from(`${this.username}:${this.password}`).toString('base64')}`,
'Accept': 'application/json',
};
const response = await fetch(url, { headers });
if (response.status === 404) {
throw new ServiceNowUpdateSetError(
UPDATE_SET_ERROR_CODES.UPDATE_SET_NOT_FOUND,
404,
`Record not found: ${table}/${sysId}`
);
}
if (!response.ok) {
throw new ServiceNowUpdateSetError(
UPDATE_SET_ERROR_CODES.HTTP_ERROR,
response.status,
`Failed to get record: ${response.statusText}`
);
}
const data = await response.json();
return data.result;
}
/**
* Create table record
*/
private async createTableRecord(table: string, data: Record<string, any>): Promise<Record<string, any>> {
const url = `${this.baseUrl}/api/now/table/${table}`;
const headers = {
'Authorization': `Basic ${Buffer.from(`${this.username}:${this.password}`).toString('base64')}`,
'Content-Type': 'application/json',
};
const response = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(data),
});
if (!response.ok) {
throw new ServiceNowUpdateSetError(
UPDATE_SET_ERROR_CODES.HTTP_ERROR,
response.status,
`Failed to create record: ${response.statusText}`
);
}
const result = await response.json();
return result.result;
}
/**
* Update table record
*/
private async updateTableRecord(table: string, sysId: string, data: Record<string, any>): Promise<Record<string, any>> {
const url = `${this.baseUrl}/api/now/table/${table}/${sysId}`;
const headers = {
'Authorization': `Basic ${Buffer.from(`${this.username}:${this.password}`).toString('base64')}`,
'Content-Type': 'application/json',
};
const response = await fetch(url, {
method: 'PUT',
headers,
body: JSON.stringify(data),
});
if (!response.ok) {
throw new ServiceNowUpdateSetError(
UPDATE_SET_ERROR_CODES.HTTP_ERROR,
response.status,
`Failed to update record: ${response.statusText}`
);
}
const result = await response.json();
return result.result;
}
/**
* Query table records
*/
private async queryTableRecords(table: string, params: Record<string, any> = {}): Promise<Record<string, any>[]> {
const url = new URL(`${this.baseUrl}/api/now/table/${table}`);
// Add query parameters
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
url.searchParams.set(key, String(value));
}
});
const headers = {
'Authorization': `Basic ${Buffer.from(`${this.username}:${this.password}`).toString('base64')}`,
'Accept': 'application/json',
};
const response = await fetch(url.toString(), { headers });
if (!response.ok) {
throw new ServiceNowUpdateSetError(
UPDATE_SET_ERROR_CODES.HTTP_ERROR,
response.status,
`Failed to query records: ${response.statusText}`
);
}
const data = await response.json();
return data.result || [];
}
/**
* Get default update set ID (hardcoded for now)
*/
private getDefaultUpdateSetId(): string {
return this.clientConfig.defaultUpdateSetId || '2836addc83f0b6100bb093a6feaad35d'; // Default update set
}
/**
* Get the Default update set record
*/
private async getDefaultUpdateSet(): Promise<UpdateSetRecord> {
const defaultSetId = this.getDefaultUpdateSetId();
return await this.getUpdateSetInfo(defaultSetId);
}
/**
* Map table record to UpdateSetRecord
*/
private mapToUpdateSetRecord(record: Record<string, any>): UpdateSetRecord {
return {
sys_id: record.sys_id,
name: record.name,
description: record.description,
scope: record.scope,
state: record.state,
created_by: record.created_by,
created_on: record.created_on,
updated_on: record.updated_on,
xml_count: record.xml_count,
};
}
/**
* Map table record to XMLRecord
*/
private mapToXMLRecord(record: Record<string, any>): XMLRecord {
return {
sys_id: record.sys_id,
name: record.name,
table: record.table,
type: record.type,
update_set: record.update_set,
update_set_name: record.update_set_name,
created_by: record.created_by,
created_on: record.created_on,
updated_on: record.updated_on,
target_name: record.target_name,
sys_update_name: record.sys_update_name,
};
}
/**
* Validate update set operation request
*/
private validateUpdateSetRequest(request: UpdateSetOperationRequest): void {
if (!request.operation) {
throw new ServiceNowUpdateSetError(
UPDATE_SET_ERROR_CODES.MISSING_PARAMETER,
undefined,
'operation is required'
);
}
const validOperations = [
'create', 'set_working', 'show_working', 'clear_working',
'insert', 'update', 'rehome', 'contents', 'recent',
'list', 'info', 'complete', 'reopen', 'delete', 'diff_default'
];
if (!validOperations.includes(request.operation)) {
throw new ServiceNowUpdateSetError(
UPDATE_SET_ERROR_CODES.INVALID_OPERATION,
undefined,
`Invalid operation: ${request.operation}. Valid operations: ${validOperations.join(', ')}`
);
}
}
/**
* Get client configuration
*/
getConfig(): Required<UpdateSetClientConfig> {
return { ...this.clientConfig };
}
/**
* Update client configuration
*/
updateConfig(newConfig: Partial<UpdateSetClientConfig>): void {
this.clientConfig = { ...this.clientConfig, ...newConfig };
}
}