@sonisoft/now-sdk-ext-core 6.15.0 → 6.16.0
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 +3 -0
- package/dist/comm/http/RequestHandler.d.ts +7 -0
- package/dist/comm/http/RequestHandler.js +19 -5
- package/dist/comm/http/RequestHandler.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/policy/internal/Classify.js +23 -0
- package/dist/policy/internal/Classify.js.map +1 -1
- package/dist/sn/flow/FlowDebugError.d.ts +11 -0
- package/dist/sn/flow/FlowDebugError.js +39 -0
- package/dist/sn/flow/FlowDebugError.js.map +1 -0
- package/dist/sn/flow/FlowDebugModels.d.ts +241 -0
- package/dist/sn/flow/FlowDebugModels.js +8 -0
- package/dist/sn/flow/FlowDebugModels.js.map +1 -0
- package/dist/sn/flow/FlowDebugger.d.ts +162 -0
- package/dist/sn/flow/FlowDebugger.js +822 -0
- package/dist/sn/flow/FlowDebugger.js.map +1 -0
- package/dist/sn/flow/FlowManager.d.ts +3 -0
- package/dist/sn/flow/FlowManager.js +11 -0
- package/dist/sn/flow/FlowManager.js.map +1 -1
- package/dist/sn/flow/FlowModels.d.ts +5 -0
- package/package.json +1 -1
|
@@ -0,0 +1,822 @@
|
|
|
1
|
+
import { SessionManager } from '../../comm/http/SessionManager.js';
|
|
2
|
+
import { PROCESSFLOW_API_BASE } from '../../constants/ServiceNow.js';
|
|
3
|
+
import { checkRequirement } from '../../policy/Policy.js';
|
|
4
|
+
import { isPolicyRefusal, policyRefusal } from '../../policy/PolicyRefusal.js';
|
|
5
|
+
import { Logger } from '../../util/Logger.js';
|
|
6
|
+
import { FlowDebugError } from './FlowDebugError.js';
|
|
7
|
+
import { FlowManager } from './FlowManager.js';
|
|
8
|
+
const ID = /^[a-f0-9]{32}$/;
|
|
9
|
+
const SCOPED_NAME = /^([a-z0-9_]{1,100})\.([a-z0-9_]{1,200})$/i;
|
|
10
|
+
const USER_NAME = /^[a-z0-9_.@+-]{1,100}$/i;
|
|
11
|
+
/** An inner step of a custom action: `<step sys_id>-<action uiUniqueIdentifier>`. */
|
|
12
|
+
const ACTION_STEP = /^([a-f0-9]{32})-(.+)$/;
|
|
13
|
+
const COMMANDS = ['STEP_INTO', 'STEP_OVER', 'STEP_OUT', 'RESUME', 'SKIP_ALL'];
|
|
14
|
+
const ENDED = new Set(['COMPLETE', 'CANCELLED', 'ERROR']);
|
|
15
|
+
const EXECUTING = new Set(['IN_PROGRESS', 'QUEUED', 'STARTING', 'RUNNING']);
|
|
16
|
+
/** Contexts a debug run can still be holding open. */
|
|
17
|
+
const OPEN = ['PAUSED_IN_DEBUG', 'WAITING', 'IN_PROGRESS', 'QUEUED', 'STARTING', 'RUNNING'];
|
|
18
|
+
const GLOBAL = { sysparm_transaction_scope: 'global' };
|
|
19
|
+
const HEADERS = { 'Content-Type': 'application/json', Accept: 'application/json' };
|
|
20
|
+
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
21
|
+
/** How deep a chain of step-into subflows is followed. */
|
|
22
|
+
const MAX_DEPTH = 10;
|
|
23
|
+
const obj = (value) => (value && typeof value === 'object' && !Array.isArray(value) ? value : {});
|
|
24
|
+
const arr = (value) => (Array.isArray(value) ? value : []);
|
|
25
|
+
const str = (value) => {
|
|
26
|
+
if (typeof value === 'string')
|
|
27
|
+
return value;
|
|
28
|
+
if (typeof value === 'number' || typeof value === 'boolean')
|
|
29
|
+
return String(value);
|
|
30
|
+
return '';
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Drives the Workflow Studio flow debugger (**Test ▸ Debug**) over REST.
|
|
34
|
+
*
|
|
35
|
+
* A debug run executes the flow or subflow for real, so its actions create and change records,
|
|
36
|
+
* and it holds a flow context open while paused. Every run must be ended: by running it to the
|
|
37
|
+
* end, or with {@link cancel}. {@link withSession}, and {@link runTo} by default, cancel the run
|
|
38
|
+
* for you; `runTo` with `after: 'resume'` or `'stay'` leaves it running or paused.
|
|
39
|
+
*
|
|
40
|
+
* The APIs are Workflow Studio's own (`/api/now/processflow/debug/…`), not a published REST
|
|
41
|
+
* contract. They work with the ordinary OAuth credential; no UI session is needed.
|
|
42
|
+
*
|
|
43
|
+
* Starting a run and sending a step command need execute permission under an installed policy;
|
|
44
|
+
* breakpoint changes and cancel need write, so a run stays cancellable under `--deny-execute`.
|
|
45
|
+
*
|
|
46
|
+
* Every method takes the **root** context id that {@link start} returns. When step into enters a
|
|
47
|
+
* subflow, the subflow runs in a child context of its own; commands, breakpoints and snapshots
|
|
48
|
+
* then apply to that child, and the snapshot's `chain` shows the path to it.
|
|
49
|
+
*/
|
|
50
|
+
export class FlowDebugger {
|
|
51
|
+
_logger = new Logger('FlowDebugger');
|
|
52
|
+
_request;
|
|
53
|
+
_flows;
|
|
54
|
+
/** allowed-breakpoints never changes for a context, so it is read once. */
|
|
55
|
+
_allowed = new Map();
|
|
56
|
+
/**
|
|
57
|
+
* Poll schedule while waiting for a run to settle. `waitingSettleMs`: right after step into a
|
|
58
|
+
* subflow the parent is WAITING before its child context exists, so WAITING only counts as
|
|
59
|
+
* settled once it has lasted this long.
|
|
60
|
+
*/
|
|
61
|
+
_poll = { firstMs: 300, maxMs: 2000, factor: 1.5, waitingSettleMs: 5000 };
|
|
62
|
+
constructor(instance) {
|
|
63
|
+
this._request = SessionManager.getInstance().getRequest(instance);
|
|
64
|
+
this._flows = new FlowManager(instance);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Start a debug run and wait for it to first pause.
|
|
68
|
+
*
|
|
69
|
+
* The flow's current draft is what runs, as in Workflow Studio. Inputs are checked against a
|
|
70
|
+
* subflow's declared inputs before anything is sent. If the run cannot be set up after it
|
|
71
|
+
* started, it is cancelled again before the error is thrown, and the error carries its
|
|
72
|
+
* `contextId` and whether the cancel worked.
|
|
73
|
+
*
|
|
74
|
+
* Under an installed policy, a run is only started when a cancel would also be permitted:
|
|
75
|
+
* starting needs execute, and breakpoints and cancel need write.
|
|
76
|
+
*
|
|
77
|
+
* A run normally pauses before its first step. One that fails or finishes first is returned
|
|
78
|
+
* as it ended; check `snapshot.state`.
|
|
79
|
+
*/
|
|
80
|
+
async start(options) {
|
|
81
|
+
const opts = obj(options);
|
|
82
|
+
const timeoutMs = this.timeout(opts.timeoutMs);
|
|
83
|
+
if (opts.inputs !== undefined && (typeof opts.inputs !== 'object' || opts.inputs === null || Array.isArray(opts.inputs))) {
|
|
84
|
+
throw new FlowDebugError('invalid_input');
|
|
85
|
+
}
|
|
86
|
+
const breakpointRefs = opts.breakpoints === undefined ? [] : this.refs(opts.breakpoints);
|
|
87
|
+
const sysId = await this.resolveFlow(opts.flow);
|
|
88
|
+
const { definition, type } = await this.definition(sysId);
|
|
89
|
+
const outputMap = this.outputMap(definition, type, opts.inputs ?? {});
|
|
90
|
+
const scope = str(definition.scope);
|
|
91
|
+
// Never start a run this session could not cancel again.
|
|
92
|
+
const permitted = checkRequirement({ verbs: ['execute', 'write'], target: 'instance' });
|
|
93
|
+
if (!permitted.allowed)
|
|
94
|
+
throw policyRefusal(permitted);
|
|
95
|
+
let response;
|
|
96
|
+
try {
|
|
97
|
+
response = await this.call('post', 'debug/start/flow-object', {
|
|
98
|
+
flow: { ...definition, deletedActions: [], deletedTriggers: [] },
|
|
99
|
+
outputMap,
|
|
100
|
+
runOnThread: true,
|
|
101
|
+
tracingEnabled: true,
|
|
102
|
+
}, { query: { sysparm_transaction_scope: ID.test(scope) ? scope : 'global' }, sensitive: true });
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
throw await this.startFailure(error);
|
|
106
|
+
}
|
|
107
|
+
const result = obj(response.result);
|
|
108
|
+
const contextId = str(result.body);
|
|
109
|
+
if (result.success !== true || result.command !== 'launch' || !ID.test(contextId)) {
|
|
110
|
+
throw new FlowDebugError('start_failed', { serverMessage: str(result.message) });
|
|
111
|
+
}
|
|
112
|
+
this._logger.info('Flow debug run started', { flow: sysId, contextId });
|
|
113
|
+
try {
|
|
114
|
+
const first = await this.waitFor(contextId, null, timeoutMs);
|
|
115
|
+
const locations = await this.locationsOf(contextId);
|
|
116
|
+
let breakpoints = [];
|
|
117
|
+
if (breakpointRefs.length && first.snapshot.state === 'PAUSED') {
|
|
118
|
+
breakpoints = await this.setBreakpoints(contextId, breakpointRefs);
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
contextId,
|
|
122
|
+
flow: { sysId, name: str(definition.name), type, scope: str(definition.scopeName) || scope },
|
|
123
|
+
locations,
|
|
124
|
+
breakpoints,
|
|
125
|
+
snapshot: first.snapshot,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
// Whatever failed, the caller gets the context id and whether the run was ended.
|
|
130
|
+
const cancelled = await this.cancel(contextId).then(r => r.cancelled || r.alreadyEnded, () => false);
|
|
131
|
+
const code = error instanceof FlowDebugError ? error.code : 'start_failed';
|
|
132
|
+
const detail = error instanceof FlowDebugError ? error.detail : undefined;
|
|
133
|
+
throw new FlowDebugError(code, { ...detail, contextId, cancelled }, error instanceof FlowDebugError ? undefined : error);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Classify a failed start call. Its request is marked sensitive, so only the status survives;
|
|
138
|
+
* a 400 or 404 is checked against a harmless debugger read to tell an instance without the
|
|
139
|
+
* debugger from a start it refused.
|
|
140
|
+
*/
|
|
141
|
+
async startFailure(error) {
|
|
142
|
+
const translated = this.translate(error, undefined, 'start_failed');
|
|
143
|
+
if (!(translated instanceof FlowDebugError) || translated.code !== 'context_not_found' && translated.detail?.status !== 400) {
|
|
144
|
+
return translated;
|
|
145
|
+
}
|
|
146
|
+
const status = translated.detail?.status;
|
|
147
|
+
try {
|
|
148
|
+
await this.call('get', `debug/allowed-breakpoints/${'0'.repeat(32)}`);
|
|
149
|
+
}
|
|
150
|
+
catch (probe) {
|
|
151
|
+
const answer = this.translate(probe, undefined, 'unexpected_response');
|
|
152
|
+
if (answer instanceof FlowDebugError && answer.code === 'release_unsupported')
|
|
153
|
+
return new FlowDebugError('release_unsupported', { status });
|
|
154
|
+
}
|
|
155
|
+
return new FlowDebugError('start_failed', { status });
|
|
156
|
+
}
|
|
157
|
+
/** The run as it is now, without moving it. */
|
|
158
|
+
async inspect(contextId) {
|
|
159
|
+
return (await this.observe(this.id(contextId))).snapshot;
|
|
160
|
+
}
|
|
161
|
+
/** Where the active context (the root, or the child subflow it is paused in) can stop. */
|
|
162
|
+
async locations(contextId) {
|
|
163
|
+
const observed = await this.observe(this.id(contextId));
|
|
164
|
+
return this.locationsOf(observed.snapshot.contextId);
|
|
165
|
+
}
|
|
166
|
+
/** Breakpoints the active context holds. */
|
|
167
|
+
async getBreakpoints(contextId) {
|
|
168
|
+
const observed = await this.observe(this.id(contextId));
|
|
169
|
+
return this.readBreakpoints(observed.snapshot.contextId);
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Replace the active context's breakpoints with these steps, and read them back.
|
|
173
|
+
*
|
|
174
|
+
* Steps are resolved against the debugger's own list of locations, never sent as given: the
|
|
175
|
+
* debugger accepts any element id on a valid line, so a guessed pair would be "verified" and
|
|
176
|
+
* stop somewhere else.
|
|
177
|
+
*/
|
|
178
|
+
async setBreakpoints(contextId, steps) {
|
|
179
|
+
const refs = this.refs(steps);
|
|
180
|
+
const observed = await this.observe(this.id(contextId));
|
|
181
|
+
this.requireOpen(observed);
|
|
182
|
+
const target = observed.snapshot.contextId;
|
|
183
|
+
const locations = await this.locationsOf(target);
|
|
184
|
+
const chosen = new Map();
|
|
185
|
+
for (const ref of refs) {
|
|
186
|
+
const location = this.match(locations, ref);
|
|
187
|
+
if (!location) {
|
|
188
|
+
const validSteps = [...new Set(locations.map(l => l.step?.number).filter((n) => n !== undefined))].sort((a, b) => a - b);
|
|
189
|
+
throw new FlowDebugError('unknown_breakpoint', { contextId: target, validSteps });
|
|
190
|
+
}
|
|
191
|
+
chosen.set(location.line, location);
|
|
192
|
+
}
|
|
193
|
+
const wanted = [...chosen.values()].map(l => ({ flowElementId: l.flowElementId, line: l.line }));
|
|
194
|
+
const reply = await this.debugCall('post', `breakpoints/${target}`, target, wanted);
|
|
195
|
+
const accepted = arr(obj(reply.result).body).map(obj);
|
|
196
|
+
const refused = accepted.find(b => b.verified !== true);
|
|
197
|
+
if (refused)
|
|
198
|
+
throw new FlowDebugError('breakpoint_not_verified', { contextId: target, serverMessage: str(refused.message) });
|
|
199
|
+
const readBack = await this.readBreakpoints(target);
|
|
200
|
+
const same = readBack.length === wanted.length && wanted.every(w => readBack.some(b => b.line === w.line && b.flowElementId === w.flowElementId));
|
|
201
|
+
if (!same)
|
|
202
|
+
throw new FlowDebugError('unexpected_response', { contextId: target });
|
|
203
|
+
this._logger.info('Flow debug breakpoints set', { contextId: target, lines: wanted.map(w => w.line) });
|
|
204
|
+
return readBack;
|
|
205
|
+
}
|
|
206
|
+
/** Remove every breakpoint from the active context. */
|
|
207
|
+
async clearBreakpoints(contextId) {
|
|
208
|
+
const observed = await this.observe(this.id(contextId));
|
|
209
|
+
this.requireOpen(observed);
|
|
210
|
+
const target = observed.snapshot.contextId;
|
|
211
|
+
await this.debugCall('post', `breakpoints/${target}`, target, []);
|
|
212
|
+
if ((await this.readBreakpoints(target)).length)
|
|
213
|
+
throw new FlowDebugError('unexpected_response', { contextId: target });
|
|
214
|
+
}
|
|
215
|
+
/** Run the paused step; stop at the next one. */
|
|
216
|
+
stepOver(contextId, options) {
|
|
217
|
+
return this.command(contextId, 'STEP_OVER', options);
|
|
218
|
+
}
|
|
219
|
+
/** Like step over, but a subflow call is entered and paused in, in a child context. */
|
|
220
|
+
stepInto(contextId, options) {
|
|
221
|
+
return this.command(contextId, 'STEP_INTO', options);
|
|
222
|
+
}
|
|
223
|
+
/** Run the rest of the active context; at the top level, that is the rest of the run. */
|
|
224
|
+
stepOut(contextId, options) {
|
|
225
|
+
return this.command(contextId, 'STEP_OUT', options);
|
|
226
|
+
}
|
|
227
|
+
/** Run to the next breakpoint, or to the end. */
|
|
228
|
+
resume(contextId, options) {
|
|
229
|
+
return this.command(contextId, 'RESUME', options);
|
|
230
|
+
}
|
|
231
|
+
/** Run to the end, ignoring breakpoints. */
|
|
232
|
+
skipAll(contextId, options) {
|
|
233
|
+
return this.command(contextId, 'SKIP_ALL', options);
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Send one debugger command to the paused context and wait for the run to settle: paused
|
|
237
|
+
* again, ended, waiting, or still executing when the wait times out (`timedOut`).
|
|
238
|
+
*/
|
|
239
|
+
async command(contextId, command, options) {
|
|
240
|
+
// The debugger answers success to any name and then runs the flow to the end.
|
|
241
|
+
if (!COMMANDS.includes(command))
|
|
242
|
+
throw new FlowDebugError('invalid_input');
|
|
243
|
+
const root = this.id(contextId);
|
|
244
|
+
const timeoutMs = this.timeout(options?.timeoutMs);
|
|
245
|
+
const before = await this.observe(root);
|
|
246
|
+
this.requireDebug(before, root);
|
|
247
|
+
if (before.snapshot.state !== 'PAUSED') {
|
|
248
|
+
throw new FlowDebugError('not_paused', { contextId: before.snapshot.contextId, state: before.snapshot.rawState });
|
|
249
|
+
}
|
|
250
|
+
const target = before.snapshot.contextId;
|
|
251
|
+
const reply = obj((await this.debugCall('post', `command/${target}/${command}`, target, {})).result);
|
|
252
|
+
if (reply.success !== true || reply.command !== command) {
|
|
253
|
+
throw new FlowDebugError('unexpected_response', { contextId: target, serverMessage: str(reply.message) });
|
|
254
|
+
}
|
|
255
|
+
this._logger.info('Flow debug command sent', { contextId: target, command });
|
|
256
|
+
return (await this.waitFor(root, before.fingerprint, timeoutMs)).snapshot;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* End the run. Cancelling the root also ends any child subflow context. The state is read
|
|
260
|
+
* back, because the debugger answers "cancelled flow" whatever it did. Safe to repeat.
|
|
261
|
+
*
|
|
262
|
+
* @throws FlowDebugError `not_a_debug_session` for a context the debugger did not start, so
|
|
263
|
+
* this can never cancel someone's real flow run.
|
|
264
|
+
*/
|
|
265
|
+
async cancel(contextId) {
|
|
266
|
+
const root = await this.rootOf(this.id(contextId));
|
|
267
|
+
const first = await this.read(root);
|
|
268
|
+
if (obj(first.flowContext).debugMode !== true)
|
|
269
|
+
throw new FlowDebugError('not_a_debug_session', { contextId: root, state: first.state });
|
|
270
|
+
// Collected before the cancel: once the root's cancel ends a middle context, a walk from the
|
|
271
|
+
// root no longer reaches the contexts below it.
|
|
272
|
+
const openBefore = await this.openDescendants(root);
|
|
273
|
+
if (ENDED.has(first.state) && openBefore.length === 0) {
|
|
274
|
+
return { contextId: root, before: first.state, after: first.state, cancelled: false, alreadyEnded: true };
|
|
275
|
+
}
|
|
276
|
+
let endedHere = false;
|
|
277
|
+
if (!ENDED.has(first.state))
|
|
278
|
+
await this.postCancel(root);
|
|
279
|
+
let after = ENDED.has(first.state) ? first.state : await this.settleCancel(root);
|
|
280
|
+
// Cancelling the root has ended its subflow contexts every time it was tried; one left open
|
|
281
|
+
// would still hold a worker, so each is read back, and cancelled itself if needed.
|
|
282
|
+
const descendants = [...new Set([...openBefore, ...await this.openDescendants(root)])];
|
|
283
|
+
for (const child of descendants) {
|
|
284
|
+
const state = (await this.read(child)).state;
|
|
285
|
+
if (ENDED.has(state)) {
|
|
286
|
+
endedHere = endedHere || state === 'CANCELLED';
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
await this.postCancel(child);
|
|
290
|
+
const settled = await this.settleCancel(child);
|
|
291
|
+
if (!ENDED.has(settled))
|
|
292
|
+
throw new FlowDebugError('cancel_failed', { contextId: child, state: settled });
|
|
293
|
+
endedHere = true;
|
|
294
|
+
}
|
|
295
|
+
if (!ENDED.has(after)) {
|
|
296
|
+
await this.postCancel(root);
|
|
297
|
+
after = await this.settleCancel(root);
|
|
298
|
+
}
|
|
299
|
+
if (!ENDED.has(after))
|
|
300
|
+
throw new FlowDebugError('cancel_failed', { contextId: root, state: after });
|
|
301
|
+
this._logger.info('Flow debug run cancelled', { contextId: root, before: first.state, after });
|
|
302
|
+
const cancelled = (!ENDED.has(first.state) && after === 'CANCELLED') || endedHere;
|
|
303
|
+
return { contextId: root, before: first.state, after, cancelled, alreadyEnded: false };
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Start, run to the first of the breakpoints, and report the run there. By default the run is
|
|
307
|
+
* then cancelled, so one call leaves nothing open.
|
|
308
|
+
*/
|
|
309
|
+
async runTo(options) {
|
|
310
|
+
const after = options?.after ?? 'cancel';
|
|
311
|
+
if (!['cancel', 'resume', 'stay'].includes(after))
|
|
312
|
+
throw new FlowDebugError('invalid_input');
|
|
313
|
+
if (!Array.isArray(options?.breakpoints) || options.breakpoints.length === 0)
|
|
314
|
+
throw new FlowDebugError('invalid_input');
|
|
315
|
+
const session = await this.start(options);
|
|
316
|
+
const root = session.contextId;
|
|
317
|
+
try {
|
|
318
|
+
let snapshot = session.snapshot;
|
|
319
|
+
const lines = new Set(session.breakpoints.map(b => b.line));
|
|
320
|
+
const atBreakpoint = snapshot.state === 'PAUSED' && snapshot.pausedLines.some(l => lines.has(l)) && snapshot.contextId === root;
|
|
321
|
+
if (snapshot.state === 'PAUSED' && !atBreakpoint)
|
|
322
|
+
snapshot = await this.resume(root, { timeoutMs: options.timeoutMs });
|
|
323
|
+
const result = { session, snapshot };
|
|
324
|
+
if (after === 'resume') {
|
|
325
|
+
// final is the run as last seen. Not paused means it is already running on (or ended):
|
|
326
|
+
// a run that has not ended by then is left running, and final says so.
|
|
327
|
+
result.final = snapshot.state === 'PAUSED' ? await this.skipAll(root, { timeoutMs: options.timeoutMs }) : snapshot;
|
|
328
|
+
}
|
|
329
|
+
if (after === 'cancel')
|
|
330
|
+
result.cancel = await this.cancel(root);
|
|
331
|
+
return result;
|
|
332
|
+
}
|
|
333
|
+
catch (error) {
|
|
334
|
+
await this.cancel(root).catch(() => undefined);
|
|
335
|
+
throw error;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Start a run, hand it to `work`, and cancel it afterwards unless it has ended.
|
|
340
|
+
*
|
|
341
|
+
* If `work` throws, the run is cancelled and `work`'s error is rethrown. If `work` succeeds but
|
|
342
|
+
* the final cancel fails, the cancel's error is thrown instead of the result: a run that may
|
|
343
|
+
* still be open matters more than the result.
|
|
344
|
+
*/
|
|
345
|
+
async withSession(options, work) {
|
|
346
|
+
const session = await this.start(options);
|
|
347
|
+
let result;
|
|
348
|
+
try {
|
|
349
|
+
result = await work(session);
|
|
350
|
+
}
|
|
351
|
+
catch (error) {
|
|
352
|
+
await this.cancel(session.contextId).catch(() => undefined);
|
|
353
|
+
throw error;
|
|
354
|
+
}
|
|
355
|
+
await this.cancel(session.contextId);
|
|
356
|
+
return result;
|
|
357
|
+
}
|
|
358
|
+
/** Debug runs that are still open (paused, waiting or executing), newest first. */
|
|
359
|
+
async listSessions(options = {}) {
|
|
360
|
+
const limit = options.limit ?? 50;
|
|
361
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 500)
|
|
362
|
+
throw new FlowDebugError('invalid_input');
|
|
363
|
+
if (options.createdBy !== undefined && !USER_NAME.test(options.createdBy))
|
|
364
|
+
throw new FlowDebugError('invalid_input');
|
|
365
|
+
let query = `calling_source=DEBUG_BUTTON^stateIN${OPEN.join(',')}`;
|
|
366
|
+
if (options.createdBy)
|
|
367
|
+
query += `^sys_created_by=${options.createdBy}`;
|
|
368
|
+
const rows = await this.table('sys_flow_context', `${query}^ORDERBYDESCsys_created_on`, 'sys_id,name,flow,state,sys_created_by,sys_created_on', limit);
|
|
369
|
+
return rows.map(row => ({
|
|
370
|
+
contextId: str(row.sys_id),
|
|
371
|
+
flowName: str(row.name),
|
|
372
|
+
flowSysId: str(obj(row.flow).value ?? row.flow),
|
|
373
|
+
state: str(row.state),
|
|
374
|
+
createdBy: str(row.sys_created_by),
|
|
375
|
+
createdOn: str(row.sys_created_on),
|
|
376
|
+
}));
|
|
377
|
+
}
|
|
378
|
+
// ----------------------------------------------------------------
|
|
379
|
+
// Resolution and inputs
|
|
380
|
+
// ----------------------------------------------------------------
|
|
381
|
+
async resolveFlow(flow) {
|
|
382
|
+
const identifier = str(flow).trim();
|
|
383
|
+
if (ID.test(identifier.toLowerCase()))
|
|
384
|
+
return identifier.toLowerCase();
|
|
385
|
+
const scoped = SCOPED_NAME.exec(identifier);
|
|
386
|
+
if (!scoped)
|
|
387
|
+
throw new FlowDebugError('invalid_input');
|
|
388
|
+
const rows = await this.table('sys_hub_flow', `sys_scope.scope=${scoped[1]}^internal_name=${scoped[2]}`, 'sys_id', 2);
|
|
389
|
+
if (rows.length === 0)
|
|
390
|
+
throw new FlowDebugError('flow_not_found');
|
|
391
|
+
if (rows.length > 1)
|
|
392
|
+
throw new FlowDebugError('invalid_input');
|
|
393
|
+
return str(rows[0].sys_id);
|
|
394
|
+
}
|
|
395
|
+
async definition(sysId) {
|
|
396
|
+
const result = await this._flows.getFlowArtifactDefinition(sysId, { scope: 'global' });
|
|
397
|
+
if (result.success && result.definition && (result.artifactType === 'flow' || result.artifactType === 'subflow')) {
|
|
398
|
+
return { definition: result.definition, type: result.artifactType };
|
|
399
|
+
}
|
|
400
|
+
if (result.success)
|
|
401
|
+
throw new FlowDebugError('not_debuggable');
|
|
402
|
+
if (result.failureReason === 'permission_denied')
|
|
403
|
+
throw new FlowDebugError('permission_denied');
|
|
404
|
+
if (result.failureReason === 'not_found' || result.failureReason === 'invalid_identifier') {
|
|
405
|
+
// Action Designer has Test, not Debug: there is no start endpoint for an action.
|
|
406
|
+
const actions = await this.table('sys_hub_action_type_definition', `sys_id=${sysId}`, 'sys_id', 1);
|
|
407
|
+
throw new FlowDebugError(actions.length ? 'not_debuggable' : 'flow_not_found');
|
|
408
|
+
}
|
|
409
|
+
throw new FlowDebugError('unexpected_response');
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* The `outputMap` the start call takes. For a subflow, names are checked against its declared
|
|
413
|
+
* inputs, and a JavaScript array for an `array.*` input becomes `{children: [...]}`, the shape
|
|
414
|
+
* Workflow Studio sends; a plain array reaches the flow as an empty list.
|
|
415
|
+
*/
|
|
416
|
+
outputMap(definition, type, inputs) {
|
|
417
|
+
const out = {};
|
|
418
|
+
if (type === 'flow') {
|
|
419
|
+
// A flow's inputs are its trigger's outputs, which have no declared list to check.
|
|
420
|
+
for (const [name, value] of Object.entries(inputs))
|
|
421
|
+
out[name] = value;
|
|
422
|
+
return out;
|
|
423
|
+
}
|
|
424
|
+
const declared = new Map(arr(definition.inputs).map(obj).map(input => [str(input.name), input]));
|
|
425
|
+
const unknown = Object.keys(inputs).filter(name => !declared.has(name));
|
|
426
|
+
if (unknown.length)
|
|
427
|
+
throw new FlowDebugError('unknown_input', { inputs: unknown });
|
|
428
|
+
const missing = [...declared.values()]
|
|
429
|
+
.filter(input => input.mandatory === true && (inputs[str(input.name)] === undefined || inputs[str(input.name)] === null))
|
|
430
|
+
.map(input => str(input.name));
|
|
431
|
+
if (missing.length)
|
|
432
|
+
throw new FlowDebugError('missing_input', { inputs: missing });
|
|
433
|
+
for (const [name, value] of Object.entries(inputs)) {
|
|
434
|
+
const input = declared.get(name) ?? {};
|
|
435
|
+
if (str(input.type).startsWith('array.') && Array.isArray(value)) {
|
|
436
|
+
const child = str(obj(arr(input.children)[0]).name) || `${name}_child0`;
|
|
437
|
+
out[name] = { children: value.map(item => ({ name: child, value: item })) };
|
|
438
|
+
}
|
|
439
|
+
else {
|
|
440
|
+
out[name] = value;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return out;
|
|
444
|
+
}
|
|
445
|
+
// ----------------------------------------------------------------
|
|
446
|
+
// Observing the run
|
|
447
|
+
// ----------------------------------------------------------------
|
|
448
|
+
/** Read the root and follow it into the child subflow context a step into left paused. */
|
|
449
|
+
async observe(root) {
|
|
450
|
+
const rootRead = await this.read(root);
|
|
451
|
+
const chain = [root];
|
|
452
|
+
let current = rootRead;
|
|
453
|
+
while (current.state === 'WAITING' && chain.length <= MAX_DEPTH) {
|
|
454
|
+
const child = (await this.openChildren(current.contextId))[0];
|
|
455
|
+
if (!child || chain.includes(child))
|
|
456
|
+
break;
|
|
457
|
+
current = await this.read(child);
|
|
458
|
+
chain.push(child);
|
|
459
|
+
}
|
|
460
|
+
const locations = await this.locationsOf(current.contextId, current.raw).catch(() => []);
|
|
461
|
+
const snapshot = this.snapshot(root, chain, current, locations);
|
|
462
|
+
// The engine numbers every instruction it executes (operationsCore.order), so a loop that
|
|
463
|
+
// pauses at the same line again still changes the fingerprint.
|
|
464
|
+
const orders = ['actionOperationsReports', 'subflowOperationsReports', 'iterationOperationsReports']
|
|
465
|
+
.flatMap(bucket => Object.entries(obj(current.report[bucket])).map(([key, entry]) => `${key}:${str(obj(obj(entry).operationsCore).order)}`));
|
|
466
|
+
const fingerprint = JSON.stringify([chain, current.state, snapshot.pausedLines, str(current.flowContext.runTime),
|
|
467
|
+
snapshot.steps.map(s => `${s.reportKey}:${s.state}`), orders, str(obj(current.report.operationsCore).order)]);
|
|
468
|
+
return { snapshot, fingerprint, debugMode: obj(rootRead.flowContext).debugMode === true };
|
|
469
|
+
}
|
|
470
|
+
/** Poll until the run differs from `previous` and is no longer executing, or the time runs out. */
|
|
471
|
+
async waitFor(root, previous, timeoutMs) {
|
|
472
|
+
const deadline = Date.now() + timeoutMs;
|
|
473
|
+
let delay = this._poll.firstMs;
|
|
474
|
+
let waitingSince;
|
|
475
|
+
for (;;) {
|
|
476
|
+
await this.sleep(delay);
|
|
477
|
+
const observed = await this.observe(root);
|
|
478
|
+
const moved = previous === null || observed.fingerprint !== previous;
|
|
479
|
+
const waiting = observed.snapshot.rawState === 'WAITING';
|
|
480
|
+
waitingSince = waiting ? waitingSince ?? Date.now() : undefined;
|
|
481
|
+
const settled = !EXECUTING.has(observed.snapshot.rawState)
|
|
482
|
+
&& (!waiting || Date.now() - (waitingSince ?? 0) >= this._poll.waitingSettleMs);
|
|
483
|
+
if (moved && settled)
|
|
484
|
+
return observed;
|
|
485
|
+
if (Date.now() + delay > deadline) {
|
|
486
|
+
observed.snapshot.timedOut = true;
|
|
487
|
+
return observed;
|
|
488
|
+
}
|
|
489
|
+
delay = Math.min(this._poll.maxMs, Math.round(delay * this._poll.factor));
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
snapshot(root, chain, read, locations) {
|
|
493
|
+
const flowContext = read.flowContext;
|
|
494
|
+
const report = read.report;
|
|
495
|
+
const instances = this.instances(obj(read.raw.flow));
|
|
496
|
+
const pausedLines = arr(flowContext.pausedInDebugLocations).map(Number).filter(Number.isFinite);
|
|
497
|
+
const steps = [];
|
|
498
|
+
for (const bucket of ['actionOperationsReports', 'subflowOperationsReports', 'iterationOperationsReports']) {
|
|
499
|
+
for (const [key, value] of Object.entries(obj(report[bucket]))) {
|
|
500
|
+
const entry = obj(value);
|
|
501
|
+
const core = obj(entry.operationsCore);
|
|
502
|
+
const step = instances.byId.get(key.split('.')[0]);
|
|
503
|
+
const error = str(core.error);
|
|
504
|
+
const runTime = Number(core.runTime);
|
|
505
|
+
steps.push({
|
|
506
|
+
reportKey: key,
|
|
507
|
+
step,
|
|
508
|
+
state: str(core.state),
|
|
509
|
+
...(error ? { error } : {}),
|
|
510
|
+
...(core.startTime ? { startTime: str(core.startTime) } : {}),
|
|
511
|
+
...(Number.isFinite(runTime) && core.runTime !== undefined ? { runTimeMs: runTime } : {}),
|
|
512
|
+
inputs: this.values(obj(entry.operationsInput).data),
|
|
513
|
+
outputs: this.values(obj(entry.operationsOutput).data),
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
steps.sort((a, b) => (a.step?.number ?? Number.MAX_SAFE_INTEGER) - (b.step?.number ?? Number.MAX_SAFE_INTEGER)
|
|
518
|
+
|| a.reportKey.localeCompare(b.reportKey));
|
|
519
|
+
const state = this.state(read.state);
|
|
520
|
+
const pausedAt = state === 'PAUSED' ? locations.find(l => pausedLines.includes(l.line)) : undefined;
|
|
521
|
+
const snapshot = {
|
|
522
|
+
rootContextId: root,
|
|
523
|
+
contextId: read.contextId,
|
|
524
|
+
chain,
|
|
525
|
+
flowName: str(flowContext.name),
|
|
526
|
+
state,
|
|
527
|
+
rawState: read.state,
|
|
528
|
+
pausedLines,
|
|
529
|
+
...(pausedAt ? { pausedAt } : {}),
|
|
530
|
+
steps,
|
|
531
|
+
flowInputs: this.values(obj(report.operationsInput).data),
|
|
532
|
+
flowOutputs: this.values(obj(report.operationsOutput).data),
|
|
533
|
+
};
|
|
534
|
+
if (state === 'ERROR') {
|
|
535
|
+
const failed = steps.find(s => s.state === 'ERROR' && s.error && s.error !== 'Success');
|
|
536
|
+
const error = failed?.error ?? str(obj(report.operationsCore).error);
|
|
537
|
+
if (error)
|
|
538
|
+
snapshot.error = error;
|
|
539
|
+
}
|
|
540
|
+
return snapshot;
|
|
541
|
+
}
|
|
542
|
+
state(raw) {
|
|
543
|
+
if (raw === 'PAUSED_IN_DEBUG')
|
|
544
|
+
return 'PAUSED';
|
|
545
|
+
if (EXECUTING.has(raw))
|
|
546
|
+
return 'RUNNING';
|
|
547
|
+
if (raw === 'WAITING' || raw === 'COMPLETE' || raw === 'CANCELLED' || raw === 'ERROR')
|
|
548
|
+
return raw;
|
|
549
|
+
return 'UNKNOWN';
|
|
550
|
+
}
|
|
551
|
+
values(data) {
|
|
552
|
+
const out = {};
|
|
553
|
+
for (const [name, value] of Object.entries(obj(data))) {
|
|
554
|
+
const item = obj(value);
|
|
555
|
+
out[name] = { value: 'value' in item ? item.value : value, ...(item.displayValue !== undefined ? { displayValue: str(item.displayValue) } : {}) };
|
|
556
|
+
}
|
|
557
|
+
return out;
|
|
558
|
+
}
|
|
559
|
+
/** The run's flow snapshot, indexed by instance id and by uiUniqueIdentifier. */
|
|
560
|
+
instances(flow) {
|
|
561
|
+
const byId = new Map();
|
|
562
|
+
const byUi = new Map();
|
|
563
|
+
const kinds = [['actionInstances', 'action'], ['flowLogicInstances', 'flowLogic'], ['subFlowInstances', 'subflow']];
|
|
564
|
+
for (const [bucket, kind] of kinds) {
|
|
565
|
+
for (const item of arr(flow[bucket]).map(obj)) {
|
|
566
|
+
const order = Number(item.order);
|
|
567
|
+
const ui = str(item.uiUniqueIdentifier);
|
|
568
|
+
const label = (str(item.name) || str(item.displayText) || str(obj(item.subFlow).name) || str(obj(item.actionType).name) || kind)
|
|
569
|
+
.trim().replace(/:\s*$/, '');
|
|
570
|
+
const info = {
|
|
571
|
+
...(Number.isFinite(order) && str(item.order) !== '' ? { number: order } : {}),
|
|
572
|
+
label,
|
|
573
|
+
kind,
|
|
574
|
+
uiUniqueIdentifier: ui,
|
|
575
|
+
...(item.id ? { snapshotInstanceId: str(item.id) } : {}),
|
|
576
|
+
...(str(item.parent) ? { parent: str(item.parent) } : {}),
|
|
577
|
+
};
|
|
578
|
+
if (item.id)
|
|
579
|
+
byId.set(str(item.id), info);
|
|
580
|
+
if (ui)
|
|
581
|
+
byUi.set(ui, info);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
return { byId, byUi };
|
|
585
|
+
}
|
|
586
|
+
async locationsOf(contextId, raw) {
|
|
587
|
+
let allowed = this._allowed.get(contextId);
|
|
588
|
+
if (!allowed) {
|
|
589
|
+
const reply = await this.debugCall('get', `allowed-breakpoints/${contextId}`, contextId);
|
|
590
|
+
allowed = arr(obj(reply.result).body).map(obj)
|
|
591
|
+
.map(b => ({ flowElementId: str(b.flowElementId), line: Number(b.line) }))
|
|
592
|
+
.filter(b => b.flowElementId && Number.isInteger(b.line));
|
|
593
|
+
this._allowed.set(contextId, allowed);
|
|
594
|
+
}
|
|
595
|
+
const flow = obj((raw ?? (await this.read(contextId)).raw).flow);
|
|
596
|
+
const { byUi } = this.instances(flow);
|
|
597
|
+
return allowed.map(({ flowElementId, line }) => {
|
|
598
|
+
const direct = byUi.get(flowElementId);
|
|
599
|
+
if (direct)
|
|
600
|
+
return { line, flowElementId, step: direct };
|
|
601
|
+
const inner = ACTION_STEP.exec(flowElementId);
|
|
602
|
+
const action = inner ? byUi.get(inner[2]) : undefined;
|
|
603
|
+
if (inner && action) {
|
|
604
|
+
return { line, flowElementId, step: { label: `${action.label} (inner step)`, kind: 'actionStep',
|
|
605
|
+
uiUniqueIdentifier: inner[2], actionStepId: inner[1],
|
|
606
|
+
...(action.snapshotInstanceId ? { snapshotInstanceId: action.snapshotInstanceId } : {}) } };
|
|
607
|
+
}
|
|
608
|
+
return { line, flowElementId };
|
|
609
|
+
}).sort((a, b) => a.line - b.line);
|
|
610
|
+
}
|
|
611
|
+
async readBreakpoints(contextId) {
|
|
612
|
+
const reply = await this.debugCall('get', `breakpoints/${contextId}`, contextId);
|
|
613
|
+
const locations = await this.locationsOf(contextId);
|
|
614
|
+
return arr(obj(reply.result).body).map(obj).map(b => {
|
|
615
|
+
const line = Number(b.line);
|
|
616
|
+
const location = locations.find(l => l.line === line);
|
|
617
|
+
return {
|
|
618
|
+
line,
|
|
619
|
+
flowElementId: str(b.flowElementId),
|
|
620
|
+
...(location?.step ? { step: location.step } : {}),
|
|
621
|
+
verified: b.verified === true,
|
|
622
|
+
...(b.message ? { message: str(b.message) } : {}),
|
|
623
|
+
};
|
|
624
|
+
}).sort((a, b) => a.line - b.line);
|
|
625
|
+
}
|
|
626
|
+
match(locations, ref) {
|
|
627
|
+
if (typeof ref === 'number')
|
|
628
|
+
return locations.find(l => l.step?.number === ref && l.step.kind !== 'actionStep');
|
|
629
|
+
if ('line' in ref)
|
|
630
|
+
return locations.find(l => l.line === ref.line);
|
|
631
|
+
return locations.find(l => l.flowElementId === ref.flowElementId);
|
|
632
|
+
}
|
|
633
|
+
requireDebug(observed, root) {
|
|
634
|
+
if (!observed.debugMode)
|
|
635
|
+
throw new FlowDebugError('not_a_debug_session', { contextId: root, state: observed.snapshot.rawState });
|
|
636
|
+
}
|
|
637
|
+
requireOpen(observed) {
|
|
638
|
+
this.requireDebug(observed, observed.snapshot.rootContextId);
|
|
639
|
+
if (ENDED.has(observed.snapshot.rawState)) {
|
|
640
|
+
throw new FlowDebugError('not_paused', { contextId: observed.snapshot.contextId, state: observed.snapshot.rawState });
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
// ----------------------------------------------------------------
|
|
644
|
+
// Cancel
|
|
645
|
+
// ----------------------------------------------------------------
|
|
646
|
+
async postCancel(contextId) {
|
|
647
|
+
try {
|
|
648
|
+
await this.call('post', `operations/flow/context/${contextId}/cancel`, {});
|
|
649
|
+
}
|
|
650
|
+
catch (error) {
|
|
651
|
+
throw this.translate(error, contextId, 'cancel_failed');
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
/** Wait (about 10 seconds at most) for a cancelled context to reach an end state. */
|
|
655
|
+
async settleCancel(contextId) {
|
|
656
|
+
let state = '';
|
|
657
|
+
for (let attempt = 0; attempt < 12; attempt++) {
|
|
658
|
+
await this.sleep(attempt === 0 ? this._poll.firstMs : Math.min(this._poll.maxMs, this._poll.firstMs * (attempt + 1)));
|
|
659
|
+
state = (await this.read(contextId)).state;
|
|
660
|
+
if (ENDED.has(state))
|
|
661
|
+
return state;
|
|
662
|
+
}
|
|
663
|
+
return state;
|
|
664
|
+
}
|
|
665
|
+
/** Child subflow contexts of `parent` that are still open: a paused one first, then newest first. */
|
|
666
|
+
async openChildren(parent) {
|
|
667
|
+
const rows = await this.table('sys_flow_context', `source_table=sys_flow_context^source_record=${parent}^stateIN${OPEN.join(',')}^ORDERBYDESCsys_created_on`, 'sys_id,state', 100);
|
|
668
|
+
const open = rows.filter(row => ID.test(str(row.sys_id)));
|
|
669
|
+
const paused = open.filter(row => str(row.state) === 'PAUSED_IN_DEBUG');
|
|
670
|
+
return [...paused, ...open.filter(row => !paused.includes(row))].map(row => str(row.sys_id));
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Every open context below `root`, at any depth up to MAX_DEPTH. Ended contexts are walked
|
|
674
|
+
* through too: a cancel can end a middle context and leave the one below it paused.
|
|
675
|
+
*/
|
|
676
|
+
async openDescendants(root) {
|
|
677
|
+
const found = [];
|
|
678
|
+
const seen = new Set([root]);
|
|
679
|
+
let level = [root];
|
|
680
|
+
for (let depth = 0; depth < MAX_DEPTH && level.length; depth++) {
|
|
681
|
+
const next = [];
|
|
682
|
+
for (const parent of level) {
|
|
683
|
+
const rows = await this.table('sys_flow_context', `source_table=sys_flow_context^source_record=${parent}^ORDERBYDESCsys_created_on`, 'sys_id,state', 100);
|
|
684
|
+
for (const row of rows) {
|
|
685
|
+
const child = str(row.sys_id);
|
|
686
|
+
if (!ID.test(child) || seen.has(child))
|
|
687
|
+
continue;
|
|
688
|
+
seen.add(child);
|
|
689
|
+
next.push(child);
|
|
690
|
+
if (OPEN.includes(str(row.state)))
|
|
691
|
+
found.push(child);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
level = next;
|
|
695
|
+
}
|
|
696
|
+
return found;
|
|
697
|
+
}
|
|
698
|
+
/** The root of a subflow context's chain; a root is its own. */
|
|
699
|
+
async rootOf(contextId) {
|
|
700
|
+
let current = contextId;
|
|
701
|
+
for (let depth = 0; depth < MAX_DEPTH; depth++) {
|
|
702
|
+
const row = (await this.table('sys_flow_context', `sys_id=${current}`, 'source_table,source_record', 1))[0];
|
|
703
|
+
const parent = str(row?.source_record);
|
|
704
|
+
if (!row || str(row.source_table) !== 'sys_flow_context' || !ID.test(parent))
|
|
705
|
+
return current;
|
|
706
|
+
current = parent;
|
|
707
|
+
}
|
|
708
|
+
return current;
|
|
709
|
+
}
|
|
710
|
+
// ----------------------------------------------------------------
|
|
711
|
+
// HTTP
|
|
712
|
+
// ----------------------------------------------------------------
|
|
713
|
+
async read(contextId) {
|
|
714
|
+
let reply;
|
|
715
|
+
try {
|
|
716
|
+
// Sensitive: the reply holds the run's data, which must not reach a debug log.
|
|
717
|
+
reply = await this.call('get', `operations/flow/context/${contextId}`, undefined, { sensitive: true });
|
|
718
|
+
}
|
|
719
|
+
catch (error) {
|
|
720
|
+
throw this.translate(error, contextId, 'unexpected_response');
|
|
721
|
+
}
|
|
722
|
+
const raw = obj(reply.result);
|
|
723
|
+
const flowContext = obj(raw.flowContext);
|
|
724
|
+
if (!raw.flowContext)
|
|
725
|
+
throw new FlowDebugError('unexpected_response', { contextId });
|
|
726
|
+
return { contextId, raw, flowContext, report: obj(raw.flowReport), state: str(flowContext.state) };
|
|
727
|
+
}
|
|
728
|
+
async debugCall(method, path, contextId, body) {
|
|
729
|
+
try {
|
|
730
|
+
return await this.call(method, `debug/${path}`, body);
|
|
731
|
+
}
|
|
732
|
+
catch (error) {
|
|
733
|
+
throw this.translate(error, contextId, 'unexpected_response');
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
async call(method, path, body, options = {}) {
|
|
737
|
+
const request = {
|
|
738
|
+
path: `${PROCESSFLOW_API_BASE}/${path}`,
|
|
739
|
+
method,
|
|
740
|
+
headers: HEADERS,
|
|
741
|
+
query: options.query ?? GLOBAL,
|
|
742
|
+
body: null,
|
|
743
|
+
...(method === 'post' ? { json: (body ?? {}) } : {}),
|
|
744
|
+
...(options.sensitive ? { sensitive: true } : {}),
|
|
745
|
+
};
|
|
746
|
+
const response = method === 'get' ? await this._request.get(request) : await this._request.post(request);
|
|
747
|
+
return obj(response.bodyObject ?? response.data);
|
|
748
|
+
}
|
|
749
|
+
async table(table, query, fields, limit) {
|
|
750
|
+
const response = await this._request.get({ path: `/api/now/table/${table}`, method: 'get', headers: null, body: null,
|
|
751
|
+
query: { sysparm_query: query, sysparm_fields: fields, sysparm_limit: String(limit), sysparm_exclude_reference_link: 'true' } });
|
|
752
|
+
return arr(obj(response.bodyObject ?? response.data).result).map(obj);
|
|
753
|
+
}
|
|
754
|
+
/**
|
|
755
|
+
* Turn a failed call into a FlowDebugError. Policy refusals, session errors and anything that
|
|
756
|
+
* is not an HTTP failure pass through untouched.
|
|
757
|
+
*/
|
|
758
|
+
translate(error, contextId, fallback) {
|
|
759
|
+
if (isPolicyRefusal(error) || error instanceof FlowDebugError || !(error instanceof Error))
|
|
760
|
+
return error;
|
|
761
|
+
const match = /Status: (\d{3}) Body: ([\s\S]*)$/.exec(error.message) ?? /status (\d{3})/i.exec(error.message);
|
|
762
|
+
if (!match)
|
|
763
|
+
return error;
|
|
764
|
+
const status = Number(match[1]);
|
|
765
|
+
let serverMessage = '';
|
|
766
|
+
try {
|
|
767
|
+
const parsed = obj(JSON.parse(match[2] ?? ''));
|
|
768
|
+
serverMessage = str(obj(parsed.result).message) || str(obj(parsed.error).message);
|
|
769
|
+
}
|
|
770
|
+
catch {
|
|
771
|
+
serverMessage = '';
|
|
772
|
+
}
|
|
773
|
+
const detail = { contextId, status, ...(serverMessage ? { serverMessage } : {}) };
|
|
774
|
+
if (/not in 'PAUSED IN DEBUG' state/i.test(serverMessage))
|
|
775
|
+
return new FlowDebugError('not_paused', detail);
|
|
776
|
+
if (/not started in debug mode/i.test(serverMessage))
|
|
777
|
+
return new FlowDebugError('not_a_debug_session', detail);
|
|
778
|
+
if (/invalid sys_id/i.test(serverMessage) || /does not exist/i.test(serverMessage) || status === 404) {
|
|
779
|
+
return new FlowDebugError('context_not_found', detail);
|
|
780
|
+
}
|
|
781
|
+
if (status === 400 && /does not represent any resource/i.test(serverMessage))
|
|
782
|
+
return new FlowDebugError('release_unsupported', detail);
|
|
783
|
+
if (status === 401 || status === 403)
|
|
784
|
+
return new FlowDebugError('permission_denied', detail);
|
|
785
|
+
return new FlowDebugError(fallback, detail);
|
|
786
|
+
}
|
|
787
|
+
// ----------------------------------------------------------------
|
|
788
|
+
// Arguments
|
|
789
|
+
// ----------------------------------------------------------------
|
|
790
|
+
id(contextId) {
|
|
791
|
+
const id = str(contextId).trim().toLowerCase();
|
|
792
|
+
if (!ID.test(id))
|
|
793
|
+
throw new FlowDebugError('invalid_input');
|
|
794
|
+
return id;
|
|
795
|
+
}
|
|
796
|
+
refs(steps) {
|
|
797
|
+
if (!Array.isArray(steps))
|
|
798
|
+
throw new FlowDebugError('invalid_input');
|
|
799
|
+
return steps.map(step => {
|
|
800
|
+
if (typeof step === 'number' && Number.isInteger(step) && step > 0)
|
|
801
|
+
return step;
|
|
802
|
+
const ref = obj(step);
|
|
803
|
+
if (typeof ref.line === 'number' && Number.isInteger(ref.line) && ref.line > 0)
|
|
804
|
+
return { line: ref.line };
|
|
805
|
+
if (typeof ref.flowElementId === 'string' && /^[a-z0-9-]{1,120}$/i.test(ref.flowElementId))
|
|
806
|
+
return { flowElementId: ref.flowElementId };
|
|
807
|
+
throw new FlowDebugError('invalid_input');
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
timeout(value) {
|
|
811
|
+
if (value === undefined)
|
|
812
|
+
return DEFAULT_TIMEOUT_MS;
|
|
813
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > 3_600_000)
|
|
814
|
+
throw new FlowDebugError('invalid_input');
|
|
815
|
+
return value;
|
|
816
|
+
}
|
|
817
|
+
/** Overridable in tests. */
|
|
818
|
+
sleep(ms) {
|
|
819
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
//# sourceMappingURL=FlowDebugger.js.map
|