callman-core 1.28.0 → 1.28.1
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/ai-packs/scenario-nodes.md +451 -0
- package/dist/ai/index.d.ts +1 -0
- package/dist/ai/index.d.ts.map +1 -1
- package/dist/ai/index.js +1 -0
- package/dist/ai/index.js.map +1 -1
- package/dist/ai/prompts/scenario.d.ts.map +1 -1
- package/dist/ai/prompts/scenario.js +9 -8
- package/dist/ai/prompts/scenario.js.map +1 -1
- package/dist/ai/prompts/scenarioNodeReference.d.ts +16 -0
- package/dist/ai/prompts/scenarioNodeReference.d.ts.map +1 -0
- package/dist/ai/prompts/scenarioNodeReference.js +882 -0
- package/dist/ai/prompts/scenarioNodeReference.js.map +1 -0
- package/dist/http-file/flatImport.d.ts +1 -0
- package/dist/http-file/flatImport.d.ts.map +1 -1
- package/dist/http-file/flatImport.js.map +1 -1
- package/dist/http-file/index.d.ts +1 -1
- package/dist/http-file/index.d.ts.map +1 -1
- package/dist/http-file/index.js +1 -1
- package/dist/http-file/index.js.map +1 -1
- package/dist/http-file/parseHttpFile.d.ts +1 -0
- package/dist/http-file/parseHttpFile.d.ts.map +1 -1
- package/dist/http-file/parseHttpFile.js +40 -8
- package/dist/http-file/parseHttpFile.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/request/requestBody.d.ts +55 -0
- package/dist/request/requestBody.d.ts.map +1 -0
- package/dist/request/requestBody.js +198 -0
- package/dist/request/requestBody.js.map +1 -0
- package/dist/runner/requestRunner.d.ts.map +1 -1
- package/dist/runner/requestRunner.js +127 -43
- package/dist/runner/requestRunner.js.map +1 -1
- package/dist/runner/runCollection.d.ts +1 -1
- package/dist/runner/runCollection.d.ts.map +1 -1
- package/dist/runner/runCollection.js +9 -2
- package/dist/runner/runCollection.js.map +1 -1
- package/dist/scenario-runner/adapters/helpers/request-helpers.d.ts +2 -1
- package/dist/scenario-runner/adapters/helpers/request-helpers.d.ts.map +1 -1
- package/dist/scenario-runner/adapters/helpers/request-helpers.js +8 -40
- package/dist/scenario-runner/adapters/helpers/request-helpers.js.map +1 -1
- package/dist/scenario-runner/adapters/request-executor.d.ts.map +1 -1
- package/dist/scenario-runner/adapters/request-executor.js +2 -29
- package/dist/scenario-runner/adapters/request-executor.js.map +1 -1
- package/dist/scenario-runner/index.d.ts +1 -0
- package/dist/scenario-runner/index.d.ts.map +1 -1
- package/dist/scenario-runner/index.js +1 -0
- package/dist/scenario-runner/index.js.map +1 -1
- package/dist/scenario-runner/labels.d.ts +3 -0
- package/dist/scenario-runner/labels.d.ts.map +1 -0
- package/dist/scenario-runner/labels.js +20 -0
- package/dist/scenario-runner/labels.js.map +1 -0
- package/dist/scenario-runner/runScenario.d.ts.map +1 -1
- package/dist/scenario-runner/runScenario.js +4 -11
- package/dist/scenario-runner/runScenario.js.map +1 -1
- package/dist/script/legacy.d.ts.map +1 -1
- package/dist/script/legacy.js +24 -9
- package/dist/script/legacy.js.map +1 -1
- package/dist/types/index.d.ts +18 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/variables/resolver.d.ts.map +1 -1
- package/dist/variables/resolver.js +27 -5
- package/dist/variables/resolver.js.map +1 -1
- package/dist-cjs/ai/index.js +4 -1
- package/dist-cjs/ai/index.js.map +1 -1
- package/dist-cjs/ai/prompts/scenario.js +9 -8
- package/dist-cjs/ai/prompts/scenario.js.map +1 -1
- package/dist-cjs/ai/prompts/scenarioNodeReference.js +886 -0
- package/dist-cjs/ai/prompts/scenarioNodeReference.js.map +1 -0
- package/dist-cjs/http-file/flatImport.js.map +1 -1
- package/dist-cjs/http-file/index.js +2 -1
- package/dist-cjs/http-file/index.js.map +1 -1
- package/dist-cjs/http-file/parseHttpFile.js +42 -9
- package/dist-cjs/http-file/parseHttpFile.js.map +1 -1
- package/dist-cjs/index.js +9 -1
- package/dist-cjs/index.js.map +1 -1
- package/dist-cjs/request/requestBody.js +206 -0
- package/dist-cjs/request/requestBody.js.map +1 -0
- package/dist-cjs/runner/requestRunner.js +127 -43
- package/dist-cjs/runner/requestRunner.js.map +1 -1
- package/dist-cjs/runner/runCollection.js +9 -2
- package/dist-cjs/runner/runCollection.js.map +1 -1
- package/dist-cjs/scenario-runner/adapters/helpers/request-helpers.js +7 -42
- package/dist-cjs/scenario-runner/adapters/helpers/request-helpers.js.map +1 -1
- package/dist-cjs/scenario-runner/adapters/request-executor.js +3 -30
- package/dist-cjs/scenario-runner/adapters/request-executor.js.map +1 -1
- package/dist-cjs/scenario-runner/index.js +4 -1
- package/dist-cjs/scenario-runner/index.js.map +1 -1
- package/dist-cjs/scenario-runner/labels.js +24 -0
- package/dist-cjs/scenario-runner/labels.js.map +1 -0
- package/dist-cjs/scenario-runner/runScenario.js +4 -11
- package/dist-cjs/scenario-runner/runScenario.js.map +1 -1
- package/dist-cjs/script/legacy.js +24 -9
- package/dist-cjs/script/legacy.js.map +1 -1
- package/dist-cjs/variables/resolver.js +27 -5
- package/dist-cjs/variables/resolver.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,882 @@
|
|
|
1
|
+
// Host-neutral scenario node reference (markdown) for clients that write
|
|
2
|
+
// scenarios through the REST API or an MCP tool — NOT the in-app assistant's
|
|
3
|
+
// system prompt (`buildScenarioSystemPrompt`), which demands a JSON-only reply,
|
|
4
|
+
// teaches the builder's node envelope and refers to context only the desktop
|
|
5
|
+
// sends (AVAILABLE CONNECTIONS, importRequestId).
|
|
6
|
+
//
|
|
7
|
+
// Every statement is checked against the two places that decide what a
|
|
8
|
+
// scenario can be: the backend's save-time validation
|
|
9
|
+
// (callman-backend/src/modules/scenario/scenario.validation.ts + scenario.graph.ts)
|
|
10
|
+
// and this package's run engine (scenario-runner/*). Defaults, limits,
|
|
11
|
+
// operators, Redis commands, contract rules and reserved labels are read from
|
|
12
|
+
// the same runtime values the engine and the in-app prompt use, so they cannot
|
|
13
|
+
// drift; the prose is kept in step by tests/scenario-node-reference.test.mjs.
|
|
14
|
+
import { CONDITION_OPERATOR_META } from "../../scenario-runner/condition.js";
|
|
15
|
+
import { DEFAULT_AI_MAX_TOKENS, DEFAULT_AI_TIMEOUT_MS, DEFAULT_FOREACH_MAX_ITERATIONS, DEFAULT_WS_CLOSE_CODE, DEFAULT_WS_CONNECT_TIMEOUT_MS, DEFAULT_WS_EXPECT_TIMEOUT_MS, MAX_AI_MAX_TOKENS, MAX_AI_PROMPT_LENGTH, MAX_AI_RESPONSE_EXAMPLE_LENGTH, MAX_AI_TIMEOUT_MS, MAX_END_LOOP_ITERATIONS, MAX_FOREACH_ITERATIONS, MAX_POLL_ATTEMPTS, MAX_WS_CONNECT_TIMEOUT_MS, MAX_WS_EXPECT_TIMEOUT_MS, MAX_WS_STEPS, MAX_WS_WAIT_DURATION_MS, } from "../../scenario-runner/graphTraversal.js";
|
|
16
|
+
import { RESERVED_SCENARIO_LABELS } from "../../scenario-runner/labels.js";
|
|
17
|
+
import { REDIS_COMMAND_SPECS } from "../../host/redis/redisService.js";
|
|
18
|
+
import { RESERVED_LABEL_NAMES } from "../../script/scope.js";
|
|
19
|
+
import { CONTRACT_RULES_SPEC } from "./fragments.js";
|
|
20
|
+
import { defaultScenarioNodeConfig } from "./scenarioNodeDefaults.js";
|
|
21
|
+
// Every node type the backend stores, in reading order. `satisfies` + the
|
|
22
|
+
// exhaustiveness check below make a new ScenarioNodeType a compile error here
|
|
23
|
+
// until it is documented.
|
|
24
|
+
const NODE_TYPE_ORDER = [
|
|
25
|
+
"request",
|
|
26
|
+
"db",
|
|
27
|
+
"redis",
|
|
28
|
+
"kafka",
|
|
29
|
+
"condition",
|
|
30
|
+
"assertion",
|
|
31
|
+
"script",
|
|
32
|
+
"set-vars",
|
|
33
|
+
"poll",
|
|
34
|
+
"foreach",
|
|
35
|
+
"websocket",
|
|
36
|
+
"ai",
|
|
37
|
+
"wait",
|
|
38
|
+
"notification",
|
|
39
|
+
"aggregator",
|
|
40
|
+
"end",
|
|
41
|
+
"subscenario",
|
|
42
|
+
"scenario-ref",
|
|
43
|
+
];
|
|
44
|
+
const assertEveryTypeDocumented = true;
|
|
45
|
+
void assertEveryTypeDocumented;
|
|
46
|
+
// Built-in template/operand roots (condition.ts createRuntimeRoots,
|
|
47
|
+
// templateResolver.ts buildScenarioTemplateValues). Node outputs share the
|
|
48
|
+
// same namespace, so a node labelled with one of these and the root shadow
|
|
49
|
+
// each other (which one wins differs between templates and rule operands).
|
|
50
|
+
const BUILT_IN_ROOTS = [
|
|
51
|
+
"response",
|
|
52
|
+
"lastResponse",
|
|
53
|
+
"data",
|
|
54
|
+
"env",
|
|
55
|
+
"global",
|
|
56
|
+
"globals",
|
|
57
|
+
"db",
|
|
58
|
+
"redis",
|
|
59
|
+
"kafkaEvent",
|
|
60
|
+
"kafkaevent",
|
|
61
|
+
];
|
|
62
|
+
const code = (value) => `\`${value}\``;
|
|
63
|
+
const KV_ROWS = "{key, value, enabled?}[]";
|
|
64
|
+
const getPath = (source, path) => {
|
|
65
|
+
let current = source;
|
|
66
|
+
for (const segment of path.split(".")) {
|
|
67
|
+
if (current === null || typeof current !== "object" || Array.isArray(current)) {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
current = current[segment];
|
|
71
|
+
}
|
|
72
|
+
return current;
|
|
73
|
+
};
|
|
74
|
+
const formatDefault = (value) => {
|
|
75
|
+
if (value === undefined)
|
|
76
|
+
return "—";
|
|
77
|
+
const json = JSON.stringify(value);
|
|
78
|
+
return code(json.length <= 60 ? json : `${json.slice(0, 57)}…`);
|
|
79
|
+
};
|
|
80
|
+
const formatPolicyDefault = (policy) => {
|
|
81
|
+
const onFailure = getPath(policy, "onFailure");
|
|
82
|
+
if (typeof onFailure !== "string")
|
|
83
|
+
return "stop, no retry (engine)";
|
|
84
|
+
const retryEnabled = getPath(policy, "retry.enabled") === true;
|
|
85
|
+
return retryEnabled
|
|
86
|
+
? `${onFailure}, retry ×${String(getPath(policy, "retry.maxAttempts"))} every ${String(getPath(policy, "retry.delayMs"))} ms`
|
|
87
|
+
: `${onFailure}, no retry`;
|
|
88
|
+
};
|
|
89
|
+
// Markdown table cells: a literal `|` would end the cell.
|
|
90
|
+
const cell = (value) => value.replace(/\|/g, "\\|").replace(/\n/g, " ");
|
|
91
|
+
const renderFieldTable = (node, defaults) => {
|
|
92
|
+
const rows = node.fields.map((field) => {
|
|
93
|
+
const shown = field.shownDefault ??
|
|
94
|
+
(field.field.includes("[]") ? "—" : formatDefault(getPath(defaults, field.field)));
|
|
95
|
+
return `| ${cell(code(field.field))} | ${cell(field.type)} | ${cell(shown)} | ${field.req ?? ""} | ${cell(field.notes ?? "")} |`;
|
|
96
|
+
});
|
|
97
|
+
if (node.type !== "condition") {
|
|
98
|
+
rows.push(`| ${code("executionPolicy")} | ExecutionPolicy | ${cell(formatPolicyDefault(getPath(defaults, "executionPolicy")))} | | See Execution policy. |`);
|
|
99
|
+
}
|
|
100
|
+
return ["| Field | Type | Default | Req | Notes |", "| --- | --- | --- | --- | --- |", ...rows].join("\n");
|
|
101
|
+
};
|
|
102
|
+
// One line per example: models read it as well as a pretty-printed block,
|
|
103
|
+
// at a fraction of the size.
|
|
104
|
+
const renderExample = (example) => ["```json", JSON.stringify(example), "```"].join("\n");
|
|
105
|
+
const unaryOperators = CONDITION_OPERATOR_META.filter((entry) => entry.isUnary).map((entry) => entry.value);
|
|
106
|
+
const binaryOperators = CONDITION_OPERATOR_META.filter((entry) => !entry.isUnary).map((entry) => entry.value);
|
|
107
|
+
const NODE_DOCS = [
|
|
108
|
+
{
|
|
109
|
+
type: "request",
|
|
110
|
+
summary: "One HTTP call.",
|
|
111
|
+
fields: [
|
|
112
|
+
{ field: "method", type: "string", notes: "HTTP verb." },
|
|
113
|
+
{
|
|
114
|
+
field: "url",
|
|
115
|
+
type: "string (≤10000)",
|
|
116
|
+
req: "run",
|
|
117
|
+
notes: "Absolute URL; `{{…}}` allowed. Its own `?query` is used only when `query` has no enabled rows.",
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
field: "headers",
|
|
121
|
+
type: KV_ROWS,
|
|
122
|
+
notes: "Disabled rows are not sent. A Content-Type row beats the automatic one (formdata always uses the driver's multipart type).",
|
|
123
|
+
},
|
|
124
|
+
{ field: "query", type: KV_ROWS },
|
|
125
|
+
{ field: "body.mode", type: "none | raw | formdata | urlencoded" },
|
|
126
|
+
{
|
|
127
|
+
field: "body.raw",
|
|
128
|
+
type: "string",
|
|
129
|
+
notes: "mode raw. JSON text that still parses after templating is sent as JSON.",
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
field: "body.rawLanguage",
|
|
133
|
+
type: "json | xml | text | html",
|
|
134
|
+
notes: "With no Content-Type header, sets application/json, application/xml, text/plain or text/html (json when absent).",
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
field: "body.formEntries",
|
|
138
|
+
type: "{key, value, enabled?, type?, fixtureId?, fileName?}[]",
|
|
139
|
+
notes: 'formdata / urlencoded rows. A `type:"file"` row needs `fixtureId` (an uploaded workspace fixture file) — server runs fail it otherwise.',
|
|
140
|
+
},
|
|
141
|
+
{ field: "auth.type", type: "none | bearer | basic | apikey | oauth2" },
|
|
142
|
+
{
|
|
143
|
+
field: "auth.bearerToken",
|
|
144
|
+
type: "string",
|
|
145
|
+
notes: "bearer → `Authorization: Bearer <token>`.",
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
field: "auth.basicUsername / auth.basicPassword",
|
|
149
|
+
type: "string",
|
|
150
|
+
shownDefault: '`""`',
|
|
151
|
+
notes: "basic → base64 `user:password`.",
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
field: "auth.apiKeyKey / auth.apiKeyValue",
|
|
155
|
+
type: "string",
|
|
156
|
+
shownDefault: '`""`',
|
|
157
|
+
notes: "apikey → sends `key: value`; skipped when the key is blank.",
|
|
158
|
+
},
|
|
159
|
+
{ field: "auth.apiKeyLocation", type: "header | query" },
|
|
160
|
+
{
|
|
161
|
+
field: "auth.oauth2",
|
|
162
|
+
type: "{grantType, tokenUrl, clientId, clientSecret, scope, username, password, refreshToken, clientAuth, audience}",
|
|
163
|
+
notes: 'oauth2: when present ALL ten keys are required (send "" for unused ones); grantType client_credentials | password | refresh_token; clientAuth basic | body. The token is fetched (and cached) before the call and sent as a Bearer header. Without it, type oauth2 sends no Authorization.',
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
field: "preRequestScript / postResponseScript / testsScript",
|
|
167
|
+
type: "string",
|
|
168
|
+
shownDefault: '`""`',
|
|
169
|
+
notes: "`pm.*` scripts: before the call / after the response / assertions (`pm.test`).",
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
field: "contractAssertions",
|
|
173
|
+
type: "{id, path, enabled, rules, engine?, namespaces?}[] (≤200)",
|
|
174
|
+
notes: 'id, path, enabled and rules are required; `rules` needs ≥1 key (see Contract rules). `path` is a dot-path over the parsed body (`data.items[0].id`, `items[].id` = every element); with engine "xpath" it is an XPath over the raw XML and every prefix must be in `namespaces` {prefix: uri}.',
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
field: "expectedStatus",
|
|
178
|
+
type: "string (≤200)",
|
|
179
|
+
notes: 'Negative testing: `"201"`, `"4xx"`, `"200-299"`, `"200,204"`, templates allowed. When set the node passes iff the status matches (instead of 2xx/3xx); contract assertions and test scripts can still fail it.',
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
field: "responseSchema",
|
|
183
|
+
type: "JSON Schema object",
|
|
184
|
+
notes: "Validated against the response body; a mismatch fails the node.",
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
field: "clientCertificate",
|
|
188
|
+
type: "{enabled, format, pfxBase64?, certPem?, keyPem?, passphrase?, caPem?, rejectUnauthorized?}",
|
|
189
|
+
notes: 'mTLS. enabled and format ("pfx" | "pem") are required; pfx uses pfxBase64, pem uses certPem + keyPem. Server runs present the certificate whenever this object is set — omit it to send none.',
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
field: "soap",
|
|
193
|
+
type: "{enabled, version, action?, wsSecurity?}",
|
|
194
|
+
notes: 'version "1.1" | "1.2". Keep the full envelope in body.raw (rawLanguage "xml"); Content-Type/SOAPAction come from the version (a Content-Type header wins). wsSecurity {enabled, username, password, passwordType: text | digest} injects a UsernameToken at send time.',
|
|
195
|
+
},
|
|
196
|
+
],
|
|
197
|
+
output: "`<Label>.status` (number), `<Label>.body.<path>`, `<Label>.headers`, `<Label>.text`, `<Label>.durationMs`. The most recent request is also `response.*`.",
|
|
198
|
+
example: {
|
|
199
|
+
id: "login",
|
|
200
|
+
type: "request",
|
|
201
|
+
label: "Login",
|
|
202
|
+
config: {
|
|
203
|
+
method: "POST",
|
|
204
|
+
url: "{{env.baseUrl}}/login",
|
|
205
|
+
body: {
|
|
206
|
+
mode: "raw",
|
|
207
|
+
raw: '{"user":"{{env.user}}","password":"{{env.password}}"}',
|
|
208
|
+
rawLanguage: "json",
|
|
209
|
+
},
|
|
210
|
+
expectedStatus: "200",
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
type: "db",
|
|
216
|
+
summary: "One query on a workspace connection of type `database`.",
|
|
217
|
+
fields: [
|
|
218
|
+
{
|
|
219
|
+
field: "connectionId",
|
|
220
|
+
type: "string",
|
|
221
|
+
req: "run",
|
|
222
|
+
notes: '`""` = not chosen yet: saved, but the run fails.',
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
field: "query",
|
|
226
|
+
type: "string (1..100000)",
|
|
227
|
+
req: "yes",
|
|
228
|
+
notes: "Mongo: a JSON command-object string (`{\"collection\":\"orders\",\"operation\":\"findOne\",\"filter\":{…}}`); SQL: plain SQL. Per-engine details: the DB query reference. The default is a placeholder — always send your own.",
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
field: "params",
|
|
232
|
+
type: "string[] (≤200)",
|
|
233
|
+
notes: "Positional binds, template-resolved: `$1` (Postgres), `?` (MySQL), `@p1…` (SQL Server).",
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
field: "typedParams",
|
|
237
|
+
type: "{value, type?}[] (≤200)",
|
|
238
|
+
notes: "Wins over params. type auto | text | int | numeric | boolean | date | timestamp | json | null: null binds SQL NULL, boolean a real boolean, the rest stay strings (cast in SQL).",
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
field: "useSession",
|
|
242
|
+
type: "boolean",
|
|
243
|
+
notes: "Postgres only: join the run's shared session (scenario-level `dbSettings.transactionMode`: none | rollback | commit).",
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
field: "expectError",
|
|
247
|
+
type: "{sqlState?, messagePattern?}",
|
|
248
|
+
notes: "Negative test: the node passes only if the query FAILS with that SQLSTATE and/or a message matching the regex.",
|
|
249
|
+
},
|
|
250
|
+
],
|
|
251
|
+
output: "`<Label>.rows`, `<Label>.rowCount`; the rows are also `db.result`.",
|
|
252
|
+
example: {
|
|
253
|
+
id: "check",
|
|
254
|
+
type: "db",
|
|
255
|
+
label: "CheckOrder",
|
|
256
|
+
config: {
|
|
257
|
+
connectionId: "<database connection id>",
|
|
258
|
+
query: "SELECT status FROM orders WHERE id = $1",
|
|
259
|
+
params: ["{{CreateOrder.body.id}}"],
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
type: "redis",
|
|
265
|
+
summary: "Redis commands on a workspace connection of type `redis`.",
|
|
266
|
+
fields: [
|
|
267
|
+
{ field: "connectionId", type: "string", req: "run" },
|
|
268
|
+
{
|
|
269
|
+
field: "commands",
|
|
270
|
+
type: "string[] (≤1000, each non-empty)",
|
|
271
|
+
req: "run",
|
|
272
|
+
notes: `One command per entry, template-resolved. Supported: ${Object.keys(REDIS_COMMAND_SPECS).join(", ")}. \`SCAN pattern [count]\` returns {keys, count, truncated}. An empty list fails the run.`,
|
|
273
|
+
},
|
|
274
|
+
],
|
|
275
|
+
output: "The reply (one command) or an array of replies, as `<Label>` and `redis.result`.",
|
|
276
|
+
example: {
|
|
277
|
+
id: "cache",
|
|
278
|
+
type: "redis",
|
|
279
|
+
label: "Cache",
|
|
280
|
+
config: {
|
|
281
|
+
connectionId: "<redis connection id>",
|
|
282
|
+
commands: ["GET order:{{CreateOrder.body.id}}"],
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
type: "kafka",
|
|
288
|
+
summary: "Consume (wait for a matching message) or publish, on a connection of type `kafka`.",
|
|
289
|
+
fields: [
|
|
290
|
+
{ field: "connectionId", type: "string", req: "run" },
|
|
291
|
+
{ field: "topic", type: "string (1..500)", req: "yes" },
|
|
292
|
+
{ field: "mode", type: "consume | publish" },
|
|
293
|
+
{ field: "matchMode", type: "regex | rules", notes: "consume." },
|
|
294
|
+
{
|
|
295
|
+
field: "rules",
|
|
296
|
+
type: "string[]",
|
|
297
|
+
notes: "consume. regex: rules[0] is a regex tested against the whole message. rules: every entry is ONE comparison `left op right` (op ==, !=, ===, !==, >, <, >=, <=, contains) and all must hold, e.g. `kafkaEvent.orderId == '{{CreateOrder.body.id}}'`. Empty = the first message captured.",
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
field: "publishValueFormat",
|
|
301
|
+
type: "json | raw",
|
|
302
|
+
notes: "publish. json fails the node when messageValue is not valid JSON after templating.",
|
|
303
|
+
},
|
|
304
|
+
{ field: "messageKey", type: "string", notes: "publish." },
|
|
305
|
+
{ field: "messageValue", type: "string (≤1000000)", notes: "publish payload, template-resolved." },
|
|
306
|
+
{ field: "messageHeaders", type: KV_ROWS, notes: "publish." },
|
|
307
|
+
],
|
|
308
|
+
output: "consume: the matched message — a JSON message's keys at the top level plus `meta` {value, text, key, partition, offset, timestamp, headers}; also `kafkaEvent.*`.",
|
|
309
|
+
example: {
|
|
310
|
+
id: "event",
|
|
311
|
+
type: "kafka",
|
|
312
|
+
label: "OrderCreated",
|
|
313
|
+
config: {
|
|
314
|
+
connectionId: "<kafka connection id>",
|
|
315
|
+
topic: "orders",
|
|
316
|
+
mode: "consume",
|
|
317
|
+
matchMode: "rules",
|
|
318
|
+
rules: ["kafkaEvent.orderId == '{{CreateOrder.body.id}}'"],
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
type: "condition",
|
|
324
|
+
summary: "The only branching node and the only node without `executionPolicy`. Needs exactly two outgoing edges: one `yes`, one `no`.",
|
|
325
|
+
fields: [
|
|
326
|
+
{ field: "mode", type: "builder | expert" },
|
|
327
|
+
{
|
|
328
|
+
field: "rootGroup",
|
|
329
|
+
type: "Group",
|
|
330
|
+
shownDefault: "one rule: `response.status` equals `200`",
|
|
331
|
+
notes: "Evaluated in builder mode (see Rules and operands). Stored in both modes.",
|
|
332
|
+
},
|
|
333
|
+
{
|
|
334
|
+
field: "expertExpression",
|
|
335
|
+
type: "string | null",
|
|
336
|
+
notes: 'Used ONLY with `mode: "expert"` (ignored otherwise). Syntax below.',
|
|
337
|
+
},
|
|
338
|
+
],
|
|
339
|
+
output: "Takes the `yes` or `no` edge.",
|
|
340
|
+
notes: [
|
|
341
|
+
"Expert syntax: operands are `{{path}}` tokens (a bare path is a parse error), 'single'- or \"double\"-quoted strings, numbers, true / false / null and [arrays]; operators ==, !=, ===, !==, >, <, >=, <=, contains, not contains, starts with, ends with, regex, in, not in, between, exists, not exists, is empty, not empty, is null, not null, contains row, set equals; combine with && / AND, || / OR and parentheses. Example: `{{Login.status}} == 200 && {{Login.body.token}} != ''`.",
|
|
342
|
+
],
|
|
343
|
+
example: {
|
|
344
|
+
id: "ok",
|
|
345
|
+
type: "condition",
|
|
346
|
+
label: "LoggedIn",
|
|
347
|
+
config: {
|
|
348
|
+
mode: "builder",
|
|
349
|
+
rootGroup: {
|
|
350
|
+
id: "g1",
|
|
351
|
+
type: "group",
|
|
352
|
+
operator: "AND",
|
|
353
|
+
children: [
|
|
354
|
+
{
|
|
355
|
+
id: "r1",
|
|
356
|
+
type: "rule",
|
|
357
|
+
left: { mode: "runtime", valueType: "number", value: "Login.status" },
|
|
358
|
+
operator: "equals",
|
|
359
|
+
right: { mode: "literal", valueType: "number", value: 200 },
|
|
360
|
+
},
|
|
361
|
+
],
|
|
362
|
+
},
|
|
363
|
+
},
|
|
364
|
+
},
|
|
365
|
+
},
|
|
366
|
+
{
|
|
367
|
+
type: "assertion",
|
|
368
|
+
summary: "Pass/fail checkpoint; does not branch (on failure the execution policy decides).",
|
|
369
|
+
fields: [
|
|
370
|
+
{ field: "matchMode", type: "all | any" },
|
|
371
|
+
{
|
|
372
|
+
field: "checks",
|
|
373
|
+
type: "{id, name, enabled, rule}[] (≤1000)",
|
|
374
|
+
req: "run",
|
|
375
|
+
shownDefault: "one blank check",
|
|
376
|
+
notes: "id, name (may be \"\"), enabled and rule are required; `rule` is a full Rule. With no enabled check the node passes without testing anything — send at least one.",
|
|
377
|
+
},
|
|
378
|
+
],
|
|
379
|
+
output: "`<Label>.passed`, `.passedCount`, `.failedCount`, `.checks[i]` {name, passed, message}.",
|
|
380
|
+
example: {
|
|
381
|
+
id: "verify",
|
|
382
|
+
type: "assertion",
|
|
383
|
+
label: "VerifyOrder",
|
|
384
|
+
config: {
|
|
385
|
+
checks: [
|
|
386
|
+
{
|
|
387
|
+
id: "c1",
|
|
388
|
+
name: "total is 40",
|
|
389
|
+
enabled: true,
|
|
390
|
+
rule: {
|
|
391
|
+
id: "c1r",
|
|
392
|
+
type: "rule",
|
|
393
|
+
left: { mode: "runtime", valueType: "number", value: "CreateOrder.body.total" },
|
|
394
|
+
operator: "equals",
|
|
395
|
+
right: { mode: "literal", valueType: "number", value: 40 },
|
|
396
|
+
},
|
|
397
|
+
},
|
|
398
|
+
],
|
|
399
|
+
},
|
|
400
|
+
},
|
|
401
|
+
},
|
|
402
|
+
{
|
|
403
|
+
type: "script",
|
|
404
|
+
summary: "JavaScript in the `pm.*` sandbox.",
|
|
405
|
+
fields: [
|
|
406
|
+
{
|
|
407
|
+
field: "script",
|
|
408
|
+
type: "string (≤1000000)",
|
|
409
|
+
req: "run",
|
|
410
|
+
notes: "Its `return` value is the node's output. Can `pm.environment.set`, `pm.test`, `await pm.db.query(…)` / `pm.redis.command(…)`. Empty or invalid code fails the run.",
|
|
411
|
+
},
|
|
412
|
+
{ field: "timeoutMs", type: "integer 1..60000", notes: "Also capped by the host's per-node timeout." },
|
|
413
|
+
],
|
|
414
|
+
output: "The returned value (`<Label>.<key>` for an object).",
|
|
415
|
+
example: {
|
|
416
|
+
id: "calc",
|
|
417
|
+
type: "script",
|
|
418
|
+
label: "Totals",
|
|
419
|
+
config: { script: "const body = CreateOrder.body;\nreturn { total: body.total * 2 };" },
|
|
420
|
+
},
|
|
421
|
+
},
|
|
422
|
+
{
|
|
423
|
+
type: "set-vars",
|
|
424
|
+
summary: "Write environment/global variables without a script. Rows apply top to bottom (a row sees earlier rows' writes).",
|
|
425
|
+
fields: [
|
|
426
|
+
{
|
|
427
|
+
field: "assignments",
|
|
428
|
+
type: "Assignment[] (≤200)",
|
|
429
|
+
shownDefault: "one blank row",
|
|
430
|
+
},
|
|
431
|
+
{ field: "assignments[].id / .key / .value", type: "string", req: "yes", notes: 'key and value are template-resolved; value may be "".' },
|
|
432
|
+
{ field: "assignments[].scope", type: "environment | global", req: "yes" },
|
|
433
|
+
{ field: "assignments[].enabled", type: "boolean", req: "yes" },
|
|
434
|
+
{
|
|
435
|
+
field: "assignments[].extractorType",
|
|
436
|
+
type: "template | jsonpath | regex",
|
|
437
|
+
notes: "jsonpath / regex: resolve `source` (a bare runtime path such as `CreateOrder.body`), then apply `expression` (JSONPath like `$.items[0].id`, or a JS regex source); `value` is ignored. Regex rows take `flags` (subset of gimsuy) and `group`; `select` first | all (all writes a JSON array). A failed extraction skips that write.",
|
|
438
|
+
},
|
|
439
|
+
],
|
|
440
|
+
output: "`<Label>.environment` / `<Label>.globals` (what was written), `<Label>.extraction` for extractor rows.",
|
|
441
|
+
example: {
|
|
442
|
+
id: "save",
|
|
443
|
+
type: "set-vars",
|
|
444
|
+
label: "SaveOrderId",
|
|
445
|
+
config: {
|
|
446
|
+
assignments: [
|
|
447
|
+
{ id: "a1", scope: "environment", key: "orderId", value: "{{CreateOrder.body.id}}", enabled: true },
|
|
448
|
+
],
|
|
449
|
+
},
|
|
450
|
+
},
|
|
451
|
+
},
|
|
452
|
+
{
|
|
453
|
+
type: "poll",
|
|
454
|
+
summary: "Repeat ONE embedded action until a condition passes or the budget runs out. Retry is forced off (the loop is the retry); a failing tick is retried, not fatal.",
|
|
455
|
+
fields: [
|
|
456
|
+
{ field: "actionType", type: "request | db | redis | script" },
|
|
457
|
+
{
|
|
458
|
+
field: "request / db / redis / script",
|
|
459
|
+
type: "that node type's config",
|
|
460
|
+
req: "run",
|
|
461
|
+
shownDefault: "request: the request defaults",
|
|
462
|
+
notes: "The one matching actionType. Same fields as the standalone node.",
|
|
463
|
+
},
|
|
464
|
+
{
|
|
465
|
+
field: "successCondition",
|
|
466
|
+
type: "{mode, rootGroup, expertExpression?}",
|
|
467
|
+
shownDefault: "`response.status` equals `200`",
|
|
468
|
+
notes: "Evaluated after every successful tick. A tick publishes like the real node: request → `response.*`, db → `db.result` (rows), redis → `redis.result`; a script tick exposes only its variable writes (`env.*`, `global.*`).",
|
|
469
|
+
},
|
|
470
|
+
{ field: "intervalMs", type: "integer 0..3600000" },
|
|
471
|
+
{ field: "maxAttempts", type: `integer 1..${MAX_POLL_ATTEMPTS}` },
|
|
472
|
+
{ field: "timeoutMs", type: "integer 1..86400000", notes: "Wall-clock cap." },
|
|
473
|
+
{ field: "backoffMultiplier", type: "number 1..10", notes: "Exponential backoff on the interval." },
|
|
474
|
+
{ field: "maxDelayMs", type: "integer 0..3600000", notes: "Backoff cap." },
|
|
475
|
+
{ field: "onTimeout", type: "fail | continue", notes: "continue marks the node degraded and goes on." },
|
|
476
|
+
],
|
|
477
|
+
output: "`<Label>.attempts`, `.elapsedMs`, `.timedOut`, `.conditionResult`, `.lastTickError`; the last tick's result as listed above.",
|
|
478
|
+
example: {
|
|
479
|
+
id: "wait-ready",
|
|
480
|
+
type: "poll",
|
|
481
|
+
label: "WaitReady",
|
|
482
|
+
config: {
|
|
483
|
+
actionType: "request",
|
|
484
|
+
request: { url: "{{env.baseUrl}}/orders/{{CreateOrder.body.id}}" },
|
|
485
|
+
successCondition: {
|
|
486
|
+
mode: "expert",
|
|
487
|
+
expertExpression: "{{response.body.status}} == 'READY'",
|
|
488
|
+
},
|
|
489
|
+
intervalMs: 2000,
|
|
490
|
+
maxAttempts: 15,
|
|
491
|
+
},
|
|
492
|
+
},
|
|
493
|
+
},
|
|
494
|
+
{
|
|
495
|
+
type: "foreach",
|
|
496
|
+
summary: "Run an embedded body graph once per item of an array.",
|
|
497
|
+
fields: [
|
|
498
|
+
{
|
|
499
|
+
field: "nodes / edges",
|
|
500
|
+
type: "stored node[] / stored edge[]",
|
|
501
|
+
shownDefault: "`[]`",
|
|
502
|
+
notes: "The body graph in the STORED form with complete configs (see Scenario shape) — no defaults are merged inside. Same graph rules; labels unique across the whole scenario.",
|
|
503
|
+
},
|
|
504
|
+
{ field: "sourceMode", type: "path | inline" },
|
|
505
|
+
{ field: "itemsPath", type: "string (≤2000)", req: "run", notes: "path mode: a bare runtime path to an array, e.g. `Users.body.items`." },
|
|
506
|
+
{ field: "itemsInline", type: "string", req: "run", notes: "inline mode: JSON array text; templates resolve first." },
|
|
507
|
+
{ field: "itemAlias", type: "string (≤100)", notes: "Extra root mirroring `{{item}}` (e.g. `user` → `{{user.id}}`). Not a reserved label." },
|
|
508
|
+
{
|
|
509
|
+
field: "maxIterations",
|
|
510
|
+
type: `integer 1..${MAX_FOREACH_ITERATIONS}`,
|
|
511
|
+
shownDefault: `${DEFAULT_FOREACH_MAX_ITERATIONS} (engine)`,
|
|
512
|
+
},
|
|
513
|
+
{ field: "delayMs", type: "integer 0..3600000", notes: "Pause between items." },
|
|
514
|
+
{ field: "onItemFailure", type: "stop | continue", notes: "continue: failed items degrade the run, the loop goes on." },
|
|
515
|
+
{ field: "collectResults", type: "boolean" },
|
|
516
|
+
],
|
|
517
|
+
output: "`<Label>.results[i].<ChildLabel>…`, `.itemCount`, `.processedCount`, `.truncated`, `.failedItems`. Inside the body: `{{item}}`, `{{each.index}}` (0-based), `{{each.iteration}}` (1-based), `{{each.total}}`, `{{each.remaining}}`, `{{eaches.<ForeachLabel>.iteration}}` for an outer loop.",
|
|
518
|
+
example: {
|
|
519
|
+
id: "each-user",
|
|
520
|
+
type: "foreach",
|
|
521
|
+
label: "EachUser",
|
|
522
|
+
config: {
|
|
523
|
+
sourceMode: "path",
|
|
524
|
+
itemsPath: "Users.body.items",
|
|
525
|
+
itemAlias: "user",
|
|
526
|
+
nodes: [
|
|
527
|
+
{
|
|
528
|
+
id: "get-user",
|
|
529
|
+
type: "request",
|
|
530
|
+
position: { x: 0, y: 0 },
|
|
531
|
+
data: {
|
|
532
|
+
label: "GetUser",
|
|
533
|
+
config: {
|
|
534
|
+
method: "GET",
|
|
535
|
+
url: "{{env.baseUrl}}/users/{{user.id}}",
|
|
536
|
+
headers: [],
|
|
537
|
+
query: [],
|
|
538
|
+
body: { mode: "none", raw: "", formEntries: [] },
|
|
539
|
+
},
|
|
540
|
+
},
|
|
541
|
+
},
|
|
542
|
+
],
|
|
543
|
+
edges: [],
|
|
544
|
+
},
|
|
545
|
+
},
|
|
546
|
+
},
|
|
547
|
+
{
|
|
548
|
+
type: "websocket",
|
|
549
|
+
summary: "One WebSocket conversation: connect, run the steps in order, close. Retry re-runs the whole conversation.",
|
|
550
|
+
fields: [
|
|
551
|
+
{ field: "url", type: "string (≤2000)", req: "run", notes: "ws:// or wss://, template-resolved." },
|
|
552
|
+
{ field: "headers", type: `${KV_ROWS} (≤100)` },
|
|
553
|
+
{ field: "subprotocols", type: "string[] (≤20)" },
|
|
554
|
+
{
|
|
555
|
+
field: "connectTimeoutMs",
|
|
556
|
+
type: `integer 100..${MAX_WS_CONNECT_TIMEOUT_MS}`,
|
|
557
|
+
shownDefault: `${DEFAULT_WS_CONNECT_TIMEOUT_MS} (engine)`,
|
|
558
|
+
},
|
|
559
|
+
{
|
|
560
|
+
field: "steps",
|
|
561
|
+
type: `Step[] (≤${MAX_WS_STEPS})`,
|
|
562
|
+
notes: `{id, kind:"send", payload} sends one text frame; {id, kind:"expect", rules (1..50 Rules), timeoutMs? (100..${MAX_WS_EXPECT_TIMEOUT_MS}, default ${DEFAULT_WS_EXPECT_TIMEOUT_MS}), matchMode? all | any} waits for a matching frame — rule paths use \`ws.text\`, \`ws.json.<path>\`, \`ws.direction\`, \`ws.index\`; {id, kind:"wait", durationMs (0..${MAX_WS_WAIT_DURATION_MS})}. A timed-out expect fails the node.`,
|
|
563
|
+
},
|
|
564
|
+
{ field: "closeCode", type: "integer 1000..4999", shownDefault: `${DEFAULT_WS_CLOSE_CODE} (engine)` },
|
|
565
|
+
{ field: "rejectUnauthorized", type: "boolean", notes: "false accepts self-signed certificates." },
|
|
566
|
+
],
|
|
567
|
+
output: "`<Label>.connected`, `.messages[i].text`, `.messageCount`, `.failedSteps`, `.closeCode`.",
|
|
568
|
+
example: {
|
|
569
|
+
id: "ws",
|
|
570
|
+
type: "websocket",
|
|
571
|
+
label: "Subscribe",
|
|
572
|
+
config: {
|
|
573
|
+
url: "wss://stream.example.com/orders",
|
|
574
|
+
steps: [
|
|
575
|
+
{ id: "s1", kind: "send", payload: '{"subscribe":"orders"}' },
|
|
576
|
+
{
|
|
577
|
+
id: "s2",
|
|
578
|
+
kind: "expect",
|
|
579
|
+
rules: [
|
|
580
|
+
{
|
|
581
|
+
id: "s2r",
|
|
582
|
+
type: "rule",
|
|
583
|
+
left: { mode: "runtime", valueType: "string", value: "ws.json.event" },
|
|
584
|
+
operator: "equals",
|
|
585
|
+
right: { mode: "literal", valueType: "string", value: "subscribed" },
|
|
586
|
+
},
|
|
587
|
+
],
|
|
588
|
+
},
|
|
589
|
+
],
|
|
590
|
+
},
|
|
591
|
+
},
|
|
592
|
+
},
|
|
593
|
+
{
|
|
594
|
+
type: "ai",
|
|
595
|
+
summary: "One LLM call on a managed workspace connection of type `ai`.",
|
|
596
|
+
fields: [
|
|
597
|
+
{
|
|
598
|
+
field: "connectionId",
|
|
599
|
+
type: "string (1..200)",
|
|
600
|
+
req: "yes",
|
|
601
|
+
notes: "The default `\"\"` is rejected on save.",
|
|
602
|
+
},
|
|
603
|
+
{
|
|
604
|
+
field: "prompt",
|
|
605
|
+
type: `string (≤${MAX_AI_PROMPT_LENGTH})`,
|
|
606
|
+
req: "run",
|
|
607
|
+
notes: "Template-resolved; empty fails the run.",
|
|
608
|
+
},
|
|
609
|
+
{
|
|
610
|
+
field: "responseExample",
|
|
611
|
+
type: `string (≤${MAX_AI_RESPONSE_EXAMPLE_LENGTH})`,
|
|
612
|
+
req: "run",
|
|
613
|
+
notes: "A JSON OBJECT as text; its top-level keys are required in the reply (extra keys allowed).",
|
|
614
|
+
},
|
|
615
|
+
{ field: "maxTokens", type: `integer 1..${MAX_AI_MAX_TOKENS}`, shownDefault: `${DEFAULT_AI_MAX_TOKENS} (engine)` },
|
|
616
|
+
{ field: "timeoutMs", type: `integer 1000..${MAX_AI_TIMEOUT_MS}`, shownDefault: `${DEFAULT_AI_TIMEOUT_MS} (engine)` },
|
|
617
|
+
],
|
|
618
|
+
output: "The parsed JSON reply: `<Label>.<exampleKey>`.",
|
|
619
|
+
example: {
|
|
620
|
+
id: "classify",
|
|
621
|
+
type: "ai",
|
|
622
|
+
label: "Classify",
|
|
623
|
+
config: {
|
|
624
|
+
connectionId: "<ai connection id>",
|
|
625
|
+
prompt: "Classify the sentiment of: {{GetReview.body.text}}",
|
|
626
|
+
responseExample: '{"sentiment":"positive","score":0.9}',
|
|
627
|
+
},
|
|
628
|
+
},
|
|
629
|
+
},
|
|
630
|
+
{
|
|
631
|
+
type: "wait",
|
|
632
|
+
summary: "Fixed delay.",
|
|
633
|
+
fields: [{ field: "durationMs", type: "integer 1..2147483647" }],
|
|
634
|
+
output: "`<Label>.configuredDurationMs`, `.actualDurationMs`, `.status`.",
|
|
635
|
+
example: { id: "pause", type: "wait", label: "Pause", config: { durationMs: 1000 } },
|
|
636
|
+
},
|
|
637
|
+
{
|
|
638
|
+
type: "notification",
|
|
639
|
+
summary: "Slack message through a workspace connection of type `notification`.",
|
|
640
|
+
fields: [
|
|
641
|
+
{ field: "provider", type: '"slack"' },
|
|
642
|
+
{ field: "connectionId", type: "string", req: "run" },
|
|
643
|
+
{ field: "severity", type: "info | success | warning | error" },
|
|
644
|
+
{
|
|
645
|
+
field: "messageTemplate",
|
|
646
|
+
type: "string (≤100000)",
|
|
647
|
+
shownDefault: "a run-status line",
|
|
648
|
+
notes: "Template-resolved (node outputs, `{{workflow.name}}`, `{{workflow.__meta.status}}` …).",
|
|
649
|
+
},
|
|
650
|
+
{ field: "channelOverride", type: "string (≤500)" },
|
|
651
|
+
{ field: "continueOnFailure", type: "boolean", notes: "Overrides executionPolicy.onFailure (true = continue)." },
|
|
652
|
+
{ field: "advanced.useRichBlocks", type: "boolean" },
|
|
653
|
+
],
|
|
654
|
+
output: "The delivery result.",
|
|
655
|
+
example: {
|
|
656
|
+
id: "notify",
|
|
657
|
+
type: "notification",
|
|
658
|
+
label: "Notify",
|
|
659
|
+
config: {
|
|
660
|
+
connectionId: "<notification connection id>",
|
|
661
|
+
severity: "warning",
|
|
662
|
+
messageTemplate: "Order {{CreateOrder.body.id}} is not ready",
|
|
663
|
+
},
|
|
664
|
+
},
|
|
665
|
+
},
|
|
666
|
+
{
|
|
667
|
+
type: "aggregator",
|
|
668
|
+
summary: "Build one JSON object from earlier outputs.",
|
|
669
|
+
fields: [
|
|
670
|
+
{
|
|
671
|
+
field: "template",
|
|
672
|
+
type: "string (≤1000000)",
|
|
673
|
+
req: "run",
|
|
674
|
+
notes: "Must be valid JSON after templating (`{{Label.path}}` or `{Label.path}` tokens), else the run fails.",
|
|
675
|
+
},
|
|
676
|
+
],
|
|
677
|
+
output: "The parsed object: `<Label>.<key>`.",
|
|
678
|
+
example: {
|
|
679
|
+
id: "summary",
|
|
680
|
+
type: "aggregator",
|
|
681
|
+
label: "Summary",
|
|
682
|
+
config: { template: '{"orderId":"{{CreateOrder.body.id}}","status":"{{CheckOrder.rows[0].status}}"}' },
|
|
683
|
+
},
|
|
684
|
+
},
|
|
685
|
+
{
|
|
686
|
+
type: "end",
|
|
687
|
+
summary: "Terminates a path; optionally loops back to an earlier node.",
|
|
688
|
+
fields: [
|
|
689
|
+
{ field: "loop", type: "boolean" },
|
|
690
|
+
{ field: "loopTargetNodeId", type: "string | null", req: "run", notes: "loop: the id of an earlier node to jump back to." },
|
|
691
|
+
{
|
|
692
|
+
field: "loopIterations",
|
|
693
|
+
type: `integer 1..${MAX_END_LOOP_ITERATIONS}`,
|
|
694
|
+
notes: "Re-entries: the looped section runs loopIterations + 1 times.",
|
|
695
|
+
},
|
|
696
|
+
{ field: "loopDelayMs", type: "integer 0..600000" },
|
|
697
|
+
{
|
|
698
|
+
field: "loopExitRule",
|
|
699
|
+
type: "Rule | null",
|
|
700
|
+
notes: "Checked after each pass; TRUE stops the loop early and continues along the end node's outgoing edges. Loop roots: `loop.iteration` (1-based), `loop.index`, `loop.total`, `loop.remaining`.",
|
|
701
|
+
},
|
|
702
|
+
],
|
|
703
|
+
output: "A snapshot of the run's outputs so far.",
|
|
704
|
+
example: {
|
|
705
|
+
id: "again",
|
|
706
|
+
type: "end",
|
|
707
|
+
label: "Repeat",
|
|
708
|
+
config: { loop: true, loopTargetNodeId: "login", loopIterations: 2, loopDelayMs: 500 },
|
|
709
|
+
},
|
|
710
|
+
},
|
|
711
|
+
{
|
|
712
|
+
type: "subscenario",
|
|
713
|
+
summary: "A group of nodes collapsed into one node (the builder's Group).",
|
|
714
|
+
fields: [
|
|
715
|
+
{
|
|
716
|
+
field: "nodes / edges",
|
|
717
|
+
type: "stored node[] / stored edge[]",
|
|
718
|
+
req: "yes",
|
|
719
|
+
shownDefault: "none",
|
|
720
|
+
notes: "Stored form, complete configs; no defaults are merged for this type. Same graph rules.",
|
|
721
|
+
},
|
|
722
|
+
{
|
|
723
|
+
field: "reusableGroupId / reusableSourceScenarioId / reusableSourceNodeId / ungroupSnapshot",
|
|
724
|
+
type: "app-managed",
|
|
725
|
+
shownDefault: "—",
|
|
726
|
+
notes: "Written by the builder; omit them.",
|
|
727
|
+
},
|
|
728
|
+
],
|
|
729
|
+
output: "Its inner nodes publish under their own labels.",
|
|
730
|
+
example: {
|
|
731
|
+
id: "setup",
|
|
732
|
+
type: "subscenario",
|
|
733
|
+
label: "Setup",
|
|
734
|
+
config: {
|
|
735
|
+
nodes: [
|
|
736
|
+
{
|
|
737
|
+
id: "seed",
|
|
738
|
+
type: "wait",
|
|
739
|
+
position: { x: 0, y: 0 },
|
|
740
|
+
data: { label: "Settle", config: { durationMs: 500 } },
|
|
741
|
+
},
|
|
742
|
+
],
|
|
743
|
+
edges: [],
|
|
744
|
+
},
|
|
745
|
+
},
|
|
746
|
+
},
|
|
747
|
+
{
|
|
748
|
+
type: "scenario-ref",
|
|
749
|
+
summary: "A reference to another scenario, embedded as a synced snapshot. Created by the app when a scenario is linked; the compact form cannot create or re-send one (in the stored form, send it back exactly as read).",
|
|
750
|
+
fields: [
|
|
751
|
+
{ field: "referencedScenarioId", type: "string", req: "yes" },
|
|
752
|
+
{ field: "referencedScenarioVersion", type: "integer | null", req: "yes" },
|
|
753
|
+
{ field: "referencedScenarioName / lastSyncedAt", type: "string", req: "yes" },
|
|
754
|
+
{ field: "nodes / edges", type: "stored node[] / stored edge[]", req: "yes", notes: "The referenced scenario's graph snapshot." },
|
|
755
|
+
],
|
|
756
|
+
output: "Its inner nodes publish under their own labels.",
|
|
757
|
+
example: null,
|
|
758
|
+
},
|
|
759
|
+
];
|
|
760
|
+
const renderNodeSection = (node, defaultConfigFor) => {
|
|
761
|
+
let defaults = null;
|
|
762
|
+
try {
|
|
763
|
+
defaults = defaultConfigFor(node.type);
|
|
764
|
+
}
|
|
765
|
+
catch {
|
|
766
|
+
defaults = null;
|
|
767
|
+
}
|
|
768
|
+
return [
|
|
769
|
+
`### ${node.type}`,
|
|
770
|
+
node.summary,
|
|
771
|
+
"",
|
|
772
|
+
renderFieldTable(node, defaults),
|
|
773
|
+
"",
|
|
774
|
+
`Output: ${node.output}`,
|
|
775
|
+
...(node.notes ?? []).flatMap((note) => ["", note]),
|
|
776
|
+
...(node.example ? ["", renderExample(node.example)] : []),
|
|
777
|
+
].join("\n");
|
|
778
|
+
};
|
|
779
|
+
/**
|
|
780
|
+
* The scenario node reference for API / MCP clients: the scenario shape, every
|
|
781
|
+
* node type's config fields (type, default, requirement, notes) with a minimal
|
|
782
|
+
* example, and the graph rules the server enforces.
|
|
783
|
+
*/
|
|
784
|
+
export const renderScenarioNodeReference = (options = {}) => {
|
|
785
|
+
const defaultConfigFor = options.defaultConfigFor ?? defaultScenarioNodeConfig;
|
|
786
|
+
const scriptReserved = [...RESERVED_LABEL_NAMES].filter((name) => ["pm", "env", "request", "response", "test", "expect", "workflow", "db", "kafka", "redis", "console"].includes(name));
|
|
787
|
+
return [
|
|
788
|
+
"# Scenario node reference",
|
|
789
|
+
"",
|
|
790
|
+
"A scenario is a directed graph: nodes do the work, edges decide what runs next. This reference is for clients that write scenarios through the API or an MCP tool; every rule below is what the server enforces on save or what the run engine does.",
|
|
791
|
+
"",
|
|
792
|
+
"## Scenario shape",
|
|
793
|
+
"",
|
|
794
|
+
"Compact form (what the MCP scenario tools take):",
|
|
795
|
+
"",
|
|
796
|
+
"```json",
|
|
797
|
+
JSON.stringify({
|
|
798
|
+
name: "Order flow",
|
|
799
|
+
description: "optional",
|
|
800
|
+
priority: "high",
|
|
801
|
+
nodes: [
|
|
802
|
+
{ id: "login", type: "request", label: "Login", config: { method: "POST", url: "{{env.baseUrl}}/login" } },
|
|
803
|
+
{ id: "done", type: "end", label: "Done" },
|
|
804
|
+
],
|
|
805
|
+
edges: [{ source: "login", target: "done" }],
|
|
806
|
+
environments: [
|
|
807
|
+
{ name: "Default", variables: [{ key: "baseUrl", value: "https://api.example.com" }] },
|
|
808
|
+
],
|
|
809
|
+
}, null, 2),
|
|
810
|
+
"```",
|
|
811
|
+
"",
|
|
812
|
+
"- Node `{ id?, type, label?, config?, position? }`. `id` defaults to n1, n2, …; `position` is laid out when omitted.",
|
|
813
|
+
"- `config` is deep-merged over the type's defaults (the Default column): objects merge key by key, arrays and scalars replace the default whole. Send only what you change — but an array you send (headers, checks, steps, rules, assignments) must be complete.",
|
|
814
|
+
"- Edge `{ id?, source, target, type? }`, `type` = `default` (or omitted) | `yes` | `no` | `error`. Edges have no label.",
|
|
815
|
+
"- `priority`: critical | high | medium | low. `environments[].variables`: `{ key, value, enabled? }[]`.",
|
|
816
|
+
"- Stored form (the REST API, and the body of `foreach` / `subscenario` / `scenario-ref`): node `{ id, type, position: { x, y }, data: { label, config } }` with the COMPLETE config — nothing is merged — and edge `{ id, source, target, type? }` with a required `id`.",
|
|
817
|
+
"",
|
|
818
|
+
"Req column: **yes** = rejected on save without a real value; **run** = saved, but the run fails without it. Default \"(engine)\" = applied at run time when the field is absent.",
|
|
819
|
+
"",
|
|
820
|
+
"## Connections",
|
|
821
|
+
"",
|
|
822
|
+
"db, redis, kafka, notification and ai nodes run on a workspace connection: `config.connectionId` is the id of a connection of type `database`, `redis`, `kafka`, `notification` (Slack) or `ai` (managed). Use a real connection id from the workspace — never invent one. Only the ai node rejects an empty id on save; the others save with `\"\"` and fail when run.",
|
|
823
|
+
"",
|
|
824
|
+
"## Labels",
|
|
825
|
+
"",
|
|
826
|
+
"- `label` names a node's output: later nodes read `{{Label.path}}`. It is optional on save, but an unlabelled node's output cannot be referenced.",
|
|
827
|
+
"- Labels must be unique across the whole scenario, including foreach / subscenario bodies: a repeated label is overwritten by whichever node ran last.",
|
|
828
|
+
`- Reserved labels — the engine drops the output of a node labelled ${RESERVED_SCENARIO_LABELS.map(code).join(", ")}, so references to it resolve to empty without any error.`,
|
|
829
|
+
`- Avoid labels that are built-in roots — ${BUILT_IN_ROOTS.map(code).join(", ")} — the node and the root shadow each other.`,
|
|
830
|
+
`- Prefer identifier labels (\`Login\`, \`CreateOrder\`, \`check_db\`). Script nodes get a label as a global variable only when it is an identifier and not a sandbox name (${scriptReserved.map(code).join(", ")}, …).`,
|
|
831
|
+
"",
|
|
832
|
+
"## Templates and outputs",
|
|
833
|
+
"",
|
|
834
|
+
"- `{{env.KEY}}` / `{{global.KEY}}` read one scope; a bare `{{KEY}}` reads both (in scenario runs a global wins when both define KEY). `{{data.column}}` is the current dataset row.",
|
|
835
|
+
"- `{{Label.path}}` (or `{Label.path}`) reads an earlier node's output; arrays by index (`items[0].id`), no wildcards. `{{response.*}}` is the most recent request's response. Generators: `{{$randomUUID}}`, `{{$timestamp}}`, `{{random.email}}`, `{{counter}}`.",
|
|
836
|
+
"- Substitution is plain text — quote SQL strings yourself (`WHERE id = '{{userId}}'`). A missing value resolves to an empty string.",
|
|
837
|
+
"- A request's output: `<Label>.status` is the HTTP status (number). Read body fields through `<Label>.body.<path>`: top-level body keys are also spread onto the output, but `status`, `statusText`, `data`, `body`, `rawBody`, `text`, `headers`, `cookies`, `durationMs` are HTTP metadata and win over same-named body keys. `<Label>.data.<path>` reads a body's top-level `data`.",
|
|
838
|
+
"- Operand paths (rules, `foreach.itemsPath`, set-vars `source`) are BARE dotted paths without braces: `CreateOrder.body.total`, `CheckOrder.rows[0].status`.",
|
|
839
|
+
"",
|
|
840
|
+
"## Rules and operands",
|
|
841
|
+
"",
|
|
842
|
+
"Shared by condition, assertion, poll, websocket expect steps and `end.loopExitRule`.",
|
|
843
|
+
"",
|
|
844
|
+
"- Rule `{ id, type: \"rule\", left, operator, right? }` — `id` and `type: \"rule\"` are required; `right` is required unless the operator is unary.",
|
|
845
|
+
"- Group `{ id, type: \"group\", operator: \"AND\" | \"OR\", children: [Rule | Group, …] }` with at least one child.",
|
|
846
|
+
"- Operand `{ mode: \"runtime\" | \"literal\", valueType: \"string\" | \"number\" | \"boolean\" | \"null\" | \"array\", value, extract? }`. runtime: `value` is a bare path. literal: a JSON number for number, true/false for boolean, null for null, a JSON array for array.",
|
|
847
|
+
`- Binary operators: ${binaryOperators.join(", ")}. \`between\` takes [min, max]; \`in\` / \`not_in\` an array; \`regex\` a JS regex source; \`contains_row\` checks that an array of rows has one matching every key of an object (a JSON-object string literal or a runtime path); \`set_equals_unordered\` compares two arrays as multisets.`,
|
|
848
|
+
`- Unary operators (no \`right\`): ${unaryOperators.join(", ")}.`,
|
|
849
|
+
"- `extract` `{ type: \"jsonpath\" | \"regex\", expression, flags?, group?, select?: \"first\" | \"all\" }` is applied to the resolved value first; a failed extraction behaves as not-exists.",
|
|
850
|
+
"",
|
|
851
|
+
"Example: `{ \"id\": \"r1\", \"type\": \"rule\", \"left\": { \"mode\": \"runtime\", \"valueType\": \"number\", \"value\": \"CreateOrder.status\" }, \"operator\": \"equals\", \"right\": { \"mode\": \"literal\", \"valueType\": \"number\", \"value\": 201 } }`",
|
|
852
|
+
"",
|
|
853
|
+
"## Contract rules",
|
|
854
|
+
"",
|
|
855
|
+
CONTRACT_RULES_SPEC,
|
|
856
|
+
"",
|
|
857
|
+
"## Execution policy",
|
|
858
|
+
"",
|
|
859
|
+
"Every node except condition takes `executionPolicy: { onFailure: \"stop\" | \"continue\", retry: { enabled, maxAttempts (0..100), delayMs (0..3600000), backoffMultiplier? (1..10), maxDelayMs? (0..3600000) } }` — onFailure and retry.enabled / maxAttempts / delayMs are required when you send the stored form.",
|
|
860
|
+
"- `maxAttempts` counts every attempt (an enabled retry makes at least 2). Delay before retry n = min(maxDelayMs, delayMs × backoffMultiplier^(n−1)). No policy = stop, no retry.",
|
|
861
|
+
"- stop: a failure ends the run; continue: the run goes on along the normal edges.",
|
|
862
|
+
"- `error` edges from the failing node win over both: the run follows them instead of the normal edges (after the retries).",
|
|
863
|
+
"",
|
|
864
|
+
"## Node types",
|
|
865
|
+
"",
|
|
866
|
+
NODE_DOCS.map((node) => renderNodeSection(node, defaultConfigFor)).join("\n\n"),
|
|
867
|
+
"",
|
|
868
|
+
"## Graph rules",
|
|
869
|
+
"",
|
|
870
|
+
"Rejected on save:",
|
|
871
|
+
"- Node ids unique; edge ids unique; every edge's source and target is an existing node id.",
|
|
872
|
+
"- `yes` / `no` edges only leave a condition node; `error` edges only leave a non-condition node; omit `type` (or `default`) for normal flow.",
|
|
873
|
+
"- A condition node has exactly two outgoing edges: one `yes`, one `no`.",
|
|
874
|
+
"- At least one start node (a node with no incoming edge) and no cycles — loop with an end node's `loop` settings, never with a back edge.",
|
|
875
|
+
"- foreach / subscenario / scenario-ref bodies are checked with the same rules.",
|
|
876
|
+
"",
|
|
877
|
+
"Saved with a warning: more than one start node; nodes no start node reaches.",
|
|
878
|
+
].join("\n");
|
|
879
|
+
};
|
|
880
|
+
// Exposed for tests: the node types the reference documents, in order.
|
|
881
|
+
export const SCENARIO_NODE_REFERENCE_TYPES = NODE_TYPE_ORDER;
|
|
882
|
+
//# sourceMappingURL=scenarioNodeReference.js.map
|