@jacebenson/jsn 4.3.7-dev.17 → 4.3.7-dev.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -93,6 +93,87 @@ jsn snippets run open-inc
93
93
  jsn logs follow --level error --tail 10
94
94
  ```
95
95
 
96
+ ### Flow Designer actions
97
+
98
+ `actions` and `action` are the same command. `list`, `show`, and explicit
99
+ `delete` remain available. Create and edit use full Process Flow JSON, not
100
+ ordinary table-field updates.
101
+
102
+ ```bash
103
+ jsn actions step-types --scope <scope_sys_id> --get data > step-types.json
104
+ jsn actions create --scope <scope_sys_id> --data-file new-action.json --json
105
+ jsn actions definition <action_sys_id> --get data > action.json
106
+ # Edit action.json, retaining its ID, scope, lifecycle fields and all arrays.
107
+ jsn actions update <action_sys_id> --data-file action.json --json
108
+ jsn action edit <action_sys_id> --data-file action.json --json # same operation
109
+ jsn actions test <action_sys_id> --force --output-map '{"response":"hello"}' --wait --timeout 60 --json
110
+ jsn actions test <action_sys_id> --force --data-file action.json --output-map '{"response":"hello"}' --wait --get data.outputs.response.value
111
+ jsn actions delete <action_sys_id> --force # explicit cleanup, never automatic
112
+ ```
113
+
114
+ Existing targets infer their exact transaction scope from the action record.
115
+ An explicit `--scope` must match that record byte-for-byte and exist in
116
+ `sys_scope`. For Global, use the literal sys_id `global`.
117
+
118
+ This **illustrative create document** shows an input/script/output mapping.
119
+ Replace `<SCRIPT_STEP_TYPE_SYS_ID>` with the target's `step-types` result.
120
+ Start from that step type's full input/output schemas, preserving runtime
121
+ settings, status outputs and any other required fields. The shortened example
122
+ is not a captured or instance-verified template. New steps/variables need their
123
+ own identifiers; do not clone another action's record IDs. Leave new steps'
124
+ `step_id` and `action` blank or omitted. Create binds their `action` to the newly
125
+ allocated parent; persisted step IDs or parent bindings are rejected before creation.
126
+
127
+ ```json
128
+ {
129
+ "name": "Echo response",
130
+ "description": "Return the supplied response",
131
+ "internal_name": "echo_response",
132
+ "inputs": [{"name": "response", "label": "Response", "type": "string", "mandatory": true}],
133
+ "outputs": [{"name": "response", "label": "Response", "type": "string", "value": "{{step[11111111-2222-4333-8444-555555555555].response}}"}],
134
+ "steps": [{
135
+ "cid": "11111111-2222-4333-8444-555555555555",
136
+ "step_type": "SCRIPT",
137
+ "step_type_id": "<SCRIPT_STEP_TYPE_SYS_ID>",
138
+ "label": "Echo script",
139
+ "order": 1,
140
+ "inputs": [
141
+ {"name": "required_run_time", "type": "choice", "value": "instance"},
142
+ {"name": "script", "type": "script", "value": "(function execute(inputs, outputs) { outputs.response = inputs.response; })(inputs, outputs);"}
143
+ ],
144
+ "outputs": [],
145
+ "extended_inputs": [{"name": "response", "type": "string", "value": "{{inputs.response}}", "extended": true}],
146
+ "extended_outputs": [{"name": "response", "type": "string", "extended": true}]
147
+ }]
148
+ }
149
+ ```
150
+
151
+ Create first inserts the parent in `sys_hub_action_type_definition`, reads that
152
+ parent's own Process Flow defaults, then saves the inputs, outputs and nonempty
153
+ steps with the internal `/api/now/processflow/action/action_types/{id}` PUT.
154
+ It retains fresh lifecycle/status metadata instead of copying source snapshot
155
+ or status IDs. A subsequent definition GET plus `/step_instances` GET verifies
156
+ scripts, mappings and schemas. Update requires a complete document from
157
+ `definition`, including target-owned lifecycle fields. Omitted fields are
158
+ rejected before mutation. A PUT timeout triggers readback, not another PUT;
159
+ `persisted_after_timeout` means persistence was verified, not that HTTP success
160
+ was received. Failed creation reports the parent ID and leaves cleanup to you.
161
+
162
+ Test sends `action`, `outputMap`, `runOnThread` and `tracingEnabled` to `/test`.
163
+ Despite its wire name, `--output-map` supplies **action inputs**. Without
164
+ `--wait`, success means dispatch only, with `status: dispatched` and no outputs.
165
+ With `--wait`, JSN requires a `COMPLETE` context, blank `error_message`, and each
166
+ declared user output's actual value from `sys_flow_runtime_value`. Output entries
167
+ have `value`, `displayValue` and `hasValue`; false, zero and empty strings count.
168
+ Failures, denied/missing outputs and timeouts exit nonzero. The wait budget
169
+ starts after dispatch, defaults to 60 seconds, and is capped at 3600 seconds.
170
+ Timeout does not cancel the server execution. Draft actions can be tested without
171
+ publication. Testing runs their scripts and can have side effects. It requires
172
+ interactive confirmation or `--force`, unless the profile explicitly skips
173
+ confirmations. Read-only profiles block create/update/delete/test. No publish
174
+ or snapshot command is added.
175
+ These internal endpoints and runtime-table permissions can vary by instance.
176
+
96
177
  ### Flow execution fields
97
178
 
98
179
  `jsn flows executions` reads `sys_flow_context` and returns both the raw row and a normalized `execution` object. JSN discovers the runtime columns from `sys_dictionary` first, then uses these mappings:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacebenson/jsn",
3
- "version": "4.3.7-dev.17",
3
+ "version": "4.3.7-dev.18",
4
4
  "description": "A command-line interface for ServiceNow that follows the Unix philosophy: simple, composable, and scriptable.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -45,7 +45,7 @@ async function fetchExtensionChain(sdk, record) {
45
45
  return chain;
46
46
  }
47
47
 
48
- export const actionsCmd = (wrap) => buildDevCmd('actions', 'sys_hub_action_type_definition', ['action'], ['name', 'active', 'sys_scope', 'sys_updated_on'], wrap, { singular: 'action', scopeValidation: true });
48
+ export { actionsCmd } from './actions.js';
49
49
 
50
50
  export const includesCmd = (wrap) => buildDevCmd('includes', 'sys_script_include', ['include', 'si'], ['name', 'api_name', 'active', 'sys_scope'], wrap, { singular: 'script include', scopeValidation: true });
51
51
 
@@ -0,0 +1,122 @@
1
+ import { buildDevCmd } from './_generic.js';
2
+ import { parseDataArg, confirmDelete } from '../helpers.js';
3
+ import { resolveRecord, unwrapSysId } from '../resolve-record.js';
4
+ import { declareCapabilities } from '../capabilities.js';
5
+ import { ACTION_TABLE, actionScope, validateScope } from '../processflow-actions.js';
6
+ import { errUsage } from '../errors.js';
7
+
8
+ const scopeOption = y => y.option('scope', { type: 'string', describe: 'Exact transaction scope sys_id; inferred from existing action when omitted' });
9
+ const dataOptions = y => y
10
+ .option('data-file', { type: 'string', describe: 'Full action definition JSON file, not table fields' })
11
+ .option('data', { type: 'string', describe: 'Full action definition as JSON' });
12
+ async function actionID(argv, app) {
13
+ return unwrapSysId(await resolveRecord(app.sdk, { table: ACTION_TABLE, identifier: argv.identifier, matchField: 'name', resource: 'action' }));
14
+ }
15
+
16
+ export const actionsCmd = wrap => {
17
+ // Reuse the existing list/show picker and output contract only.
18
+ const base = buildDevCmd('actions', ACTION_TABLE, ['action'], ['name', 'active', 'sys_scope', 'sys_updated_on'], wrap, {
19
+ singular: 'action', readOnly: true,
20
+ showBreadcrumbs: (_record, id) => [
21
+ { action: 'definition', cmd: `jsn actions definition ${id}`, description: 'Read complete executable definition' },
22
+ { action: 'list', cmd: 'jsn actions list', description: 'Back to actions' },
23
+ ],
24
+ });
25
+ declareCapabilities('actions', { mutationSubcommands: ['create', 'update', 'edit', 'delete', 'test'] });
26
+ // Retain the common wrap and output controller; domain failures get a
27
+ // structured error envelope and nonzero exit, never a success-shaped dispatch.
28
+ const run = handler => wrap(async (argv, app) => {
29
+ try { await handler(argv, app); }
30
+ catch (error) {
31
+ if (!error.code?.startsWith('action_')) throw error;
32
+ if (error.details) error.hint = JSON.stringify(error.details);
33
+ app.err(error);
34
+ process.exitCode = 1;
35
+ }
36
+ });
37
+ return {
38
+ ...base,
39
+ describe: 'Flow Designer actions: create, edit full definitions, and test with outputs',
40
+ builder: y => base.builder(y)
41
+ .command({
42
+ command: 'create', describe: 'Create parent and executable Process Flow action; verify persistence',
43
+ builder: yy => dataOptions(yy).option('scope', { type: 'string', demandOption: true, describe: 'Exact target scope sys_id, or global' }),
44
+ handler: run(async (argv, app) => {
45
+ const definition = parseDataArg(argv);
46
+ const result = await app.sdk.createProcessFlowAction(argv.scope, definition);
47
+ app.ok(result, { summary: `Created and verified action ${result.sys_id}` });
48
+ }),
49
+ })
50
+ .command({
51
+ command: 'definition <identifier>', describe: 'Read full JSON definition including executable step_instances',
52
+ builder: scopeOption,
53
+ handler: run(async (argv, app) => {
54
+ const id = await actionID(argv, app);
55
+ const scope = await actionScope(app.sdk, id, argv.scope);
56
+ app.ok(await app.sdk.getProcessFlowAction(id, scope), { summary: `Read action definition ${id}` });
57
+ }),
58
+ })
59
+ .command({
60
+ command: 'update <identifier>', aliases: ['edit'], describe: 'Save a full definition through Process Flow PUT and verify readback',
61
+ builder: yy => dataOptions(scopeOption(yy)),
62
+ handler: run(async (argv, app) => {
63
+ const definition = parseDataArg(argv);
64
+ const id = await actionID(argv, app);
65
+ const result = await app.sdk.updateProcessFlowAction(id, argv.scope, definition);
66
+ app.ok(result, { summary: `Saved and verified action ${id}` });
67
+ }),
68
+ })
69
+ .command({
70
+ command: 'test <identifier>', describe: 'Execute saved or supplied definition; optionally wait for actual outputs',
71
+ builder: yy => dataOptions(scopeOption(yy))
72
+ .option('force', { type: 'boolean', default: false, describe: 'Confirm execution of action scripts, which may have side effects' })
73
+ .option('output-map', { type: 'string', default: '{}', describe: 'JSON action input names to values; sent as outputMap' })
74
+ .option('run-on-thread', { type: 'boolean', default: true, describe: 'Run test on a worker thread' })
75
+ .option('tracing-enabled', { type: 'boolean', default: false, describe: 'Enable Flow Designer tracing' })
76
+ .option('wait', { type: 'boolean', default: false, describe: 'Wait for COMPLETE context and actual runtime outputs' })
77
+ .option('timeout', { type: 'number', default: 60, describe: 'Maximum wait seconds after dispatch, greater than 0, at most 3600' }),
78
+ handler: run(async (argv, app) => {
79
+ let outputMap;
80
+ try { outputMap = JSON.parse(argv['output-map'] ?? '{}'); }
81
+ catch { throw errUsage('--output-map must be valid JSON'); }
82
+ const definition = argv['data-file'] || argv.data ? parseDataArg(argv) : undefined;
83
+ const id = await actionID(argv, app);
84
+ await confirmDelete(app, argv, `Execute action '${argv.identifier}' scripts, which may have side effects`);
85
+ const result = await app.sdk.testProcessFlowAction(id, argv.scope, definition, outputMap, {
86
+ wait: argv.wait, timeout: argv.timeout, runOnThread: argv['run-on-thread'], tracingEnabled: argv['tracing-enabled'],
87
+ });
88
+ app.ok(result, { summary: result.status === 'complete' ? `Test completed ${result.context}` : `Test dispatched ${result.context}; outputs not yet verified` });
89
+ }),
90
+ })
91
+ .command({
92
+ command: 'step-types', describe: 'Read step schema templates for a target scope',
93
+ builder: yy => yy.option('scope', { type: 'string', demandOption: true, describe: 'Exact transaction scope sys_id, or global' }),
94
+ handler: run(async (argv, app) => {
95
+ await validateScope(app.sdk, argv.scope);
96
+ app.ok(await app.sdk.getProcessFlowStepTypes(argv.scope), { summary: 'Process Flow step schemas' });
97
+ }),
98
+ })
99
+ .command({
100
+ command: 'delete <identifier>', describe: 'Delete action parent explicitly',
101
+ builder: yy => scopeOption(yy).option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' }),
102
+ handler: run(async (argv, app) => {
103
+ const id = await actionID(argv, app);
104
+ await actionScope(app.sdk, id, argv.scope);
105
+ await confirmDelete(app, argv, `Delete action '${argv.identifier}'`);
106
+ await app.sdk.delete(ACTION_TABLE, id);
107
+ // Verify the exact target is absent. Do not interpret a 403 as deletion.
108
+ const query = new URLSearchParams({ sysparm_query: `sys_id=${id}`, sysparm_limit: '1', sysparm_fields: 'sys_id', sysparm_display_value: 'false' });
109
+ const response = await app.sdk.request(`${app.sdk.baseURL}/api/now/table/${ACTION_TABLE}?${query}`, { method: 'GET' });
110
+ if (!response || response.error || !Array.isArray(response.result) || response.result.length) {
111
+ throw new Error(`Delete is unverified; action ${id} absence was not explicitly confirmed`);
112
+ }
113
+ app.ok({ name: argv.identifier, sys_id: id, deleted: true }, { summary: `Deleted action ${id}` });
114
+ }),
115
+ }),
116
+ handler: wrap(async (_argv, app) => {
117
+ app.ok({ commands: ['list', 'show', 'definition', 'step-types', 'create', 'update', 'test', 'delete'] }, {
118
+ summary: 'Use jsn actions <command> --help. Create/update require full JSON definitions, not table fields.',
119
+ });
120
+ }),
121
+ };
122
+ };
@@ -0,0 +1,366 @@
1
+ // Resource-specific Flow Designer contract. No Table API definition updates.
2
+ import { AppError, errUsage } from './errors.js';
3
+
4
+ export const ACTION_TABLE = 'sys_hub_action_type_definition';
5
+ const ROOT = '/api/now/processflow/action';
6
+ const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
7
+ const raw = value => object(value) ? value.value : value;
8
+ const successCode = value => value === 0 || value === '0';
9
+ function token(value, label, global = false) {
10
+ if (typeof value !== 'string' || (!/^[a-fA-F0-9]{32}$/.test(value) && !(global && value === 'global'))) {
11
+ throw errUsage(`${label} must be an exact sys_id${global ? ' or global' : ''}`);
12
+ }
13
+ return value;
14
+ }
15
+ function failure(code, message, details = {}) {
16
+ const error = new AppError(code, message);
17
+ error.details = details;
18
+ return error;
19
+ }
20
+ async function request(sdk, id, scope, suffix = '', method = 'GET', body, opts = {}) {
21
+ if (id !== null) token(id, 'Action ID');
22
+ token(scope, 'Transaction scope', true);
23
+ const resource = id === null ? '/step_types' : `/action_types/${encodeURIComponent(id)}${suffix}`;
24
+ const query = new URLSearchParams({ sysparm_transaction_scope: scope });
25
+ const response = await sdk.request(`${sdk.baseURL}${ROOT}${resource}?${query}`, {
26
+ ...opts, method, ...(body === undefined ? {} : { body: JSON.stringify(body) }),
27
+ });
28
+ const result = response?.result;
29
+ if (response?.error || result?.error || (result?.errorCode !== undefined && !successCode(result.errorCode))) {
30
+ throw failure('action_api_error', result?.errorMessage || response?.error?.message || 'Process Flow request failed');
31
+ }
32
+ if (result === undefined || result === null) throw failure('action_api_error', 'Process Flow returned no result');
33
+ return result;
34
+ }
35
+ async function metadata(sdk, id, scope, opts) {
36
+ const result = await request(sdk, id, scope, '', 'GET', undefined, opts);
37
+ if (!object(result) || result.id !== id || result.scope !== scope) {
38
+ throw failure('action_identity_mismatch', 'Process Flow definition ID/scope does not match the target', { sys_id: id, scope });
39
+ }
40
+ return result;
41
+ }
42
+ export async function getAction(sdk, id, scope, opts = {}) {
43
+ const definition = await metadata(sdk, id, scope, opts);
44
+ const result = await request(sdk, id, scope, '/step_instances', 'GET', undefined, opts);
45
+ const steps = Array.isArray(result) ? result : result.steps;
46
+ if (!Array.isArray(steps)) throw failure('action_definition_incomplete', 'step_instances returned no steps array');
47
+ return { ...definition, steps };
48
+ }
49
+ export function getStepTypes(sdk, scope) {
50
+ return request(sdk, null, scope);
51
+ }
52
+ export async function validateScope(sdk, scope) {
53
+ token(scope, 'Transaction scope', true);
54
+ const record = await sdk.get('sys_scope', scope);
55
+ if (!record || raw(record.sys_id) !== scope) throw errUsage(`Scope does not exist or is not readable: ${scope}`);
56
+ return scope;
57
+ }
58
+ export async function actionScope(sdk, id, providedScope) {
59
+ token(id, 'Action ID');
60
+ if (providedScope !== undefined) token(providedScope, 'Transaction scope', true);
61
+ const record = await sdk.get(ACTION_TABLE, id);
62
+ if (!record || raw(record.sys_id) !== id) throw errUsage(`Action does not exist or is not readable: ${id}`);
63
+ const scope = raw(record.sys_scope);
64
+ token(scope, 'Action record scope', true);
65
+ if (providedScope !== undefined && providedScope !== scope) {
66
+ throw errUsage(`--scope ${providedScope} does not match action scope ${scope}`);
67
+ }
68
+ await validateScope(sdk, scope);
69
+ return scope;
70
+ }
71
+ function validateVariables(list, label) {
72
+ if (list.some(v => !object(v) || typeof v.name !== 'string' || !v.name || typeof v.type !== 'string' || !v.type)) {
73
+ throw errUsage(`${label} requires named, typed variable objects`);
74
+ }
75
+ if (new Set(list.map(v => v.name)).size !== list.length) throw errUsage(`${label} has duplicate variable names`);
76
+ }
77
+ export function validateDefinition(definition, id, scope) {
78
+ if (!object(definition)) throw errUsage('Expected a full action definition JSON object');
79
+ for (const key of ['inputs', 'outputs', 'steps']) {
80
+ if (!Array.isArray(definition[key])) throw errUsage(`Full definition requires an explicit ${key} array`);
81
+ }
82
+ validateVariables(definition.inputs, 'Action inputs');
83
+ validateVariables(definition.outputs, 'Action outputs');
84
+ if (!definition.steps.length) throw errUsage('Full definition requires at least one executable step');
85
+ if (id !== undefined && definition.id !== id) throw errUsage('Definition ID must match the target action ID');
86
+ if (scope !== undefined && definition.scope !== undefined && definition.scope !== scope) {
87
+ throw errUsage('Definition scope must match the target transaction scope');
88
+ }
89
+ for (const step of definition.steps) {
90
+ if (!object(step) || typeof step.cid !== 'string' || !step.cid || !step.step_type) {
91
+ throw errUsage('Each step requires cid and step_type from its Process Flow schema');
92
+ }
93
+ for (const key of ['inputs', 'outputs', 'extended_inputs', 'extended_outputs']) {
94
+ if (!Array.isArray(step[key])) throw errUsage(`Each step requires an explicit ${key} array`);
95
+ validateVariables(step[key], `Step ${key}`);
96
+ }
97
+ if (id !== undefined && step.action && step.action !== id) throw errUsage('Step action ID must match the target');
98
+ }
99
+ if (new Set(definition.steps.map(s => s.cid)).size !== definition.steps.length) throw errUsage('Step CIDs must be unique');
100
+ }
101
+
102
+ // Check executable intent, not server-assigned record/UI IDs or display caches.
103
+ const VARIABLE_FIELDS = ['name', 'type', 'value', 'defaultValue', 'mandatory', 'reference', 'data_structure', 'scriptActive', 'script', 'scriptAsJsonString', 'children', 'complexObjectValue', 'maxsize', 'ref_qual', 'choices', 'defaultChoices', 'choiceOption', 'table', 'dependent_on', 'sys_class_name', 'extended', 'local', 'dynamic'];
104
+ function subset(expected, actual, path, mismatches) {
105
+ if (Array.isArray(expected)) {
106
+ if (!Array.isArray(actual) || expected.length !== actual.length) { mismatches.push(path); return; }
107
+ expected.forEach((v, i) => subset(v, actual[i], `${path}[${i}]`, mismatches));
108
+ } else if (object(expected)) {
109
+ if (!object(actual)) { mismatches.push(path); return; }
110
+ for (const [key, value] of Object.entries(expected)) {
111
+ if (key === 'uiUniqueId' || (typeof expected.name === 'string' && ['id', 'displayValue', 'display_value'].includes(key))) continue;
112
+ subset(value, actual[key], `${path}.${key}`, mismatches);
113
+ }
114
+ } else if (expected !== actual) mismatches.push(path);
115
+ }
116
+ // Only these attribute keys are UI/generated metadata, not schema constraints.
117
+ const UI_ATTRIBUTES = new Set(['uiUniqueId', 'uiTypeLabel', 'uiType', 'element_mapping_provider']);
118
+ function semantic(value) {
119
+ if (Array.isArray(value)) return value.map(semantic);
120
+ if (object(value)) return Object.fromEntries(Object.keys(value).sort().map(k => [k, semantic(value[k])]));
121
+ return value;
122
+ }
123
+ function variableField(variable, field) {
124
+ const value = variable[field];
125
+ if (field === 'children') return (value || []).map(variableIntent);
126
+ if (field === 'mandatory' || field === 'scriptActive') return value ?? false;
127
+ // Process Flow materializes empty defaults. Nonempty mappings/scripts never disappear.
128
+ if (value === undefined || value === null || value === '' || (object(value) && !Object.keys(value).length)) return null;
129
+ return value;
130
+ }
131
+ function variableIntent(variable) {
132
+ return { ...Object.fromEntries(VARIABLE_FIELDS.map(f => [f, variableField(variable, f)])),
133
+ attributes: Object.fromEntries(Object.entries(variable.attributes || {}).filter(([k]) => !UI_ATTRIBUTES.has(k))) };
134
+ }
135
+ function equalIntent(expected, actual) {
136
+ return JSON.stringify(semantic(expected)) === JSON.stringify(semantic(actual));
137
+ }
138
+ function lifecycleDefault(variable, found) {
139
+ const normalized = { ...variable };
140
+ if (!variable.scriptActive && variable.script === null && equalIntent(found.script, {})) normalized.script = {};
141
+ if (variable.name === '__dont_treat_as_error__' && variable.type === 'boolean' && variable.value === false && !variable.scriptActive && variable.maxsize === 0 && found.maxsize === 40) normalized.maxsize = 40;
142
+ if (variable.name !== '__action_status__' || variable.type !== 'object' || variable.value !== '' || variable.complexObjectValue !== null || variable.scriptActive) return normalized;
143
+ // Captured fresh status becomes a serialized EMPTY code/message object on PUT.
144
+ try {
145
+ const value = JSON.parse(found.value);
146
+ const empty = { code: { $cv: { $c: 'java.lang.String', $v: '' } }, message: { $cv: { $c: 'java.lang.String', $v: '' } } };
147
+ const schema = value.complexObjectSchema?.['FlowDesigner:FDACTIONSTATUS'];
148
+ const facetKeys = ['uiTypeLabel', 'read_only', 'hint', 'uiType', 'default_value', 'label', 'action_error_output', 'mandatory', 'order', 'max_length', 'uiUniqueId', 'co_type_name', 'element_mapping_provider'];
149
+ const emptyFacet = (facet, size, type) => {
150
+ if (!object(facet) || !equalIntent(Object.keys(facet), ['SimpleMapFacet'])) return false;
151
+ const fields = JSON.parse(facet.SimpleMapFacet);
152
+ return Object.keys(fields).every(k => facetKeys.includes(k)) && fields.mandatory === 'false' && fields.default_value === '' && fields.max_length === String(size) && fields.uiType === type && fields.action_error_output === 'true';
153
+ };
154
+ const defaultSchema = equalIntent(Object.keys(value.complexObjectSchema || {}).sort(), ['FlowDesigner:FDACTIONSTATUS', 'FlowDesigner:FDACTIONSTATUS.$type_facets'])
155
+ && equalIntent(Object.keys(schema || {}).sort(), ['code', 'code.$field_facets', 'message', 'message.$field_facets'])
156
+ && emptyFacet(schema['code.$field_facets'], 40, 'integer') && emptyFacet(schema['message.$field_facets'], 4000, 'string')
157
+ && emptyFacet(value.complexObjectSchema['FlowDesigner:FDACTIONSTATUS.$type_facets'], 65000, 'object');
158
+ if (equalIntent(Object.keys(value).sort(), ['complexObject', 'complexObjectSchema', 'serializationFormat', 'version']) && value.version === '1.0' && value.serializationFormat === 'JSON' && defaultSchema && schema?.code === 'Integer' && schema?.message === 'String' && equalIntent(value.complexObject, empty)) normalized.value = found.value;
159
+ } catch { /* Non-default values remain mismatches. */ }
160
+ if (normalized.value === found.value && variable.maxsize === 0 && found.maxsize === 65000) normalized.maxsize = 65000;
161
+ const emptyObject = { name: variable.name, value: null, scriptActive: false, script: null, scriptAsJsonString: null, parameter: null,
162
+ children: (variable.children || []).map(child => ({ name: child.name, value: null, scriptActive: false, script: null, scriptAsJsonString: null, children: null, parameter: child })) };
163
+ const statusIntent = node => {
164
+ if (!object(node)) return node;
165
+ return Object.fromEntries(Object.entries(node).filter(([key]) => !['id', 'displayValue', 'displayField'].includes(key)).map(([key, value]) => [key,
166
+ key === 'parameter' && value ? variableIntent(value) : key === 'children' && Array.isArray(value) ? value.map(statusIntent) : value]));
167
+ };
168
+ if (equalIntent(statusIntent(emptyObject), statusIntent(found.complexObjectValue))) normalized.complexObjectValue = found.complexObjectValue;
169
+ return normalized;
170
+ }
171
+ function variables(expected, actual, path, mismatches, inherited = new Set()) {
172
+ if (!Array.isArray(actual) || expected.length !== actual.length) { mismatches.push(`${path}.length`); return; }
173
+ for (const variable of expected) {
174
+ const found = actual.find(v => v.name === variable.name);
175
+ if (!found) { mismatches.push(`${path}.${variable.name}`); continue; }
176
+ const intent = variableIntent(inherited.has(variable.name) ? lifecycleDefault(variable, found) : variable);
177
+ const persisted = variableIntent(found);
178
+ for (const field of [...VARIABLE_FIELDS, 'attributes']) {
179
+ if (!equalIntent(intent[field], persisted[field])) mismatches.push(`${path}.${variable.name}.${field}`);
180
+ }
181
+ }
182
+ }
183
+ function verify(expected, actual, inherited) {
184
+ const mismatches = [];
185
+ for (const key of ['id', 'scope', 'name', 'description', 'internal_name', 'state', 'master_snapshot', 'latest_snapshot']) {
186
+ if (Object.hasOwn(expected, key)) subset(expected[key], actual[key], key, mismatches);
187
+ }
188
+ for (const key of ['sysId', 'actionTypeId']) {
189
+ const before = expected.action_status_metadata?.[key];
190
+ const after = actual.action_status_metadata?.[key];
191
+ // The first create PUT allocates the status row; later updates must retain it.
192
+ const allocated = key === 'sysId' && inherited !== undefined && (before === '' || before === null || before === undefined)
193
+ && typeof after === 'string' && /^[a-fA-F0-9]{32}$/.test(after);
194
+ if (!allocated && (before !== after || (key === 'sysId' && inherited !== undefined && !before))) mismatches.push(`action_status_metadata.${key}`);
195
+ }
196
+ variables(expected.inputs, actual.inputs, 'inputs', mismatches);
197
+ variables(expected.outputs, actual.outputs, 'outputs', mismatches, inherited);
198
+ if (inherited === undefined) {
199
+ for (const output of expected.outputs.filter(o => o.name.startsWith('__'))) {
200
+ if (actual.outputs.find(o => o.name === output.name)?.id !== output.id) mismatches.push(`outputs.${output.name}.id`);
201
+ }
202
+ }
203
+ if (actual.steps.length !== expected.steps.length) mismatches.push('steps.length');
204
+ for (const step of expected.steps) {
205
+ const found = actual.steps.find(s => s.cid === step.cid);
206
+ if (!found) { mismatches.push(`steps.${step.cid}`); continue; }
207
+ for (const key of ['cid', 'action', 'step_type', 'step_type_id', 'order', 'error_handling_type', 'quiescence']) {
208
+ if (!equalIntent(step[key] ?? null, found[key] ?? null)) mismatches.push(`steps.${step.cid}.${key}`);
209
+ }
210
+ for (const key of ['inputs', 'outputs', 'extended_inputs', 'extended_outputs']) variables(step[key], found[key], `steps.${step.cid}.${key}`, mismatches);
211
+ }
212
+ if (mismatches.length) throw failure('action_verification_failed', `Persisted definition differs at: ${mismatches.join(', ')}`, { mismatches });
213
+ }
214
+ async function save(sdk, id, scope, definition, inherited) {
215
+ let writeError;
216
+ let returned;
217
+ try {
218
+ returned = await request(sdk, id, scope, '', 'PUT', definition);
219
+ } catch (error) {
220
+ // A timeout is ambiguous: reconcile once via reads, NEVER retry the PUT.
221
+ if (!/timed?\s*out|timeout/i.test(error.message)) throw error;
222
+ writeError = error;
223
+ }
224
+ const persisted = await getAction(sdk, id, scope);
225
+ verify(definition, persisted, inherited);
226
+ if (returned?.id !== undefined && returned.id !== id) throw failure('action_identity_mismatch', 'PUT returned a different action ID');
227
+ if (returned?.scope !== undefined && returned.scope !== scope) throw failure('action_identity_mismatch', 'PUT returned a different action scope');
228
+ if (Array.isArray(returned?.inputs) && Array.isArray(returned?.outputs) && Array.isArray(returned?.steps)) verify(definition, returned, inherited);
229
+ return { sys_id: id, scope, status: writeError ? 'persisted_after_timeout' : 'verified',
230
+ http_write_confirmed: !writeError, definition: persisted };
231
+ }
232
+ export async function updateAction(sdk, id, providedScope, definition) {
233
+ validateDefinition(definition, id, providedScope);
234
+ const scope = await actionScope(sdk, id, providedScope);
235
+ validateDefinition(definition, id, scope);
236
+ // A full read document is required, so table fields cannot erase lifecycle defaults.
237
+ for (const key of ['scope', 'name', 'state', 'master_snapshot', 'latest_snapshot', 'action_status_metadata']) {
238
+ if (!Object.hasOwn(definition, key)) throw errUsage(`Full update definition requires ${key}; use actions definition first`);
239
+ }
240
+ const current = await getAction(sdk, id, scope);
241
+ const omitted = Object.keys(current).filter(key => !Object.hasOwn(definition, key));
242
+ if (omitted.length) throw errUsage(`Full update definition is missing ${omitted.join(', ')}; use actions definition first`);
243
+ for (const key of ['state', 'master_snapshot', 'latest_snapshot']) {
244
+ if (definition[key] !== current[key]) throw errUsage(`Do not replace target-owned ${key}`);
245
+ }
246
+ for (const key of ['sysId', 'actionTypeId']) {
247
+ if (definition.action_status_metadata?.[key] !== current.action_status_metadata?.[key]) {
248
+ throw errUsage(`Do not replace target-owned action_status_metadata.${key}`);
249
+ }
250
+ }
251
+ const currentReserved = current.outputs.filter(o => o.name.startsWith('__'));
252
+ const suppliedReserved = definition.outputs.filter(o => o.name.startsWith('__'));
253
+ if (currentReserved.length !== suppliedReserved.length || currentReserved.some(o => {
254
+ const supplied = suppliedReserved.find(v => v.name === o.name);
255
+ return !supplied || supplied.id !== o.id;
256
+ })) {
257
+ throw errUsage('Do not replace or remove target-owned reserved output IDs');
258
+ }
259
+ try { return await save(sdk, id, scope, definition); }
260
+ catch (error) {
261
+ throw failure(error.code?.startsWith('action_') ? error.code : 'action_update_failed', `Action ${id} update is unverified: ${error.message}`, {
262
+ ...error.details, sys_id: id, scope, status: 'unverified', cleanup: 'explicit',
263
+ });
264
+ }
265
+ }
266
+ export async function createAction(sdk, scope, source) {
267
+ validateDefinition(source, undefined, scope);
268
+ for (const step of source.steps) {
269
+ if (['step_id', 'action'].some(key => step[key] !== undefined && step[key] !== null && step[key] !== '')) throw errUsage('Create requires new steps with blank step_id and action; do not reuse persisted Step IDs');
270
+ }
271
+ const name = source.name || source.displayName;
272
+ if (typeof name !== 'string' || !name.trim()) throw errUsage('Create requires a definition name');
273
+ await validateScope(sdk, scope);
274
+ const parentData = { name, description: source.description || '', sys_scope: scope };
275
+ if (source.internal_name !== undefined) parentData.internal_name = source.internal_name;
276
+ let parent;
277
+ try { parent = await sdk.create(ACTION_TABLE, parentData); }
278
+ catch (error) {
279
+ throw failure('action_create_partial', `Parent creation is unverified: ${error.message}. Inspect actions named '${name}' before retrying.`, {
280
+ scope, name, status: 'unknown_parent', cleanup: 'explicit',
281
+ });
282
+ }
283
+ const id = raw(parent?.sys_id);
284
+ if (!id) throw failure('action_create_partial', 'Parent creation returned no action ID; inspect the action table before retrying', { status: 'unknown_parent', cleanup: 'explicit' });
285
+ try {
286
+ token(id, 'Created action ID');
287
+ await actionScope(sdk, id, scope);
288
+ const fresh = await metadata(sdk, id, scope);
289
+ // Keep this parent's lifecycle/status objects and reserved output IDs.
290
+ const reserved = (fresh.outputs || []).filter(o => o.name?.startsWith('__'));
291
+ const definition = { ...fresh, name, displayName: name, description: parentData.description,
292
+ inputs: source.inputs, outputs: [...reserved, ...source.outputs.filter(o => !o.name?.startsWith('__'))],
293
+ steps: source.steps.map(step => ({ ...step, action: id })) };
294
+ if (source.internal_name !== undefined) definition.internal_name = source.internal_name;
295
+ validateDefinition(definition, id, scope);
296
+ return await save(sdk, id, scope, definition, new Set(reserved.filter(o => ['__action_status__', '__dont_treat_as_error__'].includes(o.name)).map(o => o.name)));
297
+ } catch (error) {
298
+ throw failure('action_create_partial', `Action parent ${id} exists; full definition is unverified: ${error.message}. No automatic cleanup was performed.`, {
299
+ ...error.details, sys_id: id, scope, status: 'parent_created_definition_unverified', cleanup: 'explicit',
300
+ });
301
+ }
302
+ }
303
+
304
+ function waitTimeout(context, state) {
305
+ return failure('action_test_timeout', `Action test ${context} exceeded --timeout; state=${state}. Execution was not cancelled.`, { context, state, status: 'timeout' });
306
+ }
307
+ export async function testAction(sdk, id, providedScope, suppliedDefinition, outputMap = {}, opts = {}) {
308
+ if (!object(outputMap)) throw errUsage('--output-map must be a JSON object of input names to values');
309
+ const timeout = opts.timeout ?? 60;
310
+ if (!Number.isFinite(timeout) || timeout <= 0 || timeout > 3600) throw errUsage('--timeout must be between 0 and 3600 seconds, exclusive of 0');
311
+ if (suppliedDefinition !== undefined) validateDefinition(suppliedDefinition, id, providedScope);
312
+ const scope = await actionScope(sdk, id, providedScope);
313
+ const definition = suppliedDefinition ?? await getAction(sdk, id, scope);
314
+ validateDefinition(definition, id, scope);
315
+ const dispatched = await request(sdk, id, scope, '/test', 'POST', {
316
+ action: definition, outputMap, runOnThread: opts.runOnThread ?? true, tracingEnabled: opts.tracingEnabled ?? false,
317
+ });
318
+ if (!successCode(dispatched.errorCode) || dispatched.errorMessage) throw failure('action_test_dispatch_failed', dispatched.errorMessage || 'Test dispatch did not return errorCode 0');
319
+ const context = token(dispatched.data, 'Test context ID');
320
+ if (!opts.wait) return { context, state: 'DISPATCHED', status: 'dispatched', outputs: null };
321
+ const deadline = Date.now() + timeout * 1000;
322
+ let state = 'UNKNOWN';
323
+ const expected = definition.outputs.filter(o => !o.name.startsWith('__')).map(o => o.name);
324
+ const bounded = async (url) => {
325
+ const remaining = deadline - Date.now();
326
+ if (remaining <= 0) throw waitTimeout(context, state);
327
+ try { return await sdk.request(url, { method: 'GET', timeout: Math.min(sdk.timeout || 30000, remaining) }); }
328
+ catch (error) {
329
+ if (Date.now() >= deadline || /timed?\s*out|timeout/i.test(error.message)) throw waitTimeout(context, state);
330
+ throw failure('action_test_read_failed', `Cannot read test ${context} ${state}: ${error.message}`, { context, state, status: 'read_failed' });
331
+ }
332
+ };
333
+ while (Date.now() < deadline) {
334
+ const response = await bounded(`${sdk.baseURL}/api/now/table/sys_flow_context/${context}`);
335
+ const record = response?.result;
336
+ if (!record || raw(record.sys_id) !== context) throw failure('action_test_read_failed', `Test context ${context} is missing or unreadable`, { context, state, status: 'read_failed' });
337
+ state = String(raw(record.state) || 'UNKNOWN').toUpperCase();
338
+ const message = raw(record.error_message);
339
+ if (message || ['ERROR', 'FAILED', 'CANCELLED', 'CANCELED', 'TERMINATED'].includes(state)) {
340
+ throw failure('action_test_failed', `Test ${context} ended ${state}: ${message || 'terminal failure'}`, { context, state, status: 'failed' });
341
+ }
342
+ if (state === 'COMPLETE') {
343
+ if (!Object.hasOwn(record, 'error_message')) throw failure('action_test_read_failed', `Test ${context} COMPLETE but error_message is unreadable`, { context, state, status: 'read_failed' });
344
+ const query = new URLSearchParams({ sysparm_query: `context=${context}^type=output`, sysparm_fields: 'context,type,value', sysparm_display_value: 'false' });
345
+ const runtime = await bounded(`${sdk.baseURL}/api/now/table/sys_flow_runtime_value?${query}`);
346
+ const values = Object.create(null);
347
+ if (!Array.isArray(runtime?.result)) throw failure('action_test_outputs_missing', `Test ${context} output records are missing`, { context, state, status: 'outputs_missing' });
348
+ try {
349
+ for (const row of runtime.result) {
350
+ if (raw(row.context) !== context || raw(row.type) !== 'output') throw new Error('Runtime output belongs to a different context/type');
351
+ const map = JSON.parse(raw(row.value));
352
+ if (!object(map)) throw new Error('Runtime value is not a map');
353
+ Object.assign(values, map);
354
+ }
355
+ } catch (error) {
356
+ throw failure('action_test_outputs_invalid', `Test ${context} has invalid runtime outputs: ${error.message}`, { context, state, status: 'outputs_invalid' });
357
+ }
358
+ const missing = expected.filter(name => values[name]?.hasValue !== true || !Object.hasOwn(values[name], 'value'));
359
+ if (missing.length) throw failure('action_test_outputs_missing', `Test ${context} COMPLETE but missing actual outputs: ${missing.join(', ')}`, { context, state, status: 'outputs_missing', missing });
360
+ const outputs = Object.fromEntries(Object.entries(values).filter(([, v]) => v?.hasValue === true && Object.hasOwn(v, 'value')).map(([name, v]) => [name, { value: v.value, displayValue: v.displayValue, hasValue: v.hasValue }]));
361
+ return { context, state, status: 'complete', outputs };
362
+ }
363
+ await new Promise(resolve => setTimeout(resolve, Math.min(500, Math.max(0, deadline - Date.now()))));
364
+ }
365
+ throw waitTimeout(context, state);
366
+ }
package/src/sdk.js CHANGED
@@ -3,6 +3,7 @@
3
3
  import { gunzipSync } from 'node:zlib';
4
4
  import { errAuth, errAPI, errNetwork } from './errors.js';
5
5
  import { getStringField } from './helpers.js';
6
+ import { getAction, getStepTypes, createAction, updateAction, testAction } from './processflow-actions.js';
6
7
 
7
8
  const DEFAULT_TIMEOUT = 30000;
8
9
 
@@ -232,6 +233,13 @@ export class SDKClient {
232
233
  await this.request(endpoint, { method: 'DELETE' });
233
234
  }
234
235
 
236
+ // Flow Designer actions use full Process Flow documents, not table CRUD.
237
+ getProcessFlowAction(id, scope, opts) { return getAction(this, id, scope, opts); }
238
+ getProcessFlowStepTypes(scope) { return getStepTypes(this, scope); }
239
+ createProcessFlowAction(scope, definition) { return createAction(this, scope, definition); }
240
+ updateProcessFlowAction(id, scope, definition) { return updateAction(this, id, scope, definition); }
241
+ testProcessFlowAction(id, scope, definition, outputMap, opts) { return testAction(this, id, scope, definition, outputMap, opts); }
242
+
235
243
  // ─── Attachments ───
236
244
 
237
245
  /**