Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
297 changes: 295 additions & 2 deletions src/sn/flow/FlowManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,38 @@ import {
ProcessFlowApiResponse,
ProcessFlowTestPayload,
CopyFlowOptions,
FlowCopyResult
FlowCopyResult,
FlowContextDetailsResult,
FlowContextInfo,
FlowExecutionReport,
FlowReportAvailabilityDetails,
FlowContextOperationsResponse,
FlowLogOptions,
FlowLogResult,
FlowLogEntry,
FlowOperationsCore,
FlowOperationsData,
FlowActionReport,
FlowExecutionSource
} from './FlowModels';
import { TableAPIRequest } from '../../comm/http/TableAPIRequest';
import { ProcessFlowRequest } from '../../comm/http/ProcessFlowRequest';

const RESULT_MARKER = '___FLOW_EXEC_RESULT___';
const VALID_TYPES: FlowObjectType[] = ['flow', 'subflow', 'action'];

/** Shape of a sys_flow_log record as returned by the Table API. */
interface FlowLogRecord {
sys_id: string;
level: string;
message: string;
action: string;
operation: string;
order: string;
sys_created_on: string;
sys_created_by: string;
}

/**
* Provides operations for executing ServiceNow Flow Designer flows,
* subflows, and actions remotely via BackgroundScriptExecutor.
Expand Down Expand Up @@ -645,6 +669,272 @@ export class FlowManager {
}
}

// ================================================================
// Flow Context Details API (ProcessFlow Operations)
// ================================================================

/**
* Get detailed flow execution context via the ProcessFlow operations API.
*
* Returns rich execution data including per-action timing, inputs, outputs,
* execution metadata (who ran it, test vs production, runtime, etc.), and
* optionally the full flow definition snapshot.
*
* This uses `GET /api/now/processflow/operations/flow/context/{id}` which is
* the same endpoint Flow Designer uses to display execution details.
*
* Note: The execution report (`flowReport`) requires operations view / flow
* logging to be enabled. If not available, `flowReportAvailabilityDetails`
* will contain information about why.
*
* @param contextId The sys_id of the flow context (from sys_flow_context)
* @param scope Optional scope sys_id for the transaction scope query parameter
* @param includeFlowDefinition Whether to include the full flow definition snapshot (default: false)
* @returns FlowContextDetailsResult with execution context and report
*/
public async getFlowContextDetails(
contextId: string,
scope?: string,
includeFlowDefinition: boolean = false
): Promise<FlowContextDetailsResult> {
this._validateContextId(contextId);
this._logger.info(`Getting flow context details: ${contextId}`);

const pfr = new ProcessFlowRequest(this._instance);
const query = scope ? { sysparm_transaction_scope: scope } : undefined;

try {
const response = await pfr.get<FlowContextOperationsResponse>(
'operations/flow/context/{context_id}',
{ context_id: contextId },
query
);

const result = (response as any)?.data?.result ?? (response as any)?.bodyObject?.result;
if (!result) {
return {
success: false,
contextId,
errorMessage: 'Unexpected response structure from processflow operations API: no result field',
rawResponse: response
};
}

const flowContext = this._mapFlowContextInfo(result.flowContext);
const flowDef = result.flow as Record<string, unknown> | undefined;
const flowReport = this._mapFlowExecutionReport(result.flowReport, flowDef);
const reportAvailability = result.flowReportAvailabilityDetails as FlowReportAvailabilityDetails | undefined;

this._logger.info(`Flow context details fetched: state=${flowContext?.state ?? 'unknown'}`);

return {
success: true,
contextId,
flowContext,
flowReport,
flowReportAvailabilityDetails: reportAvailability,
flowDefinition: includeFlowDefinition ? result.flow as Record<string, unknown> : undefined,
rawResponse: result
};
} catch (error) {
const err = error as Error;
this._logger.error(`Error fetching flow context details "${contextId}": ${err.message}`);
return {
success: false,
contextId,
errorMessage: `Failed to fetch flow context details: ${err.message}`
};
}
}

// ================================================================
// Flow Logs API (sys_flow_log Table)
// ================================================================

/**
* Retrieve flow execution logs from the sys_flow_log table.
*
* Returns log entries associated with a flow context, including error messages,
* step-level logs, and cancellation reasons. Log entries may be empty for
* simple successful executions.
*
* @param contextId The sys_id of the flow context to get logs for
* @param options Optional query options (limit, order direction)
* @returns FlowLogResult with array of log entries
*/
public async getFlowLogs(
contextId: string,
options?: FlowLogOptions
): Promise<FlowLogResult> {
this._validateContextId(contextId);

if (options?.limit !== undefined && (options.limit < 1 || !Number.isInteger(options.limit))) {
throw new Error('Limit must be a positive integer');
}

this._logger.info(`Getting flow logs for context: ${contextId}`);

const limit = options?.limit ?? 100;
const orderDir = options?.orderDirection ?? 'asc';
const orderBy = orderDir === 'desc' ? 'ORDERBYDESCorder' : 'ORDERBYorder';

const tableRequest = new TableAPIRequest(this._instance);

try {
const response = await tableRequest.get<{ result: FlowLogRecord[] }>('sys_flow_log', {
sysparm_query: `context=${contextId}^${orderBy}`,
sysparm_fields: 'sys_id,level,message,action,operation,order,sys_created_on,sys_created_by',
sysparm_limit: String(limit)
});

const records: FlowLogRecord[] = (response as any)?.data?.result ?? (response as any)?.bodyObject?.result ?? [];

const entries: FlowLogEntry[] = records.map((r) => ({
sysId: r.sys_id,
level: r.level,
message: r.message,
action: r.action,
operation: r.operation,
order: r.order,
createdOn: r.sys_created_on,
createdBy: r.sys_created_by
}));

this._logger.info(`Flow logs fetched: ${entries.length} entries`);

return {
success: true,
contextId,
entries,
rawResponse: records
};
} catch (error) {
const err = error as Error;
this._logger.error(`Error fetching flow logs for "${contextId}": ${err.message}`);
return {
success: false,
contextId,
entries: [],
errorMessage: `Failed to fetch flow logs: ${err.message}`
};
}
}

/**
* Map the raw flowContext object from the operations API to a typed FlowContextInfo.
* @internal
*/
private _mapFlowContextInfo(raw: Record<string, unknown> | undefined): FlowContextInfo | undefined {
if (!raw) return undefined;

const execSource = raw.executionSource as Record<string, string> | undefined;

return {
flowId: String(raw.flowId ?? ''),
name: String(raw.name ?? ''),
state: String(raw.state ?? '') as FlowContextInfo['state'],
runTime: String(raw.runTime ?? ''),
isTestRun: raw.isTestRun === true,
executedAs: String(raw.executedAs ?? ''),
flowInitiatedBy: String(raw.flowInitiatedBy ?? ''),
reporting: String(raw.reporting ?? ''),
debugMode: raw.debugMode === true,
executionSource: {
callingSource: execSource?.callingSource ?? '',
executionSourceTable: execSource?.executionSourceTable ?? '',
executionSourceRecord: execSource?.executionSourceRecord ?? '',
executionSourceRecordDisplay: execSource?.executionSourceRecordDisplay ?? ''
},
enableOpsViewExpansion: raw.enableOpsViewExpansion === true,
flowRetentionPolicyCandidate: raw.flowRetentionPolicyCandidate === true
};
}

/**
* Map the raw flowReport object from the operations API to a typed FlowExecutionReport.
* Cross-references with the flow definition to resolve human-readable step names.
* @internal
*/
private _mapFlowExecutionReport(
raw: Record<string, unknown> | undefined,
flowDef?: Record<string, unknown>
): FlowExecutionReport | undefined {
if (!raw) return undefined;

// Build a lookup from action instance ID → { actionTypeName, comment }
const actionLookup = this._buildActionInstanceLookup(flowDef);

const mapActionReports = (reports: Record<string, Record<string, unknown>> | undefined): Record<string, FlowActionReport> => {
if (!reports) return {};
const result: Record<string, FlowActionReport> = {};
for (const [key, report] of Object.entries(reports)) {
const lookup = actionLookup.get(key);
const actionTypeName = lookup?.actionTypeName;
const stepComment = lookup?.comment;
const stepLabel = actionTypeName
? (stepComment ? `${actionTypeName} (${stepComment})` : actionTypeName)
: (stepComment || undefined);

result[key] = {
fStepCount: String(report.fStepCount ?? ''),
actionName: String(report.actionName ?? ''),
instanceReference: String(report.instanceReference ?? ''),
stepLabel,
actionTypeName,
stepComment,
operationsCore: (report.operationsCore ?? { error: '', state: '', startTime: '', order: '', runTime: '' }) as FlowOperationsCore,
relatedLinks: (report.relatedLinks ?? {}) as Record<string, string>,
operationsOutput: (report.operationsOutput ?? { data: {} }) as FlowOperationsData,
operationsInput: (report.operationsInput ?? { data: {} }) as FlowOperationsData,
reportId: String(report.reportId ?? '')
};
}
return result;
};

return {
flowId: String(raw.flowId ?? ''),
domainSeparationEnabled: raw.domainSeparationEnabled === true,
executionDomain: String(raw.executionDomain ?? ''),
actionOperationsReports: mapActionReports(raw.actionOperationsReports as Record<string, Record<string, unknown>>),
subflowOperationsReports: mapActionReports(raw.subflowOperationsReports as Record<string, Record<string, unknown>>),
iterationOperationsReports: (raw.iterationOperationsReports ?? {}) as Record<string, unknown>,
instanceReference: String(raw.instanceReference ?? ''),
operationsCore: (raw.operationsCore ?? { error: '', state: '', startTime: '', order: '', runTime: '' }) as FlowOperationsCore,
relatedLinks: (raw.relatedLinks ?? {}) as Record<string, string>,
operationsOutput: (raw.operationsOutput ?? { data: {} }) as FlowOperationsData,
operationsInput: (raw.operationsInput ?? { data: {} }) as FlowOperationsData,
reportId: String(raw.reportId ?? '')
};
}

/**
* Build a lookup map from action instance ID to human-readable names
* by scanning the flow definition's actionInstances array.
* @internal
*/
private _buildActionInstanceLookup(flowDef?: Record<string, unknown>): Map<string, { actionTypeName?: string; comment?: string }> {
const lookup = new Map<string, { actionTypeName?: string; comment?: string }>();
if (!flowDef) return lookup;

const actionInstances = flowDef.actionInstances as Array<Record<string, unknown>> | undefined;
if (!Array.isArray(actionInstances)) return lookup;

for (const instance of actionInstances) {
const id = instance.id as string;
if (!id) continue;

const actionType = instance.actionType as Record<string, unknown> | undefined;
const actionTypeName = (actionType?.displayName ?? actionType?.name) as string | undefined
|| undefined;
const comment = (instance.comment as string | undefined) || undefined;

lookup.set(id, { actionTypeName, comment });
}

return lookup;
}

/**
* Extract the result from a ProcessFlow API response, handling both
* Axios-style (data.result) and RequestHandler-style (bodyObject.result) response shapes.
Expand Down Expand Up @@ -838,11 +1128,14 @@ export class FlowManager {
// Lifecycle Script Builders
// ================================================================

/** Validate that a context ID is non-empty. */
/** Validate that a context ID is a non-empty 32-character hex sys_id. */
private _validateContextId(contextId: string): void {
if (!contextId || contextId.trim().length === 0) {
throw new Error('Context ID is required');
}
if (!/^[0-9a-f]{32}$/i.test(contextId.trim())) {
throw new Error(`Invalid context ID format: "${contextId}". Expected a 32-character hex sys_id.`);
}
}

/** Escape a string for embedding in a generated SN script single-quoted string. */
Expand Down
Loading