@runcofactor/cli 0.10.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 +125 -0
- package/dist/api-catalog-GFGCLVS4.js +401 -0
- package/dist/api-catalog-GFGCLVS4.js.map +1 -0
- package/dist/attachment-SJC7D7FY.js +276 -0
- package/dist/attachment-SJC7D7FY.js.map +1 -0
- package/dist/auth-EN6NQCJY.js +31 -0
- package/dist/auth-EN6NQCJY.js.map +1 -0
- package/dist/bin.js +7 -0
- package/dist/bin.js.map +1 -0
- package/dist/chunk-3CZU7XCS.js +272 -0
- package/dist/chunk-3CZU7XCS.js.map +1 -0
- package/dist/chunk-4NRDONSC.js +1048 -0
- package/dist/chunk-4NRDONSC.js.map +1 -0
- package/dist/chunk-4PNOV7OV.js +51980 -0
- package/dist/chunk-4PNOV7OV.js.map +1 -0
- package/dist/chunk-52IO7ACJ.js +2941 -0
- package/dist/chunk-52IO7ACJ.js.map +1 -0
- package/dist/chunk-6LEZCKKV.js +663 -0
- package/dist/chunk-6LEZCKKV.js.map +1 -0
- package/dist/chunk-A4OFTUVV.js +170 -0
- package/dist/chunk-A4OFTUVV.js.map +1 -0
- package/dist/chunk-B2VISN2V.js +2459 -0
- package/dist/chunk-B2VISN2V.js.map +1 -0
- package/dist/chunk-CLAWT7BJ.js +271 -0
- package/dist/chunk-CLAWT7BJ.js.map +1 -0
- package/dist/chunk-GKY2KXO5.js +281 -0
- package/dist/chunk-GKY2KXO5.js.map +1 -0
- package/dist/chunk-I4QLI3JO.js +413 -0
- package/dist/chunk-I4QLI3JO.js.map +1 -0
- package/dist/chunk-IMPZAX6L.js +180 -0
- package/dist/chunk-IMPZAX6L.js.map +1 -0
- package/dist/chunk-J6SH7GQT.js +241 -0
- package/dist/chunk-J6SH7GQT.js.map +1 -0
- package/dist/chunk-JDBCZ3Z2.js +298 -0
- package/dist/chunk-JDBCZ3Z2.js.map +1 -0
- package/dist/chunk-JICSYFRJ.js +46 -0
- package/dist/chunk-JICSYFRJ.js.map +1 -0
- package/dist/chunk-JWHYXEA2.js +32 -0
- package/dist/chunk-JWHYXEA2.js.map +1 -0
- package/dist/chunk-N7HFALX3.js +88 -0
- package/dist/chunk-N7HFALX3.js.map +1 -0
- package/dist/chunk-Q4BO4Z5V.js +178 -0
- package/dist/chunk-Q4BO4Z5V.js.map +1 -0
- package/dist/chunk-QFDOWABX.js +195 -0
- package/dist/chunk-QFDOWABX.js.map +1 -0
- package/dist/chunk-SJJW77WN.js +605 -0
- package/dist/chunk-SJJW77WN.js.map +1 -0
- package/dist/chunk-VKVL7WBN.js +50 -0
- package/dist/chunk-VKVL7WBN.js.map +1 -0
- package/dist/chunk-WOHL4FTB.js +353 -0
- package/dist/chunk-WOHL4FTB.js.map +1 -0
- package/dist/chunk-XQXVG7RA.js +426 -0
- package/dist/chunk-XQXVG7RA.js.map +1 -0
- package/dist/chunk-ZD3KHESI.js +94 -0
- package/dist/chunk-ZD3KHESI.js.map +1 -0
- package/dist/credential-COLTBV6D.js +450 -0
- package/dist/credential-COLTBV6D.js.map +1 -0
- package/dist/credential-template-EEPYFG7R.js +291 -0
- package/dist/credential-template-EEPYFG7R.js.map +1 -0
- package/dist/debug-workflow-KOVH5GOP.js +259 -0
- package/dist/debug-workflow-KOVH5GOP.js.map +1 -0
- package/dist/dist-44PZR7BQ.js +423 -0
- package/dist/dist-44PZR7BQ.js.map +1 -0
- package/dist/docs-3SSORHB5.js +366 -0
- package/dist/docs-3SSORHB5.js.map +1 -0
- package/dist/doctor-OTSIWV6G.js +132 -0
- package/dist/doctor-OTSIWV6G.js.map +1 -0
- package/dist/feature-flag-T2LRZME3.js +283 -0
- package/dist/feature-flag-T2LRZME3.js.map +1 -0
- package/dist/file-type-S3UUBVCT.js +4205 -0
- package/dist/file-type-S3UUBVCT.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +982 -0
- package/dist/index.js.map +1 -0
- package/dist/init-TGWRECT2.js +208 -0
- package/dist/init-TGWRECT2.js.map +1 -0
- package/dist/install-DXSNWZKO.js +295 -0
- package/dist/install-DXSNWZKO.js.map +1 -0
- package/dist/issues-XIZOPX6T.js +564 -0
- package/dist/issues-XIZOPX6T.js.map +1 -0
- package/dist/knobs-NBINZIUB.js +295 -0
- package/dist/knobs-NBINZIUB.js.map +1 -0
- package/dist/list-3TDHK637.js +59 -0
- package/dist/list-3TDHK637.js.map +1 -0
- package/dist/local-HICVD6PU.js +100 -0
- package/dist/local-HICVD6PU.js.map +1 -0
- package/dist/login-UOIUXVUK.js +315 -0
- package/dist/login-UOIUXVUK.js.map +1 -0
- package/dist/mcp-54IJEOUA.js +1956 -0
- package/dist/mcp-54IJEOUA.js.map +1 -0
- package/dist/pdf-extract-ABWV3L5X.js +13 -0
- package/dist/pdf-extract-ABWV3L5X.js.map +1 -0
- package/dist/pdfjs-DQ2YA5S3.js +41561 -0
- package/dist/pdfjs-DQ2YA5S3.js.map +1 -0
- package/dist/property-UXLOLPTD.js +701 -0
- package/dist/property-UXLOLPTD.js.map +1 -0
- package/dist/property-skill-guide.md +189 -0
- package/dist/psql-BYUUNLGG.js +115 -0
- package/dist/psql-BYUUNLGG.js.map +1 -0
- package/dist/record-4TR2UIIS.js +233 -0
- package/dist/record-4TR2UIIS.js.map +1 -0
- package/dist/redis-35VBUYW7.js +116 -0
- package/dist/redis-35VBUYW7.js.map +1 -0
- package/dist/release-OOLTUBUT.js +354 -0
- package/dist/release-OOLTUBUT.js.map +1 -0
- package/dist/sandbox-XGSUU6G7.js +2046 -0
- package/dist/sandbox-XGSUU6G7.js.map +1 -0
- package/dist/scenarios-GWLJEGHA.js +280 -0
- package/dist/scenarios-GWLJEGHA.js.map +1 -0
- package/dist/session-MYFNZPRP.js +2414 -0
- package/dist/session-MYFNZPRP.js.map +1 -0
- package/dist/skill-KM34LDMO.js +1839 -0
- package/dist/skill-KM34LDMO.js.map +1 -0
- package/dist/skills/debug-run/SKILL.md +262 -0
- package/dist/skills/debug-run/history/1.0.0.md +244 -0
- package/dist/skills/debug-run/history/1.0.1.md +245 -0
- package/dist/skills/debug-run/history/1.0.2.md +254 -0
- package/dist/skills/debug-run/history/1.1.0.md +262 -0
- package/dist/skills/debug-run/version.json +3 -0
- package/dist/spec-RA3CFEKG.js +951 -0
- package/dist/spec-RA3CFEKG.js.map +1 -0
- package/dist/src-LB4HBTJQ.js +1516 -0
- package/dist/src-LB4HBTJQ.js.map +1 -0
- package/dist/src-N3TPKGTY.js +244 -0
- package/dist/src-N3TPKGTY.js.map +1 -0
- package/dist/src-UVDX5UP6.js +17 -0
- package/dist/src-UVDX5UP6.js.map +1 -0
- package/dist/start-PSEQSQA3.js +151 -0
- package/dist/start-PSEQSQA3.js.map +1 -0
- package/dist/tag-YY6YAABQ.js +22 -0
- package/dist/tag-YY6YAABQ.js.map +1 -0
- package/dist/task-IQC3YR5K.js +922 -0
- package/dist/task-IQC3YR5K.js.map +1 -0
- package/dist/test-suite-DZNJFXDW.js +394 -0
- package/dist/test-suite-DZNJFXDW.js.map +1 -0
- package/dist/trace-HXIW7U2D.js +14 -0
- package/dist/trace-HXIW7U2D.js.map +1 -0
- package/dist/triage-ORG45R5W.js +16 -0
- package/dist/triage-ORG45R5W.js.map +1 -0
- package/dist/upgrade-L2VYMP2V.js +61 -0
- package/dist/upgrade-L2VYMP2V.js.map +1 -0
- package/dist/workflow-XYFH5LTP.js +3708 -0
- package/dist/workflow-XYFH5LTP.js.map +1 -0
- package/dist/workflow-cache-NQJQFWMV.js +253 -0
- package/dist/workflow-cache-NQJQFWMV.js.map +1 -0
- package/dist/workflow-debug-BGD4UIXU.js +927 -0
- package/dist/workflow-debug-BGD4UIXU.js.map +1 -0
- package/dist/workflow-proposals-MTUDX46O.js +382 -0
- package/dist/workflow-proposals-MTUDX46O.js.map +1 -0
- package/package.json +61 -0
|
@@ -0,0 +1,3708 @@
|
|
|
1
|
+
import { createRequire as __cr } from "module"; const require = __cr(import.meta.url);
|
|
2
|
+
import {
|
|
3
|
+
printGuide
|
|
4
|
+
} from "./chunk-4NRDONSC.js";
|
|
5
|
+
import {
|
|
6
|
+
workflowToSpec
|
|
7
|
+
} from "./chunk-B2VISN2V.js";
|
|
8
|
+
import {
|
|
9
|
+
applyWorkflowTagFlags
|
|
10
|
+
} from "./chunk-XQXVG7RA.js";
|
|
11
|
+
import {
|
|
12
|
+
alignSnapshotNodeIds
|
|
13
|
+
} from "./chunk-JDBCZ3Z2.js";
|
|
14
|
+
import {
|
|
15
|
+
apiRequest,
|
|
16
|
+
renderWorkflowUserError
|
|
17
|
+
} from "./chunk-WOHL4FTB.js";
|
|
18
|
+
import {
|
|
19
|
+
NODE_TYPE_LABEL,
|
|
20
|
+
USER_SETTABLE_WORKFLOW_STATUSES,
|
|
21
|
+
normalizeNodeType
|
|
22
|
+
} from "./chunk-SJJW77WN.js";
|
|
23
|
+
import {
|
|
24
|
+
apiFetch,
|
|
25
|
+
exitOnFailure,
|
|
26
|
+
fetchOrExit
|
|
27
|
+
} from "./chunk-3CZU7XCS.js";
|
|
28
|
+
import {
|
|
29
|
+
getArgValue,
|
|
30
|
+
hasFlag,
|
|
31
|
+
resolveAppUrl,
|
|
32
|
+
resolveConfig
|
|
33
|
+
} from "./chunk-A4OFTUVV.js";
|
|
34
|
+
import "./chunk-JWHYXEA2.js";
|
|
35
|
+
import "./chunk-VKVL7WBN.js";
|
|
36
|
+
|
|
37
|
+
// src/workflow.ts
|
|
38
|
+
import fs5 from "fs/promises";
|
|
39
|
+
import process9 from "process";
|
|
40
|
+
|
|
41
|
+
// src/workflow-create.ts
|
|
42
|
+
import fs from "fs/promises";
|
|
43
|
+
import process2 from "process";
|
|
44
|
+
import { createParser } from "eventsource-parser";
|
|
45
|
+
async function readFromStdin() {
|
|
46
|
+
const chunks = [];
|
|
47
|
+
const stdin = process2.stdin;
|
|
48
|
+
if (stdin.isTTY) {
|
|
49
|
+
throw new Error("No input on stdin. Pipe workflow JSON or use --from-file.");
|
|
50
|
+
}
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
stdin.on("data", (chunk) => {
|
|
53
|
+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
54
|
+
});
|
|
55
|
+
stdin.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
|
56
|
+
stdin.on("error", reject);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
async function readWorkflowInput(argv) {
|
|
60
|
+
let raw;
|
|
61
|
+
const fromFile = getArgValue(argv, "--from-file");
|
|
62
|
+
if (fromFile) {
|
|
63
|
+
try {
|
|
64
|
+
raw = await fs.readFile(fromFile, "utf-8");
|
|
65
|
+
} catch (err) {
|
|
66
|
+
throw new Error(`Failed to read file ${fromFile}: ${err instanceof Error ? err.message : String(err)}`);
|
|
67
|
+
}
|
|
68
|
+
} else {
|
|
69
|
+
raw = await readFromStdin();
|
|
70
|
+
}
|
|
71
|
+
let parsed;
|
|
72
|
+
try {
|
|
73
|
+
parsed = JSON.parse(raw);
|
|
74
|
+
} catch {
|
|
75
|
+
throw new Error("Invalid JSON input. Please provide valid workflow JSON.");
|
|
76
|
+
}
|
|
77
|
+
const input = parsed;
|
|
78
|
+
if (!input.name || typeof input.name !== "string") {
|
|
79
|
+
throw new Error('Workflow JSON must include a "name" string field.');
|
|
80
|
+
}
|
|
81
|
+
if (!Array.isArray(input.nodes) || input.nodes.length === 0) {
|
|
82
|
+
throw new Error('Workflow JSON must include a non-empty "nodes" array.');
|
|
83
|
+
}
|
|
84
|
+
const validNodeTypes = ["login", "navigate", "action", "assertion", "end", "setup", "seed", "wait", "condition", "api_sequence", "notification"];
|
|
85
|
+
for (const node of input.nodes) {
|
|
86
|
+
if (typeof node.nodeType === "string") {
|
|
87
|
+
node.nodeType = normalizeNodeType(node.nodeType);
|
|
88
|
+
}
|
|
89
|
+
if (!node.nodeType || !validNodeTypes.includes(node.nodeType)) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`Invalid nodeType "${node.nodeType}". Valid types: ${validNodeTypes.join(", ")}`
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return input;
|
|
96
|
+
}
|
|
97
|
+
async function fetchOrgCredentials(apiUrl, token) {
|
|
98
|
+
const res = await fetch(`${apiUrl}/org/credentials`, {
|
|
99
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
100
|
+
});
|
|
101
|
+
if (!res.ok) return [];
|
|
102
|
+
const data = await res.json();
|
|
103
|
+
return (data.items ?? []).filter((c) => !c.isEphemeral);
|
|
104
|
+
}
|
|
105
|
+
async function resolveCredential(apiUrl, token, credentialName) {
|
|
106
|
+
const credentials = await fetchOrgCredentials(apiUrl, token);
|
|
107
|
+
if (credentials.length === 0) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
"No credentials found for this organization. Create one in the web app first."
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
if (credentialName) {
|
|
113
|
+
const match = credentials.find(
|
|
114
|
+
(c) => c.name.toLowerCase() === credentialName.toLowerCase()
|
|
115
|
+
);
|
|
116
|
+
if (match) {
|
|
117
|
+
const isLocal = /\blocal\b/i.test(match.name);
|
|
118
|
+
if (isLocal) {
|
|
119
|
+
console.warn(
|
|
120
|
+
`Warning: Credential "${match.name}" appears to be local-only. The workflow may fail in deployed environments. Consider using a non-local credential.`
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
return { credentialId: match.id, credentialName: match.name, propertyId: match.propertyId };
|
|
124
|
+
}
|
|
125
|
+
console.warn(`Warning: No credential matching name "${credentialName}". Trying other resolution strategies.`);
|
|
126
|
+
}
|
|
127
|
+
const defaults = credentials.filter((c) => c.isDefault);
|
|
128
|
+
if (defaults.length === 1) {
|
|
129
|
+
console.log(`Auto-selected default credential: ${defaults[0].name} (${defaults[0].id})`);
|
|
130
|
+
return { credentialId: defaults[0].id, credentialName: defaults[0].name, propertyId: defaults[0].propertyId };
|
|
131
|
+
}
|
|
132
|
+
const nonLocal = credentials.filter((c) => !/\blocal\b/i.test(c.name));
|
|
133
|
+
if (nonLocal.length === 1) {
|
|
134
|
+
console.log(`Auto-selected credential: ${nonLocal[0].name} (${nonLocal[0].id})`);
|
|
135
|
+
return { credentialId: nonLocal[0].id, credentialName: nonLocal[0].name, propertyId: nonLocal[0].propertyId };
|
|
136
|
+
}
|
|
137
|
+
const lines = credentials.map(
|
|
138
|
+
(c) => ` ${c.id} ${c.name}${c.isDefault ? " (default)" : ""}${c.propertyName ? ` \u2014 ${c.propertyName}` : ""}`
|
|
139
|
+
);
|
|
140
|
+
throw new Error(
|
|
141
|
+
`Cannot auto-resolve credential. Specify credentialId in the login node configJson.
|
|
142
|
+
|
|
143
|
+
Available credentials:
|
|
144
|
+
${lines.join("\n")}`
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
async function fetchSuiteLiveState(apiUrl, token, suiteId) {
|
|
148
|
+
try {
|
|
149
|
+
const res = await fetch(`${apiUrl}/workflows/test-executions/${suiteId}/live-state`, {
|
|
150
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
151
|
+
});
|
|
152
|
+
if (!res.ok) return null;
|
|
153
|
+
return await res.json();
|
|
154
|
+
} catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
async function runAndPublish(opts) {
|
|
159
|
+
const { apiUrl, token, workflowId, workflowName, timeoutMs } = opts;
|
|
160
|
+
const body = {};
|
|
161
|
+
if (opts.propertyId) body.propertyId = opts.propertyId;
|
|
162
|
+
if (opts.environmentId) body.environmentId = opts.environmentId;
|
|
163
|
+
const triggerUrl = `${apiUrl}/workflows/test-runs?namePattern=${encodeURIComponent(workflowName)}`;
|
|
164
|
+
const triggerRes = await fetchOrExit(triggerUrl, {
|
|
165
|
+
method: "POST",
|
|
166
|
+
headers: {
|
|
167
|
+
Authorization: `Bearer ${token}`,
|
|
168
|
+
"Content-Type": "application/json"
|
|
169
|
+
},
|
|
170
|
+
token,
|
|
171
|
+
body: JSON.stringify(body)
|
|
172
|
+
});
|
|
173
|
+
if (!triggerRes.ok) {
|
|
174
|
+
const errorText = await triggerRes.text();
|
|
175
|
+
console.error(`Failed to trigger test run: ${triggerRes.status} ${errorText}`);
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
const triggerData = await triggerRes.json();
|
|
179
|
+
if (!triggerData.ok || !triggerData.suiteId) {
|
|
180
|
+
console.error(`Failed to trigger test run: ${triggerData.error ?? "Unknown error"}`);
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
const { suiteId } = triggerData;
|
|
184
|
+
console.log(`Test run started (suite: ${suiteId})`);
|
|
185
|
+
console.log("Streaming results...\n");
|
|
186
|
+
const streamUrl = `${apiUrl}/workflows/test-runs/stream?suiteId=${suiteId}`;
|
|
187
|
+
const streamRes = await fetchOrExit(streamUrl, {
|
|
188
|
+
headers: {
|
|
189
|
+
Authorization: `Bearer ${token}`,
|
|
190
|
+
Accept: "text/event-stream"
|
|
191
|
+
},
|
|
192
|
+
token,
|
|
193
|
+
// SSE connections stay open; don't let the default 30s timeout cut them off.
|
|
194
|
+
timeoutMs: 0
|
|
195
|
+
});
|
|
196
|
+
if (!streamRes.ok || !streamRes.body) {
|
|
197
|
+
console.error(`Failed to connect to event stream: ${streamRes.status}`);
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
let success = false;
|
|
201
|
+
let hasCompleted = false;
|
|
202
|
+
const parser = createParser({
|
|
203
|
+
onEvent: (event) => {
|
|
204
|
+
if (!event.data) return;
|
|
205
|
+
try {
|
|
206
|
+
const data = JSON.parse(event.data);
|
|
207
|
+
if (event.event === "workflow-test") {
|
|
208
|
+
const testEvent = data;
|
|
209
|
+
if (testEvent.status === "success") {
|
|
210
|
+
console.log(` \u2713 ${workflowName}`);
|
|
211
|
+
success = true;
|
|
212
|
+
} else if (testEvent.status === "failed") {
|
|
213
|
+
console.log(` \u2717 ${workflowName}`);
|
|
214
|
+
if (testEvent.errorMessage) {
|
|
215
|
+
console.log(` Error: ${testEvent.errorMessage.slice(0, 200)}`);
|
|
216
|
+
}
|
|
217
|
+
success = false;
|
|
218
|
+
} else if (testEvent.status === "running") {
|
|
219
|
+
console.log(` \u25B6 Running ${workflowName}...`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (event.event === "workflow-test-suite") {
|
|
223
|
+
const suiteEvent = data;
|
|
224
|
+
if (suiteEvent.status === "completed") {
|
|
225
|
+
hasCompleted = true;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
} catch {
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
const reader = streamRes.body.getReader();
|
|
233
|
+
const decoder = new TextDecoder();
|
|
234
|
+
let timedOut = false;
|
|
235
|
+
const timeoutHandle = setTimeout(() => {
|
|
236
|
+
timedOut = true;
|
|
237
|
+
reader.cancel().catch(() => {
|
|
238
|
+
});
|
|
239
|
+
}, timeoutMs);
|
|
240
|
+
try {
|
|
241
|
+
while (!hasCompleted) {
|
|
242
|
+
const { done, value } = await reader.read();
|
|
243
|
+
if (done) break;
|
|
244
|
+
parser.feed(decoder.decode(value, { stream: true }));
|
|
245
|
+
}
|
|
246
|
+
} finally {
|
|
247
|
+
clearTimeout(timeoutHandle);
|
|
248
|
+
try {
|
|
249
|
+
reader.releaseLock();
|
|
250
|
+
} catch {
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (!hasCompleted) {
|
|
254
|
+
const reason = timedOut ? `Timed out after ${Math.round(timeoutMs / 1e3)}s waiting for test completion` : "Event stream closed before completion";
|
|
255
|
+
console.warn(`
|
|
256
|
+
\u26A0 ${reason}. Polling final state...`);
|
|
257
|
+
const live = await fetchSuiteLiveState(apiUrl, token, suiteId);
|
|
258
|
+
const exec = live?.execution;
|
|
259
|
+
if (exec && (exec.status === "completed" || exec.status === "completed_with_errors")) {
|
|
260
|
+
success = exec.failedWorkflows === 0;
|
|
261
|
+
const match = live?.runs?.find((r) => r.workflowId === workflowId);
|
|
262
|
+
if (match) {
|
|
263
|
+
console.log(
|
|
264
|
+
success ? ` \u2713 ${workflowName}` : ` \u2717 ${workflowName}`
|
|
265
|
+
);
|
|
266
|
+
if (!success && match.errorMessage) {
|
|
267
|
+
console.log(` Error: ${match.errorMessage.slice(0, 200)}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
} else {
|
|
271
|
+
console.error(
|
|
272
|
+
`Suite ${suiteId} is still ${exec?.status ?? "unknown"} on the server. Check it in the web app.`
|
|
273
|
+
);
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (success) {
|
|
278
|
+
try {
|
|
279
|
+
const publishRes = await fetchOrExit(`${apiUrl}/workflows/${workflowId}/publish`, {
|
|
280
|
+
method: "POST",
|
|
281
|
+
headers: {
|
|
282
|
+
Authorization: `Bearer ${token}`,
|
|
283
|
+
"Content-Type": "application/json"
|
|
284
|
+
},
|
|
285
|
+
token
|
|
286
|
+
});
|
|
287
|
+
if (publishRes.ok) {
|
|
288
|
+
console.log(`
|
|
289
|
+
\u2713 Workflow published successfully`);
|
|
290
|
+
return true;
|
|
291
|
+
} else {
|
|
292
|
+
console.error(`
|
|
293
|
+
Failed to publish workflow: ${publishRes.status}`);
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
} catch (err) {
|
|
297
|
+
console.error(`
|
|
298
|
+
Failed to publish workflow: ${err}`);
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
console.log(`
|
|
303
|
+
\u2717 Test failed \u2014 workflow left in draft for manual review`);
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
async function readApiSequenceConfig(argv) {
|
|
307
|
+
const fromFile = getArgValue(argv, "--sequence-from-file");
|
|
308
|
+
const fromStdin = hasFlag(argv, "--sequence-from-stdin");
|
|
309
|
+
if (!fromFile && !fromStdin) {
|
|
310
|
+
return { steps: [] };
|
|
311
|
+
}
|
|
312
|
+
let raw;
|
|
313
|
+
if (fromFile) {
|
|
314
|
+
raw = await fs.readFile(fromFile, "utf-8");
|
|
315
|
+
} else {
|
|
316
|
+
raw = await readFromStdin();
|
|
317
|
+
}
|
|
318
|
+
let parsed;
|
|
319
|
+
try {
|
|
320
|
+
parsed = JSON.parse(raw);
|
|
321
|
+
} catch {
|
|
322
|
+
throw new Error("Invalid JSON for sequence config.");
|
|
323
|
+
}
|
|
324
|
+
const cfg = parsed;
|
|
325
|
+
if (!cfg || !Array.isArray(cfg.steps)) {
|
|
326
|
+
throw new Error('Sequence config must be { "steps": [...] }.');
|
|
327
|
+
}
|
|
328
|
+
return cfg;
|
|
329
|
+
}
|
|
330
|
+
async function handleApiOnlyCreate(argv, apiUrl, token) {
|
|
331
|
+
const name = getArgValue(argv, "--name");
|
|
332
|
+
if (!name) {
|
|
333
|
+
console.error("Error: --api-only requires --name <workflow name>.");
|
|
334
|
+
process2.exit(1);
|
|
335
|
+
}
|
|
336
|
+
const credentialId = getArgValue(argv, "--credential");
|
|
337
|
+
const guidance = getArgValue(argv, "--guidance");
|
|
338
|
+
let config;
|
|
339
|
+
try {
|
|
340
|
+
config = await readApiSequenceConfig(argv);
|
|
341
|
+
} catch (err) {
|
|
342
|
+
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
343
|
+
process2.exit(1);
|
|
344
|
+
}
|
|
345
|
+
const body = { name, config };
|
|
346
|
+
if (credentialId) body.credentialId = credentialId;
|
|
347
|
+
if (guidance) body.guidancePrompt = guidance;
|
|
348
|
+
const res = await fetchOrExit(`${apiUrl}/api-sequences/create-with-workflow`, {
|
|
349
|
+
method: "POST",
|
|
350
|
+
headers: {
|
|
351
|
+
Authorization: `Bearer ${token}`,
|
|
352
|
+
"Content-Type": "application/json"
|
|
353
|
+
},
|
|
354
|
+
token,
|
|
355
|
+
body: JSON.stringify(body)
|
|
356
|
+
});
|
|
357
|
+
const raw = await res.json().catch(() => ({}));
|
|
358
|
+
if (!res.ok || !raw.ok || !raw.data) {
|
|
359
|
+
const jsonOutput = hasFlag(argv, "--json") || !process2.stdout.isTTY;
|
|
360
|
+
if (raw.code === "API_SEQUENCE_INVALID" && Array.isArray(raw.issues)) {
|
|
361
|
+
if (jsonOutput) {
|
|
362
|
+
console.log(
|
|
363
|
+
JSON.stringify(
|
|
364
|
+
{
|
|
365
|
+
ok: false,
|
|
366
|
+
error: raw.error ?? "API sequence configuration is invalid",
|
|
367
|
+
code: "API_SEQUENCE_INVALID",
|
|
368
|
+
issueCount: raw.issues.length,
|
|
369
|
+
issues: raw.issues
|
|
370
|
+
},
|
|
371
|
+
null,
|
|
372
|
+
2
|
|
373
|
+
)
|
|
374
|
+
);
|
|
375
|
+
} else {
|
|
376
|
+
console.error(`Error: ${raw.error ?? "API sequence configuration is invalid"}`);
|
|
377
|
+
console.error(`Found ${raw.issues.length} issue${raw.issues.length === 1 ? "" : "s"}:`);
|
|
378
|
+
console.error("");
|
|
379
|
+
for (const issue of raw.issues) {
|
|
380
|
+
console.error(` ${issue.path || "(root)"} [${issue.code}] :: ${issue.message}`);
|
|
381
|
+
if (issue.hint) console.error(` hint: ${issue.hint}`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
process2.exit(1);
|
|
385
|
+
}
|
|
386
|
+
console.error(`Error: ${raw.error ?? `${res.status} ${res.statusText}`}`);
|
|
387
|
+
process2.exit(1);
|
|
388
|
+
}
|
|
389
|
+
if (hasFlag(argv, "--json") || !process2.stdout.isTTY) {
|
|
390
|
+
console.log(JSON.stringify({ ok: true, data: raw.data }, null, 2));
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
const appUrl = resolveAppUrl(apiUrl);
|
|
394
|
+
console.log(`
|
|
395
|
+
\u2713 API-only workflow created: ${name}`);
|
|
396
|
+
console.log(` Workflow ID: ${raw.data.workflowId}`);
|
|
397
|
+
console.log(` API Sequence ID: ${raw.data.apiSequenceId}`);
|
|
398
|
+
if (raw.data.nodeId) console.log(` Node ID: ${raw.data.nodeId}`);
|
|
399
|
+
console.log(` Steps: ${config.steps.length}`);
|
|
400
|
+
console.log(` URL: ${appUrl}/flows/${raw.data.workflowId}`);
|
|
401
|
+
console.log(`
|
|
402
|
+
Next:`);
|
|
403
|
+
console.log(` canary workflow api-sequence get ${raw.data.workflowId}`);
|
|
404
|
+
console.log(` canary workflow api-sequence update ${raw.data.workflowId} --from-file seq.json`);
|
|
405
|
+
console.log(` canary workflow api-sequence execute ${raw.data.workflowId}`);
|
|
406
|
+
}
|
|
407
|
+
async function handleWorkflowCreate(argv, apiUrl, token) {
|
|
408
|
+
if (hasFlag(argv, "--api-only")) {
|
|
409
|
+
await handleApiOnlyCreate(argv, apiUrl, token);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
const runAndPublishFlag = hasFlag(argv, "--run-and-publish");
|
|
413
|
+
const propertyArg = getArgValue(argv, "--property");
|
|
414
|
+
const environmentArg = getArgValue(argv, "--environment");
|
|
415
|
+
const timeoutArg = getArgValue(argv, "--publish-timeout");
|
|
416
|
+
const timeoutSec = timeoutArg ? Number.parseInt(timeoutArg, 10) : 900;
|
|
417
|
+
if (!Number.isFinite(timeoutSec) || timeoutSec <= 0) {
|
|
418
|
+
console.error(`Error: --publish-timeout must be a positive integer (seconds), got "${timeoutArg}"`);
|
|
419
|
+
process2.exit(1);
|
|
420
|
+
}
|
|
421
|
+
let input;
|
|
422
|
+
try {
|
|
423
|
+
input = await readWorkflowInput(argv);
|
|
424
|
+
} catch (err) {
|
|
425
|
+
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
426
|
+
process2.exit(1);
|
|
427
|
+
}
|
|
428
|
+
const loginNodes = input.nodes.filter((n) => n.nodeType === "login");
|
|
429
|
+
for (const loginNode of loginNodes) {
|
|
430
|
+
const config = loginNode.configJson ?? {};
|
|
431
|
+
if (!config.credentialId && !input.credentialId) {
|
|
432
|
+
try {
|
|
433
|
+
const resolved = await resolveCredential(
|
|
434
|
+
apiUrl,
|
|
435
|
+
token,
|
|
436
|
+
config.credentialName
|
|
437
|
+
);
|
|
438
|
+
config.credentialId = resolved.credentialId;
|
|
439
|
+
config.credentialName = resolved.credentialName;
|
|
440
|
+
loginNode.configJson = config;
|
|
441
|
+
if (resolved.propertyId && !input.propertyId) {
|
|
442
|
+
input.propertyId = resolved.propertyId;
|
|
443
|
+
}
|
|
444
|
+
} catch (err) {
|
|
445
|
+
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
446
|
+
process2.exit(1);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
input.createdVia = "cli";
|
|
451
|
+
const res = await fetchOrExit(`${apiUrl}/workflows/create-with-nodes`, {
|
|
452
|
+
method: "POST",
|
|
453
|
+
headers: {
|
|
454
|
+
Authorization: `Bearer ${token}`,
|
|
455
|
+
"Content-Type": "application/json"
|
|
456
|
+
},
|
|
457
|
+
token,
|
|
458
|
+
body: JSON.stringify(input)
|
|
459
|
+
});
|
|
460
|
+
const result = await res.json();
|
|
461
|
+
if (!result.ok || !result.workflow) {
|
|
462
|
+
console.error(`Error: ${result.error ?? "Failed to create workflow"}`);
|
|
463
|
+
process2.exit(1);
|
|
464
|
+
}
|
|
465
|
+
const { workflow, nodes, edges } = result;
|
|
466
|
+
const { warnings } = result;
|
|
467
|
+
console.log(`
|
|
468
|
+
\u2713 Workflow created: ${workflow.name}`);
|
|
469
|
+
console.log(` ID: ${workflow.id}`);
|
|
470
|
+
console.log(` Status: ${workflow.status}`);
|
|
471
|
+
console.log(` Nodes: ${nodes?.length ?? 0}`);
|
|
472
|
+
console.log(` Edges: ${edges?.length ?? 0}`);
|
|
473
|
+
if (warnings && warnings.length > 0) {
|
|
474
|
+
console.log(`
|
|
475
|
+
Warnings:`);
|
|
476
|
+
for (const w of warnings) {
|
|
477
|
+
console.log(` \u26A0 ${w}`);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
const appUrl = resolveAppUrl(apiUrl);
|
|
481
|
+
console.log(` URL: ${appUrl}/flows/${workflow.id}`);
|
|
482
|
+
try {
|
|
483
|
+
const tagResult = await applyWorkflowTagFlags({
|
|
484
|
+
apiUrl,
|
|
485
|
+
token,
|
|
486
|
+
argv,
|
|
487
|
+
workflowId: workflow.id
|
|
488
|
+
});
|
|
489
|
+
if (tagResult) {
|
|
490
|
+
console.log(` Tags: ${tagResult.tagIds.length} assigned`);
|
|
491
|
+
}
|
|
492
|
+
} catch (err) {
|
|
493
|
+
console.error(`
|
|
494
|
+
Error applying tags:
|
|
495
|
+
${err instanceof Error ? err.message : String(err)}`);
|
|
496
|
+
process2.exit(1);
|
|
497
|
+
}
|
|
498
|
+
if (runAndPublishFlag) {
|
|
499
|
+
console.log("\nTriggering test run...");
|
|
500
|
+
const passed = await runAndPublish({
|
|
501
|
+
apiUrl,
|
|
502
|
+
token,
|
|
503
|
+
workflowId: workflow.id,
|
|
504
|
+
workflowName: workflow.name,
|
|
505
|
+
propertyId: propertyArg ?? input.propertyId,
|
|
506
|
+
environmentId: environmentArg,
|
|
507
|
+
timeoutMs: timeoutSec * 1e3
|
|
508
|
+
});
|
|
509
|
+
if (!passed) {
|
|
510
|
+
process2.exit(1);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// src/workflow-run.ts
|
|
516
|
+
import process3 from "process";
|
|
517
|
+
|
|
518
|
+
// src/cli-format.ts
|
|
519
|
+
var STATUS_ICONS = {
|
|
520
|
+
completed: "\u2705",
|
|
521
|
+
completed_with_errors: "\u26A0\uFE0F",
|
|
522
|
+
success: "\u2705",
|
|
523
|
+
passed: "\u2705",
|
|
524
|
+
failed: "\u274C",
|
|
525
|
+
running: "\u{1F535}",
|
|
526
|
+
queued: "\u23F3",
|
|
527
|
+
canceled: "\u{1F6AB}",
|
|
528
|
+
timeout: "\u23F0",
|
|
529
|
+
skipped: "\u23ED\uFE0F",
|
|
530
|
+
flaky_success: "\u{1F7E1}",
|
|
531
|
+
pending: "\u23F3"
|
|
532
|
+
};
|
|
533
|
+
function statusIcon(status) {
|
|
534
|
+
return STATUS_ICONS[status] ?? "\u2753";
|
|
535
|
+
}
|
|
536
|
+
function formatRelative(iso) {
|
|
537
|
+
if (!iso) return "\u2014";
|
|
538
|
+
const diffMs = Date.now() - new Date(iso).getTime();
|
|
539
|
+
if (diffMs < 0) return "just now";
|
|
540
|
+
const seconds = Math.floor(diffMs / 1e3);
|
|
541
|
+
if (seconds < 60) return "just now";
|
|
542
|
+
const minutes = Math.floor(seconds / 60);
|
|
543
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
544
|
+
const hours = Math.floor(minutes / 60);
|
|
545
|
+
if (hours < 24) return `${hours}h ago`;
|
|
546
|
+
const days = Math.floor(hours / 24);
|
|
547
|
+
return `${days}d ago`;
|
|
548
|
+
}
|
|
549
|
+
function computeDuration(startedAt, finishedAt) {
|
|
550
|
+
if (!startedAt || !finishedAt) return "\u2014";
|
|
551
|
+
const ms = new Date(finishedAt).getTime() - new Date(startedAt).getTime();
|
|
552
|
+
if (ms < 0) return "\u2014";
|
|
553
|
+
const totalSeconds = Math.floor(ms / 1e3);
|
|
554
|
+
if (totalSeconds < 60) return `${totalSeconds}s`;
|
|
555
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
556
|
+
const seconds = totalSeconds % 60;
|
|
557
|
+
if (minutes < 60) return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
|
|
558
|
+
const hours = Math.floor(minutes / 60);
|
|
559
|
+
const remainMins = minutes % 60;
|
|
560
|
+
return remainMins > 0 ? `${hours}h ${remainMins}m` : `${hours}h`;
|
|
561
|
+
}
|
|
562
|
+
function pad(str, len) {
|
|
563
|
+
return str.padEnd(len);
|
|
564
|
+
}
|
|
565
|
+
function readFromStdin2() {
|
|
566
|
+
const stdin = process.stdin;
|
|
567
|
+
if (stdin.isTTY) {
|
|
568
|
+
return Promise.reject(
|
|
569
|
+
new Error("No input on stdin. Pipe JSON or use --from-file.")
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
const chunks = [];
|
|
573
|
+
return new Promise((resolve, reject) => {
|
|
574
|
+
stdin.on("data", (chunk) => {
|
|
575
|
+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
576
|
+
});
|
|
577
|
+
stdin.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
|
578
|
+
stdin.on("error", reject);
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// src/workflow-run.ts
|
|
583
|
+
function sleep(ms) {
|
|
584
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
585
|
+
}
|
|
586
|
+
async function handleWorkflowRun(argv, apiUrl, token) {
|
|
587
|
+
const workflowId = argv.find((a) => !a.startsWith("-"));
|
|
588
|
+
if (!workflowId) {
|
|
589
|
+
console.error("Error: Missing workflow ID.");
|
|
590
|
+
console.error(
|
|
591
|
+
"Usage: canary workflow run <workflowId> [--stream] [--json] [--validation | --no-validation]"
|
|
592
|
+
);
|
|
593
|
+
process3.exit(1);
|
|
594
|
+
}
|
|
595
|
+
const jsonOutput = hasFlag(argv, "--json");
|
|
596
|
+
const stream = hasFlag(argv, "--stream");
|
|
597
|
+
const optInValidation = hasFlag(argv, "--validation");
|
|
598
|
+
const optOutValidation = hasFlag(argv, "--no-validation");
|
|
599
|
+
const validationMode = optInValidation ? true : optOutValidation ? false : void 0;
|
|
600
|
+
const requestBody = {
|
|
601
|
+
triggerSource: "cli_direct"
|
|
602
|
+
};
|
|
603
|
+
if (validationMode !== void 0) {
|
|
604
|
+
requestBody.validationMode = validationMode;
|
|
605
|
+
}
|
|
606
|
+
const result = await apiRequest(
|
|
607
|
+
apiUrl,
|
|
608
|
+
token,
|
|
609
|
+
"POST",
|
|
610
|
+
`/workflows/${workflowId}/execute`,
|
|
611
|
+
requestBody
|
|
612
|
+
);
|
|
613
|
+
if (!result.ok) {
|
|
614
|
+
if (jsonOutput) {
|
|
615
|
+
console.log(JSON.stringify(result, null, 2));
|
|
616
|
+
} else {
|
|
617
|
+
const rendered = renderWorkflowUserError(result);
|
|
618
|
+
console.error(rendered ?? `Error: ${result.error ?? "Unknown error"}`);
|
|
619
|
+
}
|
|
620
|
+
process3.exit(1);
|
|
621
|
+
}
|
|
622
|
+
const { runId } = result;
|
|
623
|
+
if (jsonOutput && !stream) {
|
|
624
|
+
console.log(JSON.stringify({ ok: true, runId, streamId: result.streamId }, null, 2));
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
if (!stream) {
|
|
628
|
+
console.log(`Run triggered successfully.`);
|
|
629
|
+
console.log(` Run ID: ${runId}`);
|
|
630
|
+
console.log(`
|
|
631
|
+
Check status: canary workflow runs get ${workflowId} ${runId}`);
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
console.log(`Run triggered (${runId}). Waiting for completion...
|
|
635
|
+
`);
|
|
636
|
+
const terminalStatuses = /* @__PURE__ */ new Set([
|
|
637
|
+
"completed",
|
|
638
|
+
"success",
|
|
639
|
+
"passed",
|
|
640
|
+
"failed",
|
|
641
|
+
"canceled",
|
|
642
|
+
"timeout"
|
|
643
|
+
]);
|
|
644
|
+
let lastStatus = "";
|
|
645
|
+
for (; ; ) {
|
|
646
|
+
await sleep(3e3);
|
|
647
|
+
const status = await apiRequest(
|
|
648
|
+
apiUrl,
|
|
649
|
+
token,
|
|
650
|
+
"GET",
|
|
651
|
+
`/workflows/${workflowId}/runs/${runId}`
|
|
652
|
+
);
|
|
653
|
+
if (!status.ok) {
|
|
654
|
+
console.error(`Error polling status: ${status.error ?? "Unknown error"}`);
|
|
655
|
+
process3.exit(1);
|
|
656
|
+
}
|
|
657
|
+
const run = status.data.run;
|
|
658
|
+
if (run.status !== lastStatus) {
|
|
659
|
+
console.log(` ${statusIcon(run.status)} ${run.status}`);
|
|
660
|
+
lastStatus = run.status;
|
|
661
|
+
}
|
|
662
|
+
if (terminalStatuses.has(run.status)) {
|
|
663
|
+
if (run.errorMessage) {
|
|
664
|
+
console.log(`
|
|
665
|
+
Error: ${run.errorMessage}`);
|
|
666
|
+
}
|
|
667
|
+
console.log(`
|
|
668
|
+
Run finished: ${run.status}`);
|
|
669
|
+
console.log(`Details: canary workflow runs get ${workflowId} ${runId}`);
|
|
670
|
+
if (run.status === "failed" || run.status === "timeout" || run.status === "canceled") {
|
|
671
|
+
process3.exit(1);
|
|
672
|
+
}
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// src/workflow-runs.ts
|
|
679
|
+
import process4 from "process";
|
|
680
|
+
async function fetchBrowserRuns(apiUrl, token, workflowId, page, pageSize) {
|
|
681
|
+
const params = new URLSearchParams({ page, pageSize });
|
|
682
|
+
const result = await apiRequest(
|
|
683
|
+
apiUrl,
|
|
684
|
+
token,
|
|
685
|
+
"GET",
|
|
686
|
+
`/workflows/${workflowId}/runs?${params}`
|
|
687
|
+
);
|
|
688
|
+
if (!result.ok) return { error: result.error ?? "Unknown error" };
|
|
689
|
+
return { runs: result.data.runs, pagination: result.pagination };
|
|
690
|
+
}
|
|
691
|
+
async function fetchApiRuns(apiUrl, token, workflowId, page, pageSize) {
|
|
692
|
+
const seqRes = await apiRequest(
|
|
693
|
+
apiUrl,
|
|
694
|
+
token,
|
|
695
|
+
"GET",
|
|
696
|
+
`/api-sequences/by-workflow/${workflowId}`
|
|
697
|
+
);
|
|
698
|
+
if (!seqRes.ok) {
|
|
699
|
+
return {
|
|
700
|
+
error: seqRes.error ?? "Unknown error",
|
|
701
|
+
...seqRes.code ? { code: seqRes.code } : {}
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
if (!seqRes.data) return { runs: [], pagination: null };
|
|
705
|
+
const params = new URLSearchParams({ page, pageSize });
|
|
706
|
+
const runsRes = await apiRequest(
|
|
707
|
+
apiUrl,
|
|
708
|
+
token,
|
|
709
|
+
"GET",
|
|
710
|
+
`/api-sequences/${seqRes.data.id}/runs?${params}`
|
|
711
|
+
);
|
|
712
|
+
if (!runsRes.ok) return { error: runsRes.error ?? "Unknown error" };
|
|
713
|
+
const runs = (runsRes.data ?? []).map((r) => ({
|
|
714
|
+
id: r.id,
|
|
715
|
+
status: r.status,
|
|
716
|
+
createdAt: r.createdAt ?? r.startedAt ?? (/* @__PURE__ */ new Date(0)).toISOString(),
|
|
717
|
+
startedAt: r.startedAt,
|
|
718
|
+
finishedAt: r.finishedAt,
|
|
719
|
+
errorMessage: r.errorMessage,
|
|
720
|
+
browserMode: null,
|
|
721
|
+
stepTotals: null,
|
|
722
|
+
snapshotVersion: null
|
|
723
|
+
}));
|
|
724
|
+
return { runs, pagination: runsRes.pagination ?? null };
|
|
725
|
+
}
|
|
726
|
+
async function handleRunsList(argv, apiUrl, token) {
|
|
727
|
+
const workflowId = argv.find((a) => !a.startsWith("-"));
|
|
728
|
+
if (!workflowId) {
|
|
729
|
+
console.error("Error: Missing workflow ID. Usage: canary workflow runs list <workflowId>");
|
|
730
|
+
process4.exit(1);
|
|
731
|
+
}
|
|
732
|
+
const jsonOutput = hasFlag(argv, "--json");
|
|
733
|
+
const page = getArgValue(argv, "--page") ?? "1";
|
|
734
|
+
const pageSize = getArgValue(argv, "--page-size") ?? "10";
|
|
735
|
+
const explicitSide = getArgValue(argv, "--side") ?? getArgValue(argv, "--mode");
|
|
736
|
+
const sideArg = (explicitSide ?? "all").toLowerCase();
|
|
737
|
+
if (!["all", "api", "browser"].includes(sideArg)) {
|
|
738
|
+
console.error(`Error: --side must be one of: all, api, browser (got "${sideArg}")`);
|
|
739
|
+
process4.exit(1);
|
|
740
|
+
}
|
|
741
|
+
const annotated = [];
|
|
742
|
+
let browserPagination = null;
|
|
743
|
+
let apiPagination = null;
|
|
744
|
+
if (sideArg === "browser" || sideArg === "all") {
|
|
745
|
+
const browser = await fetchBrowserRuns(apiUrl, token, workflowId, page, pageSize);
|
|
746
|
+
if ("error" in browser) {
|
|
747
|
+
console.error(`Error: ${browser.error}`);
|
|
748
|
+
process4.exit(1);
|
|
749
|
+
}
|
|
750
|
+
browserPagination = browser.pagination;
|
|
751
|
+
for (const r of browser.runs) annotated.push({ ...r, side: "browser" });
|
|
752
|
+
}
|
|
753
|
+
if (sideArg === "api" || sideArg === "all") {
|
|
754
|
+
const api = await fetchApiRuns(apiUrl, token, workflowId, page, pageSize);
|
|
755
|
+
if ("error" in api) {
|
|
756
|
+
const isMissingSequence = api.code === "API_SEQUENCE_NOT_FOUND";
|
|
757
|
+
if (!explicitSide && isMissingSequence) {
|
|
758
|
+
if (!jsonOutput) {
|
|
759
|
+
console.error("Note: workflow has no api-sequence \u2014 showing browser runs only.");
|
|
760
|
+
}
|
|
761
|
+
} else {
|
|
762
|
+
console.error(`Error: ${api.error}`);
|
|
763
|
+
process4.exit(1);
|
|
764
|
+
}
|
|
765
|
+
} else {
|
|
766
|
+
apiPagination = api.pagination;
|
|
767
|
+
for (const r of api.runs) annotated.push({ ...r, side: "api" });
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
annotated.sort((a, b) => {
|
|
771
|
+
const ta = new Date(a.startedAt ?? a.createdAt).getTime();
|
|
772
|
+
const tb = new Date(b.startedAt ?? b.createdAt).getTime();
|
|
773
|
+
return tb - ta;
|
|
774
|
+
});
|
|
775
|
+
if (jsonOutput) {
|
|
776
|
+
console.log(
|
|
777
|
+
JSON.stringify(
|
|
778
|
+
{
|
|
779
|
+
ok: true,
|
|
780
|
+
data: { runs: annotated },
|
|
781
|
+
pagination: { browser: browserPagination, api: apiPagination }
|
|
782
|
+
},
|
|
783
|
+
null,
|
|
784
|
+
2
|
|
785
|
+
)
|
|
786
|
+
);
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
if (annotated.length === 0) {
|
|
790
|
+
console.log("No runs found.");
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
console.log(`Runs (${annotated.length} shown, side=${sideArg})
|
|
794
|
+
`);
|
|
795
|
+
const idW = 36;
|
|
796
|
+
const sideW = 8;
|
|
797
|
+
const statusW = 12;
|
|
798
|
+
const durationW = 10;
|
|
799
|
+
console.log(
|
|
800
|
+
[
|
|
801
|
+
"ID".padEnd(idW),
|
|
802
|
+
"Side".padEnd(sideW),
|
|
803
|
+
"Status".padEnd(statusW),
|
|
804
|
+
"Duration".padEnd(durationW),
|
|
805
|
+
"Started"
|
|
806
|
+
].join(" ")
|
|
807
|
+
);
|
|
808
|
+
for (const run of annotated) {
|
|
809
|
+
const icon = statusIcon(run.status);
|
|
810
|
+
const dur = computeDuration(run.startedAt, run.finishedAt);
|
|
811
|
+
const time = formatRelative(run.startedAt ?? run.createdAt);
|
|
812
|
+
console.log(
|
|
813
|
+
[
|
|
814
|
+
run.id.padEnd(idW),
|
|
815
|
+
pad(run.side, sideW),
|
|
816
|
+
`${icon} ${pad(run.status, statusW - 3)}`,
|
|
817
|
+
pad(dur, durationW),
|
|
818
|
+
time
|
|
819
|
+
].join(" ")
|
|
820
|
+
);
|
|
821
|
+
}
|
|
822
|
+
console.log("");
|
|
823
|
+
}
|
|
824
|
+
async function handleRunsGet(argv, apiUrl, token) {
|
|
825
|
+
const positional = argv.filter((a) => !a.startsWith("-"));
|
|
826
|
+
if (positional.length < 2) {
|
|
827
|
+
console.error(
|
|
828
|
+
"Error: Missing arguments. Usage: canary workflow runs get <workflowId> <runId>"
|
|
829
|
+
);
|
|
830
|
+
process4.exit(1);
|
|
831
|
+
}
|
|
832
|
+
const [workflowId, runId] = positional;
|
|
833
|
+
const jsonOutput = hasFlag(argv, "--json");
|
|
834
|
+
const result = await apiRequest(
|
|
835
|
+
apiUrl,
|
|
836
|
+
token,
|
|
837
|
+
"GET",
|
|
838
|
+
`/workflows/${workflowId}/runs/${runId}`
|
|
839
|
+
);
|
|
840
|
+
if (!result.ok) {
|
|
841
|
+
console.error(`Error: ${result.error ?? "Unknown error"}`);
|
|
842
|
+
process4.exit(1);
|
|
843
|
+
}
|
|
844
|
+
if (jsonOutput) {
|
|
845
|
+
console.log(JSON.stringify(result, null, 2));
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
const { run } = result.data;
|
|
849
|
+
const duration = computeDuration(run.startedAt, run.finishedAt);
|
|
850
|
+
console.log(`Run: ${run.id}`);
|
|
851
|
+
console.log(`Status: ${statusIcon(run.status)} ${run.status}`);
|
|
852
|
+
console.log(`Duration: ${duration}`);
|
|
853
|
+
if (run.errorMessage) {
|
|
854
|
+
console.log(`Error: ${run.errorMessage}`);
|
|
855
|
+
}
|
|
856
|
+
if (run.draftSnapshot) {
|
|
857
|
+
console.log(`Snapshot: v${run.draftSnapshot.version} (base: v${run.draftSnapshot.basePublishedVersion})`);
|
|
858
|
+
}
|
|
859
|
+
const steps = (run.steps ?? []).sort((a, b) => a.orderIndex - b.orderIndex);
|
|
860
|
+
if (steps.length === 0) {
|
|
861
|
+
console.log("\nNo steps recorded.");
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
console.log(`
|
|
865
|
+
Steps (${steps.length}):`);
|
|
866
|
+
for (const step of steps) {
|
|
867
|
+
const idx = String(step.orderIndex + 1).padStart(2);
|
|
868
|
+
const icon = statusIcon(step.status);
|
|
869
|
+
const nodeId = step.nodeId.slice(0, 8);
|
|
870
|
+
const dur = computeDuration(step.startedAt, step.finishedAt);
|
|
871
|
+
console.log(` ${idx}. ${icon} ${pad(nodeId, 10)} ${dur}`);
|
|
872
|
+
if (step.errorMessage) {
|
|
873
|
+
console.log(` Error: ${step.errorMessage}`);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
console.log("");
|
|
877
|
+
}
|
|
878
|
+
async function handleRunsDiagnostics(argv, apiUrl, token) {
|
|
879
|
+
const positional = argv.filter((a) => !a.startsWith("-"));
|
|
880
|
+
if (positional.length < 2) {
|
|
881
|
+
console.error(
|
|
882
|
+
"Error: Missing arguments. Usage: canary workflow runs diagnostics <workflowId> <runId>"
|
|
883
|
+
);
|
|
884
|
+
process4.exit(1);
|
|
885
|
+
}
|
|
886
|
+
const [workflowId, runId] = positional;
|
|
887
|
+
const jsonOutput = hasFlag(argv, "--json");
|
|
888
|
+
const result = await apiRequest(
|
|
889
|
+
apiUrl,
|
|
890
|
+
token,
|
|
891
|
+
"GET",
|
|
892
|
+
`/workflows/${workflowId}/runs/${runId}/failure-diagnostic`
|
|
893
|
+
);
|
|
894
|
+
if (!result.ok) {
|
|
895
|
+
console.error(`Error: ${result.error ?? "Unknown error"}`);
|
|
896
|
+
process4.exit(1);
|
|
897
|
+
}
|
|
898
|
+
if (jsonOutput && result.data) {
|
|
899
|
+
console.log(JSON.stringify(result, null, 2));
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
if (result.data) {
|
|
903
|
+
const { status, diagnostic } = result.data;
|
|
904
|
+
if (status === "pending" || status === "running") {
|
|
905
|
+
console.log(`Diagnostic is still in progress (status: ${status}).`);
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
if (status === "failed") {
|
|
909
|
+
console.log("Diagnostic analysis failed.");
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
if (diagnostic) {
|
|
913
|
+
console.log("Failure Diagnostic");
|
|
914
|
+
console.log(`Status: ${status}`);
|
|
915
|
+
if (diagnostic.category) {
|
|
916
|
+
const conf = diagnostic.confidence !== null && diagnostic.confidence !== void 0 ? ` (${diagnostic.confidence > 1 ? Math.round(diagnostic.confidence) : Math.round(diagnostic.confidence * 100)}% confidence)` : "";
|
|
917
|
+
console.log(`Category: ${diagnostic.category}${conf}`);
|
|
918
|
+
}
|
|
919
|
+
if (diagnostic.title) {
|
|
920
|
+
console.log(`Title: ${diagnostic.title}`);
|
|
921
|
+
}
|
|
922
|
+
if (diagnostic.analysis) {
|
|
923
|
+
console.log("");
|
|
924
|
+
console.log("Analysis:");
|
|
925
|
+
console.log(` ${diagnostic.analysis}`);
|
|
926
|
+
}
|
|
927
|
+
if (diagnostic.recommendation) {
|
|
928
|
+
console.log("");
|
|
929
|
+
console.log("Recommendation:");
|
|
930
|
+
console.log(` ${diagnostic.recommendation}`);
|
|
931
|
+
}
|
|
932
|
+
if (diagnostic.smokingGun) {
|
|
933
|
+
console.log("");
|
|
934
|
+
console.log("Smoking Gun:");
|
|
935
|
+
console.log(` > ${diagnostic.smokingGun.description}`);
|
|
936
|
+
if (diagnostic.smokingGun.detail) {
|
|
937
|
+
console.log(` ${diagnostic.smokingGun.detail}`);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
console.log("");
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
const rawResult = await apiRequest(
|
|
945
|
+
apiUrl,
|
|
946
|
+
token,
|
|
947
|
+
"GET",
|
|
948
|
+
`/workflows/${workflowId}/runs/${runId}/diagnostics`
|
|
949
|
+
);
|
|
950
|
+
if (!rawResult.ok) {
|
|
951
|
+
console.log("No diagnostic available for this run.");
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
if (jsonOutput) {
|
|
955
|
+
console.log(JSON.stringify(rawResult, null, 2));
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
const hasConsole = rawResult.consoleLogs && rawResult.consoleLogs.length > 0;
|
|
959
|
+
const hasNetwork = rawResult.networkRequests && rawResult.networkRequests.length > 0;
|
|
960
|
+
if (!hasConsole && !hasNetwork) {
|
|
961
|
+
console.log("No diagnostic data available for this run.");
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
if (hasConsole) {
|
|
965
|
+
console.log(`Console Logs (${rawResult.consoleLogs.length}):`);
|
|
966
|
+
for (const log of rawResult.consoleLogs.slice(0, 50)) {
|
|
967
|
+
const level = log.level.toUpperCase().padEnd(7);
|
|
968
|
+
console.log(` [${level}] ${log.text}`);
|
|
969
|
+
}
|
|
970
|
+
if (rawResult.consoleLogs.length > 50) {
|
|
971
|
+
console.log(` ... and ${rawResult.consoleLogs.length - 50} more`);
|
|
972
|
+
}
|
|
973
|
+
console.log("");
|
|
974
|
+
}
|
|
975
|
+
if (hasNetwork) {
|
|
976
|
+
console.log(`Network Requests (${rawResult.networkRequests.length}):`);
|
|
977
|
+
for (const req of rawResult.networkRequests.slice(0, 30)) {
|
|
978
|
+
const statusStr = req.status >= 400 ? `${req.status} !!!` : String(req.status);
|
|
979
|
+
console.log(` ${req.method.padEnd(6)} ${statusStr.padEnd(8)} ${req.url}`);
|
|
980
|
+
}
|
|
981
|
+
if (rawResult.networkRequests.length > 30) {
|
|
982
|
+
console.log(` ... and ${rawResult.networkRequests.length - 30} more`);
|
|
983
|
+
}
|
|
984
|
+
console.log("");
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
function printRunsHelp() {
|
|
988
|
+
console.log(
|
|
989
|
+
[
|
|
990
|
+
"Usage: canary workflow runs <sub-command> [options]",
|
|
991
|
+
"",
|
|
992
|
+
"Sub-commands:",
|
|
993
|
+
" list <workflowId> [options] List runs for a workflow",
|
|
994
|
+
" get <workflowId> <runId> [options] Get run detail with steps",
|
|
995
|
+
" diagnostics <workflowId> <runId> [options] Get failure diagnostic",
|
|
996
|
+
"",
|
|
997
|
+
"List options:",
|
|
998
|
+
" --page <n> Page number (default: 1)",
|
|
999
|
+
" --page-size <n> Page size (default: 10)",
|
|
1000
|
+
" --side <side> Filter: browser, api, all (default: all). Alias: --mode",
|
|
1001
|
+
"",
|
|
1002
|
+
"Output options:",
|
|
1003
|
+
" --json Output raw JSON",
|
|
1004
|
+
"",
|
|
1005
|
+
"Common options:",
|
|
1006
|
+
" --env <env> Target environment (prod, dev, local)",
|
|
1007
|
+
" --api-url <url> API URL override",
|
|
1008
|
+
" --token <key> API token override"
|
|
1009
|
+
].join("\n")
|
|
1010
|
+
);
|
|
1011
|
+
}
|
|
1012
|
+
async function runWorkflowRuns(argv, apiUrl, token) {
|
|
1013
|
+
const [subCommand, ...rest] = argv;
|
|
1014
|
+
if (!subCommand || subCommand === "help" || hasFlag(argv, "--help", "-h")) {
|
|
1015
|
+
printRunsHelp();
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
switch (subCommand) {
|
|
1019
|
+
case "list":
|
|
1020
|
+
await handleRunsList(rest, apiUrl, token);
|
|
1021
|
+
break;
|
|
1022
|
+
case "get":
|
|
1023
|
+
await handleRunsGet(rest, apiUrl, token);
|
|
1024
|
+
break;
|
|
1025
|
+
case "diagnostics":
|
|
1026
|
+
await handleRunsDiagnostics(rest, apiUrl, token);
|
|
1027
|
+
break;
|
|
1028
|
+
default:
|
|
1029
|
+
console.error(`Unknown sub-command: ${subCommand}`);
|
|
1030
|
+
printRunsHelp();
|
|
1031
|
+
process4.exit(1);
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// src/workflow-sync.ts
|
|
1036
|
+
import crypto from "crypto";
|
|
1037
|
+
import fs2 from "fs/promises";
|
|
1038
|
+
import process5 from "process";
|
|
1039
|
+
function normalizeNodesAndEdges(rawNodes, rawEdges) {
|
|
1040
|
+
const nodes = rawNodes.map((node, i) => ({
|
|
1041
|
+
...node,
|
|
1042
|
+
id: node.id ?? crypto.randomUUID(),
|
|
1043
|
+
orderIndex: node.orderIndex ?? i,
|
|
1044
|
+
configJson: node.configJson ?? {}
|
|
1045
|
+
}));
|
|
1046
|
+
if (rawEdges && rawEdges.length > 0) {
|
|
1047
|
+
const edges2 = rawEdges.map((edge) => ({
|
|
1048
|
+
...edge,
|
|
1049
|
+
id: edge.id ?? crypto.randomUUID()
|
|
1050
|
+
}));
|
|
1051
|
+
return { nodes, edges: edges2 };
|
|
1052
|
+
}
|
|
1053
|
+
const edges = [];
|
|
1054
|
+
for (let i = 0; i < nodes.length - 1; i++) {
|
|
1055
|
+
edges.push({
|
|
1056
|
+
id: crypto.randomUUID(),
|
|
1057
|
+
sourceNodeId: nodes[i].id,
|
|
1058
|
+
targetNodeId: nodes[i + 1].id,
|
|
1059
|
+
edgeType: "default"
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
1062
|
+
return { nodes, edges };
|
|
1063
|
+
}
|
|
1064
|
+
async function readSyncInput(argv) {
|
|
1065
|
+
const filePath = getArgValue(argv, "--from-file");
|
|
1066
|
+
const fromStdin = hasFlag(argv, "--from-stdin");
|
|
1067
|
+
let raw;
|
|
1068
|
+
if (filePath) {
|
|
1069
|
+
try {
|
|
1070
|
+
raw = await fs2.readFile(filePath, "utf-8");
|
|
1071
|
+
} catch (err) {
|
|
1072
|
+
console.error(`Error reading file: ${err instanceof Error ? err.message : String(err)}`);
|
|
1073
|
+
process5.exit(1);
|
|
1074
|
+
}
|
|
1075
|
+
} else if (fromStdin) {
|
|
1076
|
+
try {
|
|
1077
|
+
raw = await readFromStdin2();
|
|
1078
|
+
} catch (err) {
|
|
1079
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
1080
|
+
process5.exit(1);
|
|
1081
|
+
}
|
|
1082
|
+
} else {
|
|
1083
|
+
console.error("Error: Must provide --from-file <path> or --from-stdin");
|
|
1084
|
+
process5.exit(1);
|
|
1085
|
+
}
|
|
1086
|
+
let parsed;
|
|
1087
|
+
try {
|
|
1088
|
+
parsed = JSON.parse(raw);
|
|
1089
|
+
} catch {
|
|
1090
|
+
console.error("Error: Invalid JSON input");
|
|
1091
|
+
process5.exit(1);
|
|
1092
|
+
}
|
|
1093
|
+
const obj = parsed;
|
|
1094
|
+
const normalizeNodes = (raw2) => {
|
|
1095
|
+
return raw2.map((entry) => {
|
|
1096
|
+
if (entry && typeof entry === "object" && "nodeType" in entry) {
|
|
1097
|
+
const node = entry;
|
|
1098
|
+
if (typeof node.nodeType === "string") {
|
|
1099
|
+
node.nodeType = normalizeNodeType(node.nodeType);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
return entry;
|
|
1103
|
+
});
|
|
1104
|
+
};
|
|
1105
|
+
if (Array.isArray(obj.nodes)) {
|
|
1106
|
+
return {
|
|
1107
|
+
nodes: normalizeNodes(obj.nodes),
|
|
1108
|
+
edges: Array.isArray(obj.edges) ? obj.edges : void 0
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
const workflow = obj.workflow;
|
|
1112
|
+
if (workflow && Array.isArray(workflow.nodes)) {
|
|
1113
|
+
return {
|
|
1114
|
+
nodes: normalizeNodes(workflow.nodes),
|
|
1115
|
+
edges: Array.isArray(workflow.edges) ? workflow.edges : void 0
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
console.error('Error: JSON must contain a "nodes" array.');
|
|
1119
|
+
console.error("Accepted formats:");
|
|
1120
|
+
console.error(' { "nodes": [{ "nodeType": "login", "configJson": {} }, ...] }');
|
|
1121
|
+
console.error(' { "nodes": [...], "edges": [...] }');
|
|
1122
|
+
console.error(' { "workflow": { "nodes": [...], "edges": [...] } }');
|
|
1123
|
+
console.error("");
|
|
1124
|
+
console.error("Edges, IDs, positions, and ordering are auto-generated when omitted.");
|
|
1125
|
+
process5.exit(1);
|
|
1126
|
+
}
|
|
1127
|
+
async function alignWithCurrentWorkflow(apiUrl, token, workflowId, nodes, edges) {
|
|
1128
|
+
let currentNodes = [];
|
|
1129
|
+
try {
|
|
1130
|
+
const current = await apiRequest(
|
|
1131
|
+
apiUrl,
|
|
1132
|
+
token,
|
|
1133
|
+
"GET",
|
|
1134
|
+
`/workflows/${workflowId}`
|
|
1135
|
+
);
|
|
1136
|
+
if (current.ok && current.workflow?.nodes) {
|
|
1137
|
+
currentNodes = current.workflow.nodes.map((n) => ({
|
|
1138
|
+
id: n.id,
|
|
1139
|
+
nodeType: n.nodeType,
|
|
1140
|
+
orderIndex: n.orderIndex,
|
|
1141
|
+
configJson: n.configJson ?? {}
|
|
1142
|
+
}));
|
|
1143
|
+
}
|
|
1144
|
+
} catch {
|
|
1145
|
+
}
|
|
1146
|
+
if (currentNodes.length === 0) {
|
|
1147
|
+
return { nodes, edges };
|
|
1148
|
+
}
|
|
1149
|
+
const proposedSnapshot = {
|
|
1150
|
+
nodes: nodes.map((n) => ({
|
|
1151
|
+
id: String(n.id),
|
|
1152
|
+
nodeType: String(n.nodeType ?? ""),
|
|
1153
|
+
orderIndex: typeof n.orderIndex === "number" ? n.orderIndex : void 0,
|
|
1154
|
+
configJson: n.configJson ?? {}
|
|
1155
|
+
})),
|
|
1156
|
+
edges: edges.map((e) => ({
|
|
1157
|
+
id: String(e.id),
|
|
1158
|
+
sourceNodeId: String(e.sourceNodeId),
|
|
1159
|
+
targetNodeId: String(e.targetNodeId),
|
|
1160
|
+
edgeType: e.edgeType ?? null
|
|
1161
|
+
}))
|
|
1162
|
+
};
|
|
1163
|
+
const { snapshot } = alignSnapshotNodeIds(proposedSnapshot, currentNodes);
|
|
1164
|
+
const alignedNodes = nodes.map((n, i) => ({
|
|
1165
|
+
...n,
|
|
1166
|
+
id: snapshot.nodes[i].id
|
|
1167
|
+
}));
|
|
1168
|
+
const alignedEdges = edges.map((e, i) => ({
|
|
1169
|
+
...e,
|
|
1170
|
+
sourceNodeId: snapshot.edges[i].sourceNodeId,
|
|
1171
|
+
targetNodeId: snapshot.edges[i].targetNodeId
|
|
1172
|
+
}));
|
|
1173
|
+
return { nodes: alignedNodes, edges: alignedEdges };
|
|
1174
|
+
}
|
|
1175
|
+
async function handleWorkflowSync(argv, apiUrl, token) {
|
|
1176
|
+
const workflowId = argv.find((a) => !a.startsWith("-"));
|
|
1177
|
+
if (!workflowId) {
|
|
1178
|
+
console.error("Error: Missing workflow ID.");
|
|
1179
|
+
console.error("Usage: canary workflow sync <workflowId> --from-file <path>");
|
|
1180
|
+
process5.exit(1);
|
|
1181
|
+
}
|
|
1182
|
+
const jsonOutput = hasFlag(argv, "--json");
|
|
1183
|
+
const noSnapshot = hasFlag(argv, "--no-snapshot");
|
|
1184
|
+
const propose = hasFlag(argv, "--propose");
|
|
1185
|
+
const proposalTitle = getArgValue(argv, "--title");
|
|
1186
|
+
const proposalDescription = getArgValue(argv, "--description");
|
|
1187
|
+
const raw = await readSyncInput(argv);
|
|
1188
|
+
const { nodes, edges } = normalizeNodesAndEdges(raw.nodes, raw.edges);
|
|
1189
|
+
if (propose) {
|
|
1190
|
+
const title = proposalTitle ?? `CLI sync proposal (${nodes.length} nodes)`;
|
|
1191
|
+
const aligned = await alignWithCurrentWorkflow(apiUrl, token, workflowId, nodes, edges);
|
|
1192
|
+
const result2 = await apiRequest(
|
|
1193
|
+
apiUrl,
|
|
1194
|
+
token,
|
|
1195
|
+
"POST",
|
|
1196
|
+
`/workflows/${workflowId}/proposals`,
|
|
1197
|
+
{
|
|
1198
|
+
title,
|
|
1199
|
+
description: proposalDescription ?? null,
|
|
1200
|
+
snapshotJson: { nodes: aligned.nodes, edges: aligned.edges },
|
|
1201
|
+
source: "api"
|
|
1202
|
+
}
|
|
1203
|
+
);
|
|
1204
|
+
if (!result2.ok) {
|
|
1205
|
+
if (jsonOutput) {
|
|
1206
|
+
console.log(JSON.stringify({ ok: false, error: result2.error ?? "Unknown error" }, null, 2));
|
|
1207
|
+
} else {
|
|
1208
|
+
console.error(`Error: ${result2.error ?? "Unknown error"}`);
|
|
1209
|
+
}
|
|
1210
|
+
process5.exit(1);
|
|
1211
|
+
}
|
|
1212
|
+
if (jsonOutput) {
|
|
1213
|
+
console.log(JSON.stringify(result2, null, 2));
|
|
1214
|
+
return;
|
|
1215
|
+
}
|
|
1216
|
+
console.log(`Proposal created for workflow ${workflowId} (${nodes.length} nodes, ${edges.length} edges).`);
|
|
1217
|
+
if (result2.data?.id) {
|
|
1218
|
+
console.log(`Proposal ID: ${result2.data.id}`);
|
|
1219
|
+
}
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
1222
|
+
const result = await apiRequest(
|
|
1223
|
+
apiUrl,
|
|
1224
|
+
token,
|
|
1225
|
+
"PUT",
|
|
1226
|
+
`/workflows/${workflowId}/draft-state`,
|
|
1227
|
+
{ nodes, edges, createSnapshot: !noSnapshot }
|
|
1228
|
+
);
|
|
1229
|
+
if (!result.ok) {
|
|
1230
|
+
if (jsonOutput) {
|
|
1231
|
+
console.log(JSON.stringify({ ok: false, error: result.error ?? "Unknown error" }, null, 2));
|
|
1232
|
+
} else {
|
|
1233
|
+
console.error(`Error: ${result.error ?? "Unknown error"}`);
|
|
1234
|
+
}
|
|
1235
|
+
process5.exit(1);
|
|
1236
|
+
}
|
|
1237
|
+
if (jsonOutput) {
|
|
1238
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1239
|
+
return;
|
|
1240
|
+
}
|
|
1241
|
+
console.log(`Workflow ${workflowId} synced (${nodes.length} nodes, ${edges.length} edges).`);
|
|
1242
|
+
if (result.snapshotVersion) {
|
|
1243
|
+
console.log(`Draft snapshot created: ${result.snapshotVersion}`);
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
// src/workflow-api-sequence.ts
|
|
1248
|
+
import process6 from "process";
|
|
1249
|
+
import { createParser as createParser2 } from "eventsource-parser";
|
|
1250
|
+
async function handleWorkflowGenerateApiSequence(argv, apiUrl, token) {
|
|
1251
|
+
const workflowId = argv.find((a) => !a.startsWith("-"));
|
|
1252
|
+
const prompt = getArgValue(argv, "--prompt");
|
|
1253
|
+
const guidance = getArgValue(argv, "--guidance");
|
|
1254
|
+
const propertyId = getArgValue(argv, "--property");
|
|
1255
|
+
const jsonOutput = hasFlag(argv, "--json");
|
|
1256
|
+
const verbose = hasFlag(argv, "--verbose", "-v");
|
|
1257
|
+
if (!workflowId && !prompt) {
|
|
1258
|
+
console.error("Error: Provide a workflow ID or --prompt.");
|
|
1259
|
+
console.error("");
|
|
1260
|
+
console.error("Usage:");
|
|
1261
|
+
console.error(' canary workflow generate-api-sequence <workflowId> [--guidance "text"]');
|
|
1262
|
+
console.error(' canary workflow generate-api-sequence --prompt "Create user" [--property <id>]');
|
|
1263
|
+
process6.exit(1);
|
|
1264
|
+
}
|
|
1265
|
+
let endpoint;
|
|
1266
|
+
let body;
|
|
1267
|
+
if (workflowId && !prompt) {
|
|
1268
|
+
endpoint = "/api-sequence-agent/generate-from-run";
|
|
1269
|
+
body = { workflowId };
|
|
1270
|
+
if (guidance) body.guidancePrompt = guidance;
|
|
1271
|
+
if (!jsonOutput) {
|
|
1272
|
+
console.error(`Generating API sequence from latest run of workflow ${workflowId}...`);
|
|
1273
|
+
}
|
|
1274
|
+
} else {
|
|
1275
|
+
endpoint = "/api-sequence-agent/generate";
|
|
1276
|
+
body = { prompt };
|
|
1277
|
+
if (propertyId) body.propertyId = propertyId;
|
|
1278
|
+
if (workflowId) body.workflowId = workflowId;
|
|
1279
|
+
if (!jsonOutput) {
|
|
1280
|
+
console.error(`Generating API sequence from prompt...`);
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
let triggerRes;
|
|
1284
|
+
try {
|
|
1285
|
+
triggerRes = await fetch(`${apiUrl}${endpoint}`, {
|
|
1286
|
+
method: "POST",
|
|
1287
|
+
headers: {
|
|
1288
|
+
Authorization: `Bearer ${token}`,
|
|
1289
|
+
"Content-Type": "application/json"
|
|
1290
|
+
},
|
|
1291
|
+
body: JSON.stringify(body)
|
|
1292
|
+
});
|
|
1293
|
+
} catch (err) {
|
|
1294
|
+
console.error(`Failed to connect to API: ${err}`);
|
|
1295
|
+
process6.exit(1);
|
|
1296
|
+
}
|
|
1297
|
+
if (triggerRes.status === 403) {
|
|
1298
|
+
const data = await triggerRes.json().catch(() => ({}));
|
|
1299
|
+
console.error(`Error: ${data.error ?? "This feature is not enabled for your organization."}`);
|
|
1300
|
+
process6.exit(1);
|
|
1301
|
+
}
|
|
1302
|
+
if (!triggerRes.ok) {
|
|
1303
|
+
const data = await triggerRes.json().catch(() => ({}));
|
|
1304
|
+
console.error(`Error: ${data.error ?? `${triggerRes.status} ${triggerRes.statusText}`}`);
|
|
1305
|
+
process6.exit(1);
|
|
1306
|
+
}
|
|
1307
|
+
const triggerData = await triggerRes.json();
|
|
1308
|
+
if (!triggerData.ok || !triggerData.generationId) {
|
|
1309
|
+
console.error(`Error: ${triggerData.error ?? "Failed to start generation"}`);
|
|
1310
|
+
process6.exit(1);
|
|
1311
|
+
}
|
|
1312
|
+
const { generationId } = triggerData;
|
|
1313
|
+
const streamUrl = `${apiUrl}/api-sequence-agent/stream?generationId=${generationId}`;
|
|
1314
|
+
let streamRes;
|
|
1315
|
+
try {
|
|
1316
|
+
streamRes = await fetch(streamUrl, {
|
|
1317
|
+
headers: {
|
|
1318
|
+
Authorization: `Bearer ${token}`,
|
|
1319
|
+
Accept: "text/event-stream"
|
|
1320
|
+
}
|
|
1321
|
+
});
|
|
1322
|
+
} catch (err) {
|
|
1323
|
+
console.error(`Failed to connect to event stream: ${err}`);
|
|
1324
|
+
process6.exit(1);
|
|
1325
|
+
}
|
|
1326
|
+
if (!streamRes.ok || !streamRes.body) {
|
|
1327
|
+
console.error(`Failed to connect to event stream: ${streamRes.status}`);
|
|
1328
|
+
process6.exit(1);
|
|
1329
|
+
}
|
|
1330
|
+
let resultConfig = null;
|
|
1331
|
+
let resultSummary = "";
|
|
1332
|
+
let hasCompleted = false;
|
|
1333
|
+
let exitCode = 0;
|
|
1334
|
+
const parser = createParser2({
|
|
1335
|
+
onEvent: (event) => {
|
|
1336
|
+
if (!event.data || event.event !== "api-sequence-generation") return;
|
|
1337
|
+
try {
|
|
1338
|
+
const data = JSON.parse(event.data);
|
|
1339
|
+
switch (data.eventType) {
|
|
1340
|
+
case "progress":
|
|
1341
|
+
if (!jsonOutput) console.error(` ${data.message}`);
|
|
1342
|
+
break;
|
|
1343
|
+
case "tool-call":
|
|
1344
|
+
if (verbose) console.error(` [tool] ${data.toolName}`);
|
|
1345
|
+
break;
|
|
1346
|
+
case "tool-result":
|
|
1347
|
+
if (verbose) {
|
|
1348
|
+
const icon = data.success ? "\u2713" : "\u2717";
|
|
1349
|
+
console.error(` [tool] ${icon} ${data.toolName}`);
|
|
1350
|
+
}
|
|
1351
|
+
break;
|
|
1352
|
+
case "text":
|
|
1353
|
+
if (verbose) process6.stderr.write(data.delta);
|
|
1354
|
+
break;
|
|
1355
|
+
case "complete":
|
|
1356
|
+
resultConfig = data.config;
|
|
1357
|
+
resultSummary = data.summary;
|
|
1358
|
+
hasCompleted = true;
|
|
1359
|
+
break;
|
|
1360
|
+
case "error":
|
|
1361
|
+
console.error(`
|
|
1362
|
+
Error: ${data.message}`);
|
|
1363
|
+
exitCode = 1;
|
|
1364
|
+
hasCompleted = true;
|
|
1365
|
+
break;
|
|
1366
|
+
}
|
|
1367
|
+
} catch {
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
});
|
|
1371
|
+
const reader = streamRes.body.getReader();
|
|
1372
|
+
const decoder = new TextDecoder();
|
|
1373
|
+
try {
|
|
1374
|
+
while (!hasCompleted) {
|
|
1375
|
+
const { done, value } = await reader.read();
|
|
1376
|
+
if (done) break;
|
|
1377
|
+
parser.feed(decoder.decode(value, { stream: true }));
|
|
1378
|
+
}
|
|
1379
|
+
} finally {
|
|
1380
|
+
reader.releaseLock();
|
|
1381
|
+
}
|
|
1382
|
+
if (exitCode !== 0) {
|
|
1383
|
+
process6.exit(exitCode);
|
|
1384
|
+
}
|
|
1385
|
+
if (!resultConfig) {
|
|
1386
|
+
console.error("Generation completed but no config was returned.");
|
|
1387
|
+
process6.exit(1);
|
|
1388
|
+
}
|
|
1389
|
+
if (jsonOutput) {
|
|
1390
|
+
console.log(JSON.stringify({ ok: true, config: resultConfig, summary: resultSummary }, null, 2));
|
|
1391
|
+
} else {
|
|
1392
|
+
if (resultSummary) {
|
|
1393
|
+
console.error("");
|
|
1394
|
+
console.error(resultSummary);
|
|
1395
|
+
}
|
|
1396
|
+
console.error("");
|
|
1397
|
+
console.log(JSON.stringify(resultConfig, null, 2));
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
// src/workflow-api-sequence-cmds.ts
|
|
1402
|
+
import fs3 from "fs/promises";
|
|
1403
|
+
import process7 from "process";
|
|
1404
|
+
function getArg(argv, key) {
|
|
1405
|
+
const prefix = `${key}=`;
|
|
1406
|
+
const eq = argv.find((a) => a.startsWith(prefix));
|
|
1407
|
+
if (eq) return eq.slice(prefix.length);
|
|
1408
|
+
return getArgValue(argv, key);
|
|
1409
|
+
}
|
|
1410
|
+
function wantsJson(argv) {
|
|
1411
|
+
if (hasFlag(argv, "--json")) return true;
|
|
1412
|
+
if (hasFlag(argv, "--text")) return false;
|
|
1413
|
+
return !process7.stdout.isTTY;
|
|
1414
|
+
}
|
|
1415
|
+
function emit(argv, textFallback, json) {
|
|
1416
|
+
if (wantsJson(argv)) {
|
|
1417
|
+
console.log(JSON.stringify(json, null, 2));
|
|
1418
|
+
} else {
|
|
1419
|
+
console.log(textFallback);
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
async function readJsonInput(argv) {
|
|
1423
|
+
const fromFile = getArg(argv, "--from-file");
|
|
1424
|
+
let raw;
|
|
1425
|
+
if (fromFile) {
|
|
1426
|
+
raw = await fs3.readFile(fromFile, "utf-8");
|
|
1427
|
+
} else if (hasFlag(argv, "--from-stdin") || !process7.stdin.isTTY) {
|
|
1428
|
+
raw = await readStdin();
|
|
1429
|
+
} else {
|
|
1430
|
+
throw new Error("Provide --from-file <path> or pipe JSON on stdin.");
|
|
1431
|
+
}
|
|
1432
|
+
try {
|
|
1433
|
+
return JSON.parse(raw);
|
|
1434
|
+
} catch {
|
|
1435
|
+
throw new Error("Invalid JSON input.");
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
function readStdin() {
|
|
1439
|
+
return new Promise((resolve, reject) => {
|
|
1440
|
+
const chunks = [];
|
|
1441
|
+
process7.stdin.on("data", (chunk) => {
|
|
1442
|
+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
1443
|
+
});
|
|
1444
|
+
process7.stdin.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
|
1445
|
+
process7.stdin.on("error", reject);
|
|
1446
|
+
});
|
|
1447
|
+
}
|
|
1448
|
+
function reportAndMaybeExit(argv, result) {
|
|
1449
|
+
if (result.ok) return;
|
|
1450
|
+
if (result.code === "API_SEQUENCE_INVALID" && Array.isArray(result.issues)) {
|
|
1451
|
+
if (wantsJson(argv)) {
|
|
1452
|
+
console.log(
|
|
1453
|
+
JSON.stringify(
|
|
1454
|
+
{
|
|
1455
|
+
ok: false,
|
|
1456
|
+
error: result.error ?? "API sequence configuration is invalid",
|
|
1457
|
+
code: "API_SEQUENCE_INVALID",
|
|
1458
|
+
issueCount: result.issues.length,
|
|
1459
|
+
issues: result.issues
|
|
1460
|
+
},
|
|
1461
|
+
null,
|
|
1462
|
+
2
|
|
1463
|
+
)
|
|
1464
|
+
);
|
|
1465
|
+
} else {
|
|
1466
|
+
console.error(`Error: ${result.error ?? "API sequence configuration is invalid"}`);
|
|
1467
|
+
console.error(
|
|
1468
|
+
`Found ${result.issues.length} issue${result.issues.length === 1 ? "" : "s"}:`
|
|
1469
|
+
);
|
|
1470
|
+
console.error("");
|
|
1471
|
+
for (const issue of result.issues) {
|
|
1472
|
+
console.error(` ${issue.path || "(root)"} [${issue.code}] :: ${issue.message}`);
|
|
1473
|
+
if (issue.hint) console.error(` hint: ${issue.hint}`);
|
|
1474
|
+
}
|
|
1475
|
+
console.error("");
|
|
1476
|
+
console.error(
|
|
1477
|
+
"Re-run with --json for machine-readable output, or fix the listed fields and retry."
|
|
1478
|
+
);
|
|
1479
|
+
}
|
|
1480
|
+
process7.exit(1);
|
|
1481
|
+
}
|
|
1482
|
+
return;
|
|
1483
|
+
}
|
|
1484
|
+
async function handleWorkflowApiSequence(argv, apiUrl, token) {
|
|
1485
|
+
const [verb, ...rest] = argv;
|
|
1486
|
+
switch (verb) {
|
|
1487
|
+
case "get":
|
|
1488
|
+
await handleGet(rest, apiUrl, token);
|
|
1489
|
+
break;
|
|
1490
|
+
case "update":
|
|
1491
|
+
await handleUpdate(rest, apiUrl, token);
|
|
1492
|
+
break;
|
|
1493
|
+
case "execute":
|
|
1494
|
+
case "run":
|
|
1495
|
+
await handleExecute(rest, apiUrl, token);
|
|
1496
|
+
break;
|
|
1497
|
+
case "delete":
|
|
1498
|
+
await handleDelete(rest, apiUrl, token);
|
|
1499
|
+
break;
|
|
1500
|
+
case "set-api-only":
|
|
1501
|
+
await handleSetApiOnly(rest, apiUrl, token);
|
|
1502
|
+
break;
|
|
1503
|
+
case "generate":
|
|
1504
|
+
await handleWorkflowGenerateApiSequence(rest, apiUrl, token);
|
|
1505
|
+
break;
|
|
1506
|
+
default:
|
|
1507
|
+
printHelp();
|
|
1508
|
+
process7.exit(verb ? 1 : 0);
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
function printHelp() {
|
|
1512
|
+
console.error(
|
|
1513
|
+
[
|
|
1514
|
+
"Usage: canary workflow api-sequence <verb> <workflowId> [options]",
|
|
1515
|
+
"",
|
|
1516
|
+
"Verbs:",
|
|
1517
|
+
" get <wf> fetch the sequence attached to the workflow",
|
|
1518
|
+
" update <wf> --from-file <f> replace config (steps, description, exports...)",
|
|
1519
|
+
" update <wf> --from-stdin replace config from piped JSON",
|
|
1520
|
+
" execute <wf> run the sequence; returns { runId, status }",
|
|
1521
|
+
" delete <wf> delete the api-sequence record (keeps workflow)",
|
|
1522
|
+
" set-api-only <wf> [--off] toggle the workflow's apiOnly flag",
|
|
1523
|
+
" generate <wf> [--prompt ...] AI-generate a config (streams progress)",
|
|
1524
|
+
"",
|
|
1525
|
+
"Global options:",
|
|
1526
|
+
" --json force JSON output",
|
|
1527
|
+
" --text force pretty-text output (default when stdout is a TTY)",
|
|
1528
|
+
"",
|
|
1529
|
+
"Config shape (for update): { steps: [...], description?, stopOnFailure?, exports? }",
|
|
1530
|
+
'Run "canary workflow guide api-sequences" for step shape and template syntax.'
|
|
1531
|
+
].join("\n")
|
|
1532
|
+
);
|
|
1533
|
+
}
|
|
1534
|
+
async function fetchSequenceByWorkflow(apiUrl, token, workflowId) {
|
|
1535
|
+
const res = await apiRequest(
|
|
1536
|
+
apiUrl,
|
|
1537
|
+
token,
|
|
1538
|
+
"GET",
|
|
1539
|
+
`/api-sequences/by-workflow/${workflowId}`
|
|
1540
|
+
);
|
|
1541
|
+
if (!res.ok) {
|
|
1542
|
+
console.error(`Error: ${res.error ?? "Failed to fetch sequence"}`);
|
|
1543
|
+
process7.exit(1);
|
|
1544
|
+
}
|
|
1545
|
+
if (!res.data) {
|
|
1546
|
+
console.error(`Error: Workflow ${workflowId} has no attached API sequence.`);
|
|
1547
|
+
console.error("Create one with: canary workflow create --api-only --name <n> --sequence-from-file seq.json");
|
|
1548
|
+
process7.exit(1);
|
|
1549
|
+
}
|
|
1550
|
+
return res.data;
|
|
1551
|
+
}
|
|
1552
|
+
async function handleGet(argv, apiUrl, token) {
|
|
1553
|
+
const workflowId = argv.find((a) => !a.startsWith("-"));
|
|
1554
|
+
if (!workflowId) {
|
|
1555
|
+
console.error("Error: Missing workflow ID.");
|
|
1556
|
+
printHelp();
|
|
1557
|
+
process7.exit(1);
|
|
1558
|
+
}
|
|
1559
|
+
const seq = await fetchSequenceByWorkflow(apiUrl, token, workflowId);
|
|
1560
|
+
emit(argv, renderSequence(seq), seq);
|
|
1561
|
+
}
|
|
1562
|
+
async function handleUpdate(argv, apiUrl, token) {
|
|
1563
|
+
const workflowId = argv.find((a) => !a.startsWith("-"));
|
|
1564
|
+
if (!workflowId) {
|
|
1565
|
+
console.error("Error: Missing workflow ID.");
|
|
1566
|
+
printHelp();
|
|
1567
|
+
process7.exit(1);
|
|
1568
|
+
}
|
|
1569
|
+
let parsed;
|
|
1570
|
+
try {
|
|
1571
|
+
parsed = await readJsonInput(argv);
|
|
1572
|
+
} catch (err) {
|
|
1573
|
+
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
1574
|
+
process7.exit(1);
|
|
1575
|
+
}
|
|
1576
|
+
const config = parsed;
|
|
1577
|
+
if (!config || !Array.isArray(config.steps)) {
|
|
1578
|
+
console.error('Error: Input must have a "steps" array. Got: ' + JSON.stringify(config));
|
|
1579
|
+
process7.exit(1);
|
|
1580
|
+
}
|
|
1581
|
+
const seq = await fetchSequenceByWorkflow(apiUrl, token, workflowId);
|
|
1582
|
+
const result = await apiRequest(
|
|
1583
|
+
apiUrl,
|
|
1584
|
+
token,
|
|
1585
|
+
"PUT",
|
|
1586
|
+
`/api-sequences/${seq.id}/config`,
|
|
1587
|
+
{ config }
|
|
1588
|
+
);
|
|
1589
|
+
reportAndMaybeExit(argv, result);
|
|
1590
|
+
if (!result.ok) {
|
|
1591
|
+
console.error(`Error: ${result.error ?? "Update failed"}`);
|
|
1592
|
+
process7.exit(1);
|
|
1593
|
+
}
|
|
1594
|
+
emit(
|
|
1595
|
+
argv,
|
|
1596
|
+
`Updated api-sequence ${seq.id} (workflow ${workflowId}). Steps: ${config.steps.length}.`,
|
|
1597
|
+
result
|
|
1598
|
+
);
|
|
1599
|
+
}
|
|
1600
|
+
async function handleExecute(argv, apiUrl, token) {
|
|
1601
|
+
const workflowId = argv.find((a) => !a.startsWith("-"));
|
|
1602
|
+
if (!workflowId) {
|
|
1603
|
+
console.error("Error: Missing workflow ID.");
|
|
1604
|
+
printHelp();
|
|
1605
|
+
process7.exit(1);
|
|
1606
|
+
}
|
|
1607
|
+
const seq = await fetchSequenceByWorkflow(apiUrl, token, workflowId);
|
|
1608
|
+
const result = await apiRequest(
|
|
1609
|
+
apiUrl,
|
|
1610
|
+
token,
|
|
1611
|
+
"POST",
|
|
1612
|
+
`/api-sequences/${seq.id}/execute`
|
|
1613
|
+
);
|
|
1614
|
+
if (!result.ok || !result.data) {
|
|
1615
|
+
console.error(`Error: ${result.error ?? "Execute failed"}`);
|
|
1616
|
+
process7.exit(1);
|
|
1617
|
+
}
|
|
1618
|
+
emit(
|
|
1619
|
+
argv,
|
|
1620
|
+
`Run started: ${result.data.runId} (status: ${result.data.status})
|
|
1621
|
+
Inspect: canary workflow runs get ${workflowId} ${result.data.runId}`,
|
|
1622
|
+
result
|
|
1623
|
+
);
|
|
1624
|
+
}
|
|
1625
|
+
async function handleDelete(argv, apiUrl, token) {
|
|
1626
|
+
const workflowId = argv.find((a) => !a.startsWith("-"));
|
|
1627
|
+
if (!workflowId) {
|
|
1628
|
+
console.error("Error: Missing workflow ID.");
|
|
1629
|
+
printHelp();
|
|
1630
|
+
process7.exit(1);
|
|
1631
|
+
}
|
|
1632
|
+
const seq = await fetchSequenceByWorkflow(apiUrl, token, workflowId);
|
|
1633
|
+
const result = await apiRequest(
|
|
1634
|
+
apiUrl,
|
|
1635
|
+
token,
|
|
1636
|
+
"DELETE",
|
|
1637
|
+
`/api-sequences/${seq.id}`
|
|
1638
|
+
);
|
|
1639
|
+
if (!result.ok) {
|
|
1640
|
+
console.error(`Error: ${result.error ?? "Delete failed"}`);
|
|
1641
|
+
process7.exit(1);
|
|
1642
|
+
}
|
|
1643
|
+
emit(argv, `Deleted api-sequence ${seq.id} (workflow ${workflowId}).`, result);
|
|
1644
|
+
}
|
|
1645
|
+
async function handleSetApiOnly(argv, apiUrl, token) {
|
|
1646
|
+
const workflowId = argv.find((a) => !a.startsWith("-"));
|
|
1647
|
+
if (!workflowId) {
|
|
1648
|
+
console.error("Error: Missing workflow ID.");
|
|
1649
|
+
printHelp();
|
|
1650
|
+
process7.exit(1);
|
|
1651
|
+
}
|
|
1652
|
+
const apiOnly = !hasFlag(argv, "--off");
|
|
1653
|
+
const result = await apiRequest(
|
|
1654
|
+
apiUrl,
|
|
1655
|
+
token,
|
|
1656
|
+
"PUT",
|
|
1657
|
+
`/workflows/${workflowId}/api-only`,
|
|
1658
|
+
{ apiOnly }
|
|
1659
|
+
);
|
|
1660
|
+
if (!result.ok) {
|
|
1661
|
+
console.error(`Error: ${result.error ?? "Update failed"}`);
|
|
1662
|
+
process7.exit(1);
|
|
1663
|
+
}
|
|
1664
|
+
emit(argv, `Workflow ${workflowId} apiOnly=${apiOnly}.`, result);
|
|
1665
|
+
}
|
|
1666
|
+
function renderSequence(seq) {
|
|
1667
|
+
const lines = [];
|
|
1668
|
+
lines.push(`API Sequence ${seq.id}`);
|
|
1669
|
+
lines.push(` Workflow: ${seq.workflowId}`);
|
|
1670
|
+
lines.push(` Status: ${seq.status}`);
|
|
1671
|
+
if (seq.credentialId) lines.push(` Credential: ${seq.credentialId}`);
|
|
1672
|
+
if (seq.config.description) lines.push(` Description: ${seq.config.description}`);
|
|
1673
|
+
lines.push(` stopOnFailure: ${seq.config.stopOnFailure ?? false}`);
|
|
1674
|
+
lines.push("");
|
|
1675
|
+
const steps = seq.config.steps ?? [];
|
|
1676
|
+
if (steps.length === 0) {
|
|
1677
|
+
lines.push(" (no steps \u2014 use `api-sequence update` to add)");
|
|
1678
|
+
} else {
|
|
1679
|
+
lines.push(` Steps (${steps.length}):`);
|
|
1680
|
+
steps.forEach((step, i) => {
|
|
1681
|
+
const method = String(step.method ?? "??").toUpperCase().padEnd(6);
|
|
1682
|
+
const path = step.path ?? "(no path)";
|
|
1683
|
+
const id = step.id ? ` [${step.id}]` : "";
|
|
1684
|
+
lines.push(` ${String(i + 1).padStart(2)}. ${method} ${path}${id}`);
|
|
1685
|
+
if (step.endpointId) lines.push(` endpointId: ${step.endpointId}`);
|
|
1686
|
+
});
|
|
1687
|
+
}
|
|
1688
|
+
const exports = seq.config.exports ?? [];
|
|
1689
|
+
if (exports.length > 0) {
|
|
1690
|
+
lines.push("");
|
|
1691
|
+
lines.push(` Exports: ${JSON.stringify(exports)}`);
|
|
1692
|
+
}
|
|
1693
|
+
return lines.join("\n");
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
// src/workflow-seed-create.ts
|
|
1697
|
+
import fs4 from "fs/promises";
|
|
1698
|
+
import process8 from "process";
|
|
1699
|
+
function getAllArgValues(argv, flag) {
|
|
1700
|
+
const values = [];
|
|
1701
|
+
for (let i = 0; i < argv.length; i++) {
|
|
1702
|
+
if (argv[i] === flag && i + 1 < argv.length) {
|
|
1703
|
+
values.push(argv[i + 1]);
|
|
1704
|
+
i++;
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
return values;
|
|
1708
|
+
}
|
|
1709
|
+
function parseOutputFlag(value) {
|
|
1710
|
+
const parts = value.split(":");
|
|
1711
|
+
if (parts.length < 2) {
|
|
1712
|
+
throw new Error(
|
|
1713
|
+
`Invalid --output format: "${value}". Expected "dataKey:Name" or "dataKey:Name:Description".`
|
|
1714
|
+
);
|
|
1715
|
+
}
|
|
1716
|
+
const [dataKey, name, ...descParts] = parts;
|
|
1717
|
+
const description = descParts.length > 0 ? descParts.join(":") : void 0;
|
|
1718
|
+
return {
|
|
1719
|
+
dataKey: dataKey.trim(),
|
|
1720
|
+
name: name.trim(),
|
|
1721
|
+
displayLabel: name.trim(),
|
|
1722
|
+
description: description?.trim() || void 0
|
|
1723
|
+
};
|
|
1724
|
+
}
|
|
1725
|
+
async function readFromStdin3() {
|
|
1726
|
+
const chunks = [];
|
|
1727
|
+
const stdin = process8.stdin;
|
|
1728
|
+
if (stdin.isTTY) {
|
|
1729
|
+
throw new Error("No input on stdin. Pipe JSON or use --from-file.");
|
|
1730
|
+
}
|
|
1731
|
+
return new Promise((resolve, reject) => {
|
|
1732
|
+
stdin.on("data", (chunk) => {
|
|
1733
|
+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
1734
|
+
});
|
|
1735
|
+
stdin.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
|
1736
|
+
stdin.on("error", reject);
|
|
1737
|
+
});
|
|
1738
|
+
}
|
|
1739
|
+
async function readJsonInput2(argv) {
|
|
1740
|
+
let raw;
|
|
1741
|
+
const fromFile = getArgValue(argv, "--from-file");
|
|
1742
|
+
if (fromFile) {
|
|
1743
|
+
try {
|
|
1744
|
+
raw = await fs4.readFile(fromFile, "utf-8");
|
|
1745
|
+
} catch (err) {
|
|
1746
|
+
throw new Error(`Failed to read file ${fromFile}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1747
|
+
}
|
|
1748
|
+
} else {
|
|
1749
|
+
raw = await readFromStdin3();
|
|
1750
|
+
}
|
|
1751
|
+
let parsed;
|
|
1752
|
+
try {
|
|
1753
|
+
parsed = JSON.parse(raw);
|
|
1754
|
+
} catch {
|
|
1755
|
+
throw new Error("Invalid JSON input.");
|
|
1756
|
+
}
|
|
1757
|
+
const input = parsed;
|
|
1758
|
+
if (!input.name || typeof input.name !== "string") {
|
|
1759
|
+
throw new Error('JSON input must include a "name" string field.');
|
|
1760
|
+
}
|
|
1761
|
+
if (Array.isArray(input.outputs)) {
|
|
1762
|
+
for (const out of input.outputs) {
|
|
1763
|
+
if (!out.dataKey || !out.name) {
|
|
1764
|
+
throw new Error('Each output must have "dataKey" and "name" fields.');
|
|
1765
|
+
}
|
|
1766
|
+
if (!out.displayLabel) {
|
|
1767
|
+
out.displayLabel = out.name;
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
return input;
|
|
1772
|
+
}
|
|
1773
|
+
function resolveInput(argv) {
|
|
1774
|
+
const fromFile = getArgValue(argv, "--from-file");
|
|
1775
|
+
const fromStdin = hasFlag(argv, "--from-stdin");
|
|
1776
|
+
if (fromFile || fromStdin) {
|
|
1777
|
+
return readJsonInput2(argv);
|
|
1778
|
+
}
|
|
1779
|
+
const name = getArgValue(argv, "--name");
|
|
1780
|
+
if (!name) {
|
|
1781
|
+
console.error("Error: --name is required (or use --from-stdin / --from-file for JSON input).");
|
|
1782
|
+
console.error("Usage: canary workflow seed create --name <name> [--output key:name[:desc]] ...");
|
|
1783
|
+
process8.exit(1);
|
|
1784
|
+
}
|
|
1785
|
+
const description = getArgValue(argv, "--description");
|
|
1786
|
+
const status = getArgValue(argv, "--status");
|
|
1787
|
+
const outputFlags = getAllArgValues(argv, "--output");
|
|
1788
|
+
const outputs = outputFlags.map(parseOutputFlag);
|
|
1789
|
+
const outputsJsonRaw = getArgValue(argv, "--outputs-json");
|
|
1790
|
+
if (outputsJsonRaw) {
|
|
1791
|
+
try {
|
|
1792
|
+
const parsed = JSON.parse(outputsJsonRaw);
|
|
1793
|
+
if (!Array.isArray(parsed)) throw new Error("not an array");
|
|
1794
|
+
for (const out of parsed) {
|
|
1795
|
+
if (!out.dataKey || !out.name) {
|
|
1796
|
+
throw new Error('Each output must have "dataKey" and "name" fields.');
|
|
1797
|
+
}
|
|
1798
|
+
if (!out.displayLabel) out.displayLabel = out.name;
|
|
1799
|
+
}
|
|
1800
|
+
outputs.push(...parsed);
|
|
1801
|
+
} catch (err) {
|
|
1802
|
+
console.error(`Error: Invalid --outputs-json: ${err instanceof Error ? err.message : String(err)}`);
|
|
1803
|
+
process8.exit(1);
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
return { name, description: description || void 0, status, outputs };
|
|
1807
|
+
}
|
|
1808
|
+
async function handleWorkflowSeedCreate(argv, apiUrl, token) {
|
|
1809
|
+
if (hasFlag(argv, "--help", "-h")) {
|
|
1810
|
+
printSeedCreateHelp();
|
|
1811
|
+
return;
|
|
1812
|
+
}
|
|
1813
|
+
const jsonOutput = hasFlag(argv, "--json");
|
|
1814
|
+
const noTeardown = hasFlag(argv, "--no-teardown");
|
|
1815
|
+
let input;
|
|
1816
|
+
try {
|
|
1817
|
+
input = await resolveInput(argv);
|
|
1818
|
+
} catch (err) {
|
|
1819
|
+
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
1820
|
+
process8.exit(1);
|
|
1821
|
+
}
|
|
1822
|
+
const seedResult = await apiRequest(
|
|
1823
|
+
apiUrl,
|
|
1824
|
+
token,
|
|
1825
|
+
"POST",
|
|
1826
|
+
"/workflows",
|
|
1827
|
+
{
|
|
1828
|
+
name: input.name,
|
|
1829
|
+
description: input.description,
|
|
1830
|
+
status: input.status ?? "draft",
|
|
1831
|
+
flowType: "seed",
|
|
1832
|
+
createdVia: "cli"
|
|
1833
|
+
}
|
|
1834
|
+
);
|
|
1835
|
+
if (!seedResult.ok || !seedResult.workflow) {
|
|
1836
|
+
if (jsonOutput) {
|
|
1837
|
+
console.log(JSON.stringify({ ok: false, error: seedResult.error ?? "Failed to create seed workflow" }, null, 2));
|
|
1838
|
+
} else {
|
|
1839
|
+
console.error(`Error: ${seedResult.error ?? "Failed to create seed workflow"}`);
|
|
1840
|
+
}
|
|
1841
|
+
process8.exit(1);
|
|
1842
|
+
}
|
|
1843
|
+
const seedId = seedResult.workflow.id;
|
|
1844
|
+
const appUrl = resolveAppUrl(apiUrl);
|
|
1845
|
+
let outputsSaved = false;
|
|
1846
|
+
if (input.outputs && input.outputs.length > 0) {
|
|
1847
|
+
const outputsResult = await apiRequest(
|
|
1848
|
+
apiUrl,
|
|
1849
|
+
token,
|
|
1850
|
+
"PUT",
|
|
1851
|
+
`/workflows/${seedId}`,
|
|
1852
|
+
{ seedOutputsJson: input.outputs }
|
|
1853
|
+
);
|
|
1854
|
+
if (outputsResult.ok) {
|
|
1855
|
+
outputsSaved = true;
|
|
1856
|
+
} else {
|
|
1857
|
+
console.warn(`Warning: Seed created but failed to save outputs: ${outputsResult.error ?? "Unknown error"}`);
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
let teardownId;
|
|
1861
|
+
let teardownName;
|
|
1862
|
+
if (!noTeardown) {
|
|
1863
|
+
const tdName = `Teardown: ${input.name}`;
|
|
1864
|
+
const tdResult = await apiRequest(
|
|
1865
|
+
apiUrl,
|
|
1866
|
+
token,
|
|
1867
|
+
"POST",
|
|
1868
|
+
"/workflows",
|
|
1869
|
+
{
|
|
1870
|
+
name: tdName,
|
|
1871
|
+
description: `Cleans up seed data from "${input.name}"`,
|
|
1872
|
+
status: "draft",
|
|
1873
|
+
flowType: "teardown",
|
|
1874
|
+
createdVia: "cli"
|
|
1875
|
+
}
|
|
1876
|
+
);
|
|
1877
|
+
if (tdResult.ok && tdResult.workflow) {
|
|
1878
|
+
teardownId = tdResult.workflow.id;
|
|
1879
|
+
teardownName = tdName;
|
|
1880
|
+
const linkResult = await apiRequest(
|
|
1881
|
+
apiUrl,
|
|
1882
|
+
token,
|
|
1883
|
+
"PUT",
|
|
1884
|
+
`/workflows/${seedId}`,
|
|
1885
|
+
{ teardownWorkflowId: teardownId }
|
|
1886
|
+
);
|
|
1887
|
+
if (!linkResult.ok) {
|
|
1888
|
+
console.warn(`Warning: Teardown created but failed to link to seed: ${linkResult.error ?? "Unknown error"}`);
|
|
1889
|
+
}
|
|
1890
|
+
} else {
|
|
1891
|
+
console.warn(`Warning: Failed to create paired teardown: ${tdResult.error ?? "Unknown error"}`);
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
if (jsonOutput) {
|
|
1895
|
+
const result = {
|
|
1896
|
+
ok: true,
|
|
1897
|
+
seed: {
|
|
1898
|
+
id: seedId,
|
|
1899
|
+
name: input.name,
|
|
1900
|
+
status: input.status ?? "draft",
|
|
1901
|
+
flowType: "seed",
|
|
1902
|
+
outputs: outputsSaved ? input.outputs : [],
|
|
1903
|
+
url: `${appUrl}/flows/${seedId}`
|
|
1904
|
+
}
|
|
1905
|
+
};
|
|
1906
|
+
if (teardownId) {
|
|
1907
|
+
result.teardown = {
|
|
1908
|
+
id: teardownId,
|
|
1909
|
+
name: teardownName,
|
|
1910
|
+
status: "draft",
|
|
1911
|
+
flowType: "teardown",
|
|
1912
|
+
url: `${appUrl}/flows/${teardownId}`
|
|
1913
|
+
};
|
|
1914
|
+
}
|
|
1915
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1916
|
+
return;
|
|
1917
|
+
}
|
|
1918
|
+
console.log(`
|
|
1919
|
+
\u2713 Seed workflow created: ${input.name}`);
|
|
1920
|
+
console.log(` ID: ${seedId}`);
|
|
1921
|
+
console.log(` Status: ${input.status ?? "draft"}`);
|
|
1922
|
+
if (input.outputs && input.outputs.length > 0) {
|
|
1923
|
+
const keys = input.outputs.map((o) => o.dataKey).join(", ");
|
|
1924
|
+
console.log(` Outputs: ${keys}${!outputsSaved ? " (save failed)" : ""}`);
|
|
1925
|
+
}
|
|
1926
|
+
console.log(` URL: ${appUrl}/flows/${seedId}`);
|
|
1927
|
+
if (teardownId) {
|
|
1928
|
+
console.log(`
|
|
1929
|
+
\u2713 Teardown workflow created: ${teardownName}`);
|
|
1930
|
+
console.log(` ID: ${teardownId}`);
|
|
1931
|
+
console.log(` URL: ${appUrl}/flows/${teardownId}`);
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
function printSeedCreateHelp() {
|
|
1935
|
+
console.log(
|
|
1936
|
+
[
|
|
1937
|
+
"Usage: canary workflow seed create [options]",
|
|
1938
|
+
"",
|
|
1939
|
+
"Create a seed workflow with declared outputs and an optional paired teardown.",
|
|
1940
|
+
"",
|
|
1941
|
+
"Flag-based input:",
|
|
1942
|
+
" --name <name> Seed workflow name (required)",
|
|
1943
|
+
" --description <desc> Description",
|
|
1944
|
+
" --output <key:name[:desc]> Declare an output (repeatable)",
|
|
1945
|
+
" --outputs-json <json> Full outputs array as inline JSON (for credential configs)",
|
|
1946
|
+
" --no-teardown Skip creating paired teardown workflow",
|
|
1947
|
+
" --status <status> Initial status (default: draft)",
|
|
1948
|
+
" --json Output raw JSON",
|
|
1949
|
+
"",
|
|
1950
|
+
"JSON input:",
|
|
1951
|
+
" --from-stdin Read full config from stdin",
|
|
1952
|
+
" --from-file <path> Read full config from file",
|
|
1953
|
+
"",
|
|
1954
|
+
"Examples:",
|
|
1955
|
+
' canary workflow seed create --name "Create Project" --output "project_id:Project ID"',
|
|
1956
|
+
"",
|
|
1957
|
+
" canary workflow seed create --from-stdin <<'EOF'",
|
|
1958
|
+
" {",
|
|
1959
|
+
' "name": "Create Test User",',
|
|
1960
|
+
' "outputs": [{',
|
|
1961
|
+
' "dataKey": "user_cred",',
|
|
1962
|
+
' "name": "Test User Credential",',
|
|
1963
|
+
' "credentialOutput": {',
|
|
1964
|
+
' "fieldMapping": { "username": "$generate.email", "password": "Test123!" },',
|
|
1965
|
+
' "emailPrefix": "testuser",',
|
|
1966
|
+
' "createInboxRecipient": true',
|
|
1967
|
+
" }",
|
|
1968
|
+
" }]",
|
|
1969
|
+
" }",
|
|
1970
|
+
" EOF"
|
|
1971
|
+
].join("\n")
|
|
1972
|
+
);
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
// ../content/src/workflow-guide/overview.txt
|
|
1976
|
+
var overview_default = "Workflows are visual test sequences that model user journeys through your application. Each workflow is a directed graph of nodes \u2014 login, navigate, action, assertion, wait, condition, setup, and more \u2014 connected to form a testable path.\n\nWhen to use workflows:\n\n- Critical paths: signup, checkout, billing, onboarding\n- High-risk areas: features that regress often or have complex logic\n- Multi-step journeys: flows spanning multiple pages or user roles\n\nFLOW TYPES\n\n standard The default. Models a single user journey end-to-end.\n Login \u2192 Navigate \u2192 Actions \u2192 Assertions \u2192 Cleanup.\n\n setup A prerequisite workflow that runs before other workflows.\n Use for shared data creation or environment preparation.\n Any published workflow can be referenced as a setup step.\n\n seed A data-seeding workflow that generates test data and exports\n variables for downstream workflows. Seed outputs are declared\n in the workflow definition and available as $var.{dataKey}\n in any workflow that references the seed.\n\n teardown A cleanup workflow that runs after a seed workflow completes.\n Automatically invoked to remove data the seed created.\n\nEXECUTION MODEL\n\n 1. Login node provisions an authenticated browser session\n 2. Nodes execute in sequence (or branch at condition nodes)\n 3. Each action node interacts with the live application\n 4. Assertions verify expected outcomes\n 5. Additional login nodes switch to a different user \u2014 each starts a\n new segment with a fresh session (great for multi-role testing)\n 6. Wait nodes pause execution (minutes, hours, days) \u2014 also creating\n segments. After a wait, a login re-establishes the session.\n 7. On completion, the run is marked success or failure\n\nVERSION HISTORY\n\n Workflows maintain version history. Publishing creates a versioned\n snapshot. Previous versions can be previewed or restored to draft.\n Setup nodes reference a specific published version of another workflow.";
|
|
1977
|
+
|
|
1978
|
+
// ../content/src/workflow-guide/node-types.txt
|
|
1979
|
+
var node_types_default = `Every workflow is built from nodes. Each node has a type, a title, a description,
|
|
1980
|
+
and type-specific configuration.
|
|
1981
|
+
|
|
1982
|
+
STARTING NODE \u2014 pick the first node in this order
|
|
1983
|
+
The first node of a flow follows a strict hierarchy. Getting this wrong is the
|
|
1984
|
+
single most common authoring mistake (most often: opening with a navigate node).
|
|
1985
|
+
|
|
1986
|
+
1. SEED first \u2014 if the flow depends on pre-existing data, a \`seed\` node comes
|
|
1987
|
+
first. It provisions data in its own isolated context and exports
|
|
1988
|
+
$var.{dataKey} outputs the rest of the flow consumes.
|
|
1989
|
+
2. Otherwise LOGIN first \u2014 for any authenticated flow, the first node is a
|
|
1990
|
+
\`login\` node that declares the ACTOR (who the journey runs as). This is the
|
|
1991
|
+
"acting as" step. Do NOT open an authenticated flow with a navigate node.
|
|
1992
|
+
3. Navigate comes AFTER login \u2014 a navigate jumps to the test's entry point, but
|
|
1993
|
+
it follows the login; it is never the opening node of an authenticated flow.
|
|
1994
|
+
|
|
1995
|
+
The only flows that may start with a navigate are UNauthenticated ones \u2014 signup,
|
|
1996
|
+
password reset, a public/marketing page, or a test of the login experience
|
|
1997
|
+
itself. For those, set the login node's \`performLogin: false\` (or omit the login
|
|
1998
|
+
node) and author the steps as the journey.
|
|
1999
|
+
|
|
2000
|
+
LOGIN SUBSUMES LOGGING IN. A login node already provisions an authenticated
|
|
2001
|
+
session \u2014 it IS the act of logging in. Never emit a login node and then a
|
|
2002
|
+
"navigate to /login" \u2192 "enter email/password" \u2192 "click Sign in" sequence. Start
|
|
2003
|
+
with the login node and go straight to the first real action. The one exception
|
|
2004
|
+
is a flow whose explicit purpose is to test login (start logged out and make the
|
|
2005
|
+
login steps the journey).
|
|
2006
|
+
|
|
2007
|
+
LOGIN
|
|
2008
|
+
Sets the user context. Select a credential to act as that user.
|
|
2009
|
+
The login node provisions an authenticated browser session \u2014 subsequent
|
|
2010
|
+
nodes start with the browser already logged in.
|
|
2011
|
+
|
|
2012
|
+
Config:
|
|
2013
|
+
credentialId UUID of the credential to log in as. REQUIRED for the
|
|
2014
|
+
node to bind to a real credential. Without it the
|
|
2015
|
+
Flow Designer shows a red "Select a credential"
|
|
2016
|
+
warning even if credentialName is set.
|
|
2017
|
+
credentialName Human-readable name, used as the node title. Optional
|
|
2018
|
+
but recommended alongside credentialId.
|
|
2019
|
+
performLogin When true, the browser is authenticated automatically.
|
|
2020
|
+
|
|
2021
|
+
Finding the credentialId:
|
|
2022
|
+
canary credential list [--env prod|dev]
|
|
2023
|
+
\u2192 prints "ID: <uuid>" under each credential name.
|
|
2024
|
+
Pick the ID whose property matches the environment the flow runs in.
|
|
2025
|
+
|
|
2026
|
+
Ordering: login nodes should typically follow any seed nodes, not
|
|
2027
|
+
precede them. Seeds export variables (e.g. $var.workflow_id) that
|
|
2028
|
+
later nodes consume, so a seed-first / login-second order avoids
|
|
2029
|
+
wasted browser segments and lets login/navigate URLs reference
|
|
2030
|
+
seed outputs. See the SEED node type and the Node Ordering section
|
|
2031
|
+
of best-practices.
|
|
2032
|
+
|
|
2033
|
+
- Workflows support multiple login nodes for multi-role testing.
|
|
2034
|
+
Each login node starts a new segment with a fresh browser session.
|
|
2035
|
+
Example: Login as Customer \u2192 place order \u2192 Login as Sales Rep \u2192 verify order.
|
|
2036
|
+
Great for two-sided apps, permission checks (member vs admin), and
|
|
2037
|
+
cross-role journeys.
|
|
2038
|
+
- Login nodes also appear after Wait nodes to re-establish the session.
|
|
2039
|
+
- A navigate node after login is optional but useful for jumping
|
|
2040
|
+
directly to the test's entry point.
|
|
2041
|
+
|
|
2042
|
+
NAVIGATE
|
|
2043
|
+
Directs the browser to a specific path.
|
|
2044
|
+
|
|
2045
|
+
Prefer relative paths (e.g. "/flows", "/flows/$var.workflow_id")
|
|
2046
|
+
over hard-coded absolute URLs. Relative paths let the same workflow
|
|
2047
|
+
run unchanged across environments (prod, dev, local, preview) \u2014 the
|
|
2048
|
+
base URL comes from the property/environment the run targets. A
|
|
2049
|
+
workflow that hard-codes "https://app.dev.trycanary.ai/..." will
|
|
2050
|
+
silently drive traffic against dev even when run against prod.
|
|
2051
|
+
|
|
2052
|
+
Use an absolute URL only when the destination is genuinely off the
|
|
2053
|
+
application being tested (external identity provider, third-party
|
|
2054
|
+
checkout, docs site, etc.).
|
|
2055
|
+
|
|
2056
|
+
- A navigate node after login is a great way to jump straight to the
|
|
2057
|
+
page where the test begins.
|
|
2058
|
+
- Select from discovered pages or enter a custom URL.
|
|
2059
|
+
|
|
2060
|
+
ACTION
|
|
2061
|
+
Performs a user interaction on the current page. Describe what the user
|
|
2062
|
+
should do ("Create a new ticket") and optionally provide input data via
|
|
2063
|
+
formFields.
|
|
2064
|
+
|
|
2065
|
+
Every action node must have a title (customActionTitle). The title shows
|
|
2066
|
+
in the canvas, run timeline, failure reports, and issue tickets \u2014 an
|
|
2067
|
+
untitled action is nearly impossible to reason about after the fact.
|
|
2068
|
+
Keep titles short and intent-oriented, e.g. "Create a ticket" or
|
|
2069
|
+
"Publish the flow" \u2014 not "Click the button".
|
|
2070
|
+
|
|
2071
|
+
Keep actions focused on a single user goal:
|
|
2072
|
+
Good: "Create a ticket with required fields"
|
|
2073
|
+
Bad: "Click the title field" (too granular)
|
|
2074
|
+
Bad: "Create a ticket, assign it, comment, upload, and verify" (too stuffed)
|
|
2075
|
+
|
|
2076
|
+
Rule of thumb: if an action crosses multiple screens or changes multiple
|
|
2077
|
+
entities, split it. If it's a single user goal on a single screen, keep it.
|
|
2078
|
+
|
|
2079
|
+
Actions can include:
|
|
2080
|
+
- customActionTitle: short intent-oriented title (required)
|
|
2081
|
+
- formFields: input data the action needs (name, email, etc.)
|
|
2082
|
+
- inlineAssertions: checks verified after the action completes (preferred
|
|
2083
|
+
over standalone assertion nodes \u2014 see Assertions section)
|
|
2084
|
+
- declaredOutputs: values to extract from the page for downstream use
|
|
2085
|
+
- customInstructions: additional guidance for the agent executing the step
|
|
2086
|
+
|
|
2087
|
+
ASSERTION
|
|
2088
|
+
Verifies expected outcomes. Define a condition to check.
|
|
2089
|
+
The run fails if the condition is not met.
|
|
2090
|
+
|
|
2091
|
+
Prefer inline assertions on action nodes over standalone assertion nodes.
|
|
2092
|
+
Inline assertions keep the test on track and produce cleaner failure
|
|
2093
|
+
reports. If verifying something requires significant interaction first,
|
|
2094
|
+
that's better modeled as an action node with inline assertions.
|
|
2095
|
+
|
|
2096
|
+
Use standalone assertion nodes only when the check doesn't belong to
|
|
2097
|
+
any action \u2014 e.g., verifying a page state after navigation, or
|
|
2098
|
+
confirming data absence after a sequence of steps.
|
|
2099
|
+
|
|
2100
|
+
Severity levels:
|
|
2101
|
+
fail Critical \u2014 the test stops if this fails
|
|
2102
|
+
warn Non-critical \u2014 logged but doesn't fail the test
|
|
2103
|
+
|
|
2104
|
+
Agentic assertions: for visual/layout checks that can't be mechanically
|
|
2105
|
+
validated, enable agent evaluation. These require the AI agent during
|
|
2106
|
+
replay and cannot be cached. Use for:
|
|
2107
|
+
- Timestamps or dates
|
|
2108
|
+
- Real-time data feeds
|
|
2109
|
+
- Content that changes between runs
|
|
2110
|
+
|
|
2111
|
+
CONDITION
|
|
2112
|
+
Branches the workflow based on a runtime condition. Define a condition
|
|
2113
|
+
to evaluate, then connect separate paths for true and false outcomes.
|
|
2114
|
+
|
|
2115
|
+
WAIT
|
|
2116
|
+
Pauses execution for a specified duration (minutes, hours, or days).
|
|
2117
|
+
Creates a segmented flow \u2014 execution pauses, then resumes automatically.
|
|
2118
|
+
Add a Login node after the Wait to re-establish the browser session.
|
|
2119
|
+
|
|
2120
|
+
SETUP
|
|
2121
|
+
References another published workflow to run as a prerequisite.
|
|
2122
|
+
|
|
2123
|
+
Execution modes:
|
|
2124
|
+
Inline (default) Runs before each workflow
|
|
2125
|
+
Suite-level Runs once per suite, shared across workflows
|
|
2126
|
+
|
|
2127
|
+
When to use: creating users, projects, or records that multiple tests
|
|
2128
|
+
depend on. Login is handled separately \u2014 setup nodes are for data creation.
|
|
2129
|
+
|
|
2130
|
+
SEED
|
|
2131
|
+
Injects outputs from a seed workflow as variables. Seed workflows
|
|
2132
|
+
generate test data and export named values. Downstream workflows
|
|
2133
|
+
access these as $var.{dataKey}.
|
|
2134
|
+
|
|
2135
|
+
Ordering: seed nodes should almost always come FIRST \u2014 before the
|
|
2136
|
+
login node. Seeds run in their own isolated context (usually via
|
|
2137
|
+
API sequence) and export variables into the workflow. Placing a
|
|
2138
|
+
login before the seed wastes a browser session, and any
|
|
2139
|
+
login/navigate node that references $var.{seed_output} must come
|
|
2140
|
+
after the seed that produces it.
|
|
2141
|
+
|
|
2142
|
+
Typical pattern:
|
|
2143
|
+
Seed \u2192 Login \u2192 Navigate \u2192 ... action/assertion nodes
|
|
2144
|
+
|
|
2145
|
+
Config:
|
|
2146
|
+
seedWorkflowId UUID of the seed workflow (required)
|
|
2147
|
+
seedWorkflowName Human-readable name (used as the node title)
|
|
2148
|
+
|
|
2149
|
+
Seed workflows are the preferred way to provision test data. See the
|
|
2150
|
+
Seed Workflows section for details on building and using them.
|
|
2151
|
+
|
|
2152
|
+
API SEQUENCE
|
|
2153
|
+
Executes multiple API calls in sequence without a browser.
|
|
2154
|
+
Use for backend-only operations, webhooks, or API testing.
|
|
2155
|
+
|
|
2156
|
+
API sequences are also used inside seed workflows \u2014 start with a
|
|
2157
|
+
browser-based seed to discover the right API calls, then convert
|
|
2158
|
+
to an API sequence for speed and reliability.
|
|
2159
|
+
|
|
2160
|
+
NOTIFICATION
|
|
2161
|
+
Sends notifications (Slack, email, etc.) at a point in the workflow.
|
|
2162
|
+
|
|
2163
|
+
END
|
|
2164
|
+
Explicitly terminates the workflow. Use when a condition branch
|
|
2165
|
+
should stop execution rather than continue.`;
|
|
2166
|
+
|
|
2167
|
+
// ../content/src/workflow-guide/playbook.txt
|
|
2168
|
+
var playbook_default = `A playbook is a sequence of human-readable steps that describe the actions
|
|
2169
|
+
taken during a workflow node's execution. Playbooks bridge the gap between
|
|
2170
|
+
high-level node descriptions ("Create a ticket") and the low-level browser
|
|
2171
|
+
interactions that fulfill them.
|
|
2172
|
+
|
|
2173
|
+
WHAT A PLAYBOOK CONTAINS
|
|
2174
|
+
|
|
2175
|
+
Each playbook is an ordered list of PlaybookSteps:
|
|
2176
|
+
|
|
2177
|
+
order Step sequence number
|
|
2178
|
+
action What to do: click, fill, fill_form, select, navigate,
|
|
2179
|
+
wait, scroll, hover, upload, verify, handle_native_dialog,
|
|
2180
|
+
toggle, press_key, focus, drag, resize, capture,
|
|
2181
|
+
extract_table, python_execute, verify_download,
|
|
2182
|
+
verify_download_content, screenshot, agent_loop
|
|
2183
|
+
target The element or area being acted on
|
|
2184
|
+
params Key-value parameters for the action
|
|
2185
|
+
note Human-readable description of the step
|
|
2186
|
+
|
|
2187
|
+
Example playbook for "Create a ticket":
|
|
2188
|
+
1. click "New Ticket" button
|
|
2189
|
+
2. fill_form Title: "QA Test \u2014 Billing Bug", Priority: "High"
|
|
2190
|
+
3. click "Submit" button
|
|
2191
|
+
4. verify Success toast appears
|
|
2192
|
+
|
|
2193
|
+
HOW PLAYBOOKS ENABLE CACHE REPLAY
|
|
2194
|
+
|
|
2195
|
+
When a workflow runs for the first time, the AI agent explores the
|
|
2196
|
+
application, performs actions, and generates a playbook for each node.
|
|
2197
|
+
The raw browser actions (clicks, keystrokes, waits) are cached alongside
|
|
2198
|
+
the playbook steps.
|
|
2199
|
+
|
|
2200
|
+
On subsequent runs, the system uses playbook-step-driven replay:
|
|
2201
|
+
|
|
2202
|
+
1. Load the playbook steps from the node config
|
|
2203
|
+
2. For each step, look up the cached browser actions
|
|
2204
|
+
3. Replay the cached actions directly (no AI agent needed)
|
|
2205
|
+
4. If a step's cache misses or fails, the AI agent heals that step
|
|
2206
|
+
|
|
2207
|
+
This means most runs execute instantly via cache, with the agent only
|
|
2208
|
+
invoked for steps that have changed or broken.
|
|
2209
|
+
|
|
2210
|
+
COMPILED ENTRIES
|
|
2211
|
+
|
|
2212
|
+
The cache maps playbook steps to cached action indices:
|
|
2213
|
+
|
|
2214
|
+
cached Playbook step has matching cached actions \u2014 replayed directly
|
|
2215
|
+
gap No cache for this step \u2014 agent performs it fresh
|
|
2216
|
+
|
|
2217
|
+
A fully cached node replays in seconds. A node with gaps falls back to
|
|
2218
|
+
agent execution only for the missing steps.
|
|
2219
|
+
|
|
2220
|
+
PLAYBOOK HEALTH
|
|
2221
|
+
|
|
2222
|
+
Each cached node tracks playbook health:
|
|
2223
|
+
|
|
2224
|
+
verified Cache is working correctly across recent runs
|
|
2225
|
+
degraded Some steps have failures \u2014 cache still used but monitored
|
|
2226
|
+
stale Cache is no longer reliable \u2014 full agent re-execution needed
|
|
2227
|
+
|
|
2228
|
+
Step-level failure counts track which specific steps break most often,
|
|
2229
|
+
guiding targeted cache healing.
|
|
2230
|
+
|
|
2231
|
+
WHY PLAYBOOKS MATTER FOR AUTHORING
|
|
2232
|
+
|
|
2233
|
+
When you build a workflow, the quality of your node descriptions directly
|
|
2234
|
+
affects playbook quality. Clear, intent-focused descriptions produce
|
|
2235
|
+
better playbooks, which produce more reliable cache replay.
|
|
2236
|
+
|
|
2237
|
+
Good: "Create a ticket with title 'QA Test' and priority 'High'"
|
|
2238
|
+
\u2192 Clean playbook: fill_form with clear fields, click submit
|
|
2239
|
+
|
|
2240
|
+
Bad: "Do the thing on the page"
|
|
2241
|
+
\u2192 Vague playbook: agent guesses at actions, cache breaks easily
|
|
2242
|
+
|
|
2243
|
+
The playbook is the semantic contract between your intent and the
|
|
2244
|
+
execution engine. Write nodes that describe what a human would do,
|
|
2245
|
+
and the playbook will faithfully capture and replay it.
|
|
2246
|
+
`;
|
|
2247
|
+
|
|
2248
|
+
// ../content/src/workflow-guide/best-practices.txt
|
|
2249
|
+
var best_practices_default = `SCOPE: ONE COHERENT USER JOURNEY
|
|
2250
|
+
|
|
2251
|
+
A workflow should test one job-to-be-done, not multiple unrelated tasks.
|
|
2252
|
+
Many steps are fine if they're all part of the same flow (creating,
|
|
2253
|
+
editing, verifying one entity). Stop when the goal is accomplished.
|
|
2254
|
+
|
|
2255
|
+
Good \u2014 coherent journey:
|
|
2256
|
+
Login \u2192 Navigate to projects \u2192 Create project \u2192 Edit name \u2192
|
|
2257
|
+
Assert name updated \u2192 Cleanup: delete project
|
|
2258
|
+
|
|
2259
|
+
Good \u2014 multi-role journey:
|
|
2260
|
+
Login as Customer \u2192 Place an order \u2192
|
|
2261
|
+
Login as Sales Rep \u2192 Verify the order appears in the queue
|
|
2262
|
+
|
|
2263
|
+
Good \u2014 read-only flow:
|
|
2264
|
+
Login \u2192 Navigate to dashboard \u2192 Assert widgets visible
|
|
2265
|
+
|
|
2266
|
+
Bad \u2014 multiple unrelated journeys:
|
|
2267
|
+
Dashboard \u2192 Create item \u2192 Manage users \u2192 Generate report
|
|
2268
|
+
(These are separate jobs \u2014 create separate workflows)
|
|
2269
|
+
|
|
2270
|
+
Rule: "Is this step dependent on or naturally connected to the previous
|
|
2271
|
+
steps?" If no, you're starting a new journey \u2014 stop.
|
|
2272
|
+
|
|
2273
|
+
WRITE STEPS AS HUMAN INTENT
|
|
2274
|
+
|
|
2275
|
+
A workflow should read like instructions you'd explain to a teammate:
|
|
2276
|
+
- "Create a ticket assigned to Support."
|
|
2277
|
+
- "Submit the ticket and confirm it appears in the list."
|
|
2278
|
+
- "Change the ticket status to Closed."
|
|
2279
|
+
|
|
2280
|
+
Capture the "why" and the "what" \u2014 not UI mechanics.
|
|
2281
|
+
|
|
2282
|
+
CHOOSE THE RIGHT GRANULARITY
|
|
2283
|
+
|
|
2284
|
+
Too granular:
|
|
2285
|
+
- "Click New"
|
|
2286
|
+
- "Type title"
|
|
2287
|
+
- "Type description"
|
|
2288
|
+
- "Click Save"
|
|
2289
|
+
|
|
2290
|
+
Too stuffed:
|
|
2291
|
+
- "Create a ticket, assign it, comment, upload, and verify in three views"
|
|
2292
|
+
|
|
2293
|
+
Good:
|
|
2294
|
+
- "Create a ticket with required fields"
|
|
2295
|
+
- "Assign the ticket to Support"
|
|
2296
|
+
- "Verify the ticket appears in the queue"
|
|
2297
|
+
|
|
2298
|
+
If an action crosses multiple screens or changes multiple entities, split it.
|
|
2299
|
+
If it's a single user goal on a single screen, keep it as one action.
|
|
2300
|
+
|
|
2301
|
+
MAKE EACH TEST SELF-SUFFICIENT
|
|
2302
|
+
|
|
2303
|
+
Tests should not depend on what another test created or on long-lived
|
|
2304
|
+
shared state. If a workflow needs data, provision it:
|
|
2305
|
+
|
|
2306
|
+
- Use a seed workflow (preferred) to generate data and export variables
|
|
2307
|
+
- Use a setup node to reference a published workflow for shared data
|
|
2308
|
+
- As a last resort, add data creation steps at the start of the workflow
|
|
2309
|
+
- Always use macros ($EMAIL, $UUID, etc.) so reruns don't collide
|
|
2310
|
+
|
|
2311
|
+
This keeps runs deterministic and prevents cascading failures.
|
|
2312
|
+
|
|
2313
|
+
VERIFY EVERY SIGNIFICANT ACTION
|
|
2314
|
+
|
|
2315
|
+
Before recording a step, verify it worked:
|
|
2316
|
+
|
|
2317
|
+
- Form submissions: confirm acceptance (success message, redirect, new item in list)
|
|
2318
|
+
- Data creation: navigate to where the record should appear and confirm
|
|
2319
|
+
- Data modification: reload and confirm the change persisted
|
|
2320
|
+
- Deletions: confirm the item is gone (a toast alone is not enough)
|
|
2321
|
+
- Navigation: confirm the expected page loaded
|
|
2322
|
+
|
|
2323
|
+
If verification fails, debug the issue \u2014 don't record and move on.
|
|
2324
|
+
|
|
2325
|
+
KEEP DESCRIPTIONS CLEAR
|
|
2326
|
+
|
|
2327
|
+
Node titles should be short and action-oriented:
|
|
2328
|
+
Good: "Create a support ticket"
|
|
2329
|
+
Bad: "Step 3 - ticket creation process"
|
|
2330
|
+
|
|
2331
|
+
Node descriptions should add detail the title can't carry:
|
|
2332
|
+
Good: "Fill the form with a QA Test subject and High priority, then submit"
|
|
2333
|
+
Bad: "Do the thing"
|
|
2334
|
+
|
|
2335
|
+
NODE ORDERING
|
|
2336
|
+
|
|
2337
|
+
The start-of-workflow order is a strict hierarchy (see "STARTING NODE"
|
|
2338
|
+
in the Node Types guide):
|
|
2339
|
+
|
|
2340
|
+
Seed (if needed) \u2192 Login \u2192 Navigate \u2192 ...
|
|
2341
|
+
|
|
2342
|
+
The first node is the ACTOR, not a navigate: an authenticated flow opens
|
|
2343
|
+
with a \`login\` node (or a \`seed\`, if it needs data first) \u2014 never with a
|
|
2344
|
+
navigate node. A navigate jumps to the test's entry point AFTER login.
|
|
2345
|
+
Only unauthenticated flows (signup, password reset, public pages, or a
|
|
2346
|
+
login test) may start with a navigate.
|
|
2347
|
+
|
|
2348
|
+
Seed nodes should come first. Seeds run in their own isolated context
|
|
2349
|
+
(usually via API sequence) and export variables the rest of the flow
|
|
2350
|
+
consumes. Putting a login before the seed wastes a browser segment,
|
|
2351
|
+
and any login/navigate that references $var.{seed_output} must come
|
|
2352
|
+
after the seed that produced it.
|
|
2353
|
+
|
|
2354
|
+
Only skip the seed-first rule if the seed itself genuinely depends on
|
|
2355
|
+
a browser session created earlier in the same workflow (rare \u2014 in
|
|
2356
|
+
that case, prefer a separate browser-based seed workflow and reference
|
|
2357
|
+
it here).
|
|
2358
|
+
|
|
2359
|
+
LOGIN BEST PRACTICES
|
|
2360
|
+
|
|
2361
|
+
- Most workflows start with a login node. Workflows can also start
|
|
2362
|
+
with a seed node or API sequence when no browser session is needed yet.
|
|
2363
|
+
- Login SUBSUMES logging in. A login node already provisions an
|
|
2364
|
+
authenticated session \u2014 it IS the act of logging in. Never follow a
|
|
2365
|
+
login node with "navigate to /login" / "enter email + password" /
|
|
2366
|
+
"click Sign in" steps. Start with the login node and go straight to
|
|
2367
|
+
the first real action. (Only exception: a flow whose purpose is to
|
|
2368
|
+
test login \u2014 start logged out and make the login steps the journey.)
|
|
2369
|
+
- Login nodes require a credentialId (UUID), not just a credentialName.
|
|
2370
|
+
Without a credentialId the Flow Designer flags the node as invalid.
|
|
2371
|
+
Find credential IDs with: canary credential list [--env prod|dev]
|
|
2372
|
+
- Use multiple login nodes for multi-role testing \u2014 each login starts
|
|
2373
|
+
a new segment with a fresh session as that user
|
|
2374
|
+
- Don't test login itself (unless that's the explicit goal)
|
|
2375
|
+
- A navigate node after login is a great way to jump directly to the
|
|
2376
|
+
test's entry point
|
|
2377
|
+
|
|
2378
|
+
PREFER INLINE ASSERTIONS
|
|
2379
|
+
|
|
2380
|
+
Attach assertions to action nodes using inlineAssertions rather than
|
|
2381
|
+
adding standalone assertion nodes. Inline assertions keep the test
|
|
2382
|
+
focused and produce clearer failure reports. See the Assertions section
|
|
2383
|
+
for details.`;
|
|
2384
|
+
|
|
2385
|
+
// ../content/src/workflow-guide/macros.txt
|
|
2386
|
+
var macros_default = `Macros generate fresh, unique data every time a workflow runs. Variables let
|
|
2387
|
+
you reference shared values across nodes. Both use the $ prefix.
|
|
2388
|
+
|
|
2389
|
+
AVAILABLE MACROS
|
|
2390
|
+
|
|
2391
|
+
$EMAIL() Unique test email test-a1b2@example-test.com
|
|
2392
|
+
$FIRST_NAME() Random first name Jordan
|
|
2393
|
+
$LAST_NAME() Random last name Chen
|
|
2394
|
+
$PHONE() Random US phone +1-555-382-7491
|
|
2395
|
+
$UUID() Random UUID v4 a1b2c3d4-e5f6-...
|
|
2396
|
+
$NOW() ISO 8601 timestamp 2026-02-19T14:30:00.000Z
|
|
2397
|
+
$TODAY() Current date 2026-02-19
|
|
2398
|
+
$DATE(N) Date offset by N days $DATE(7) = next week
|
|
2399
|
+
$TIMESTAMP() Unix timestamp (ms) 1771520400000
|
|
2400
|
+
$RANDOM_INT(min,max) Random integer $RANDOM_INT(1,100) = 42
|
|
2401
|
+
$RANDOM_STRING(len) Random alphanumeric $RANDOM_STRING(6) = xK9mQ4
|
|
2402
|
+
$SEQUENCE(name) Auto-incrementing $SEQUENCE(order) = 1, 2, 3...
|
|
2403
|
+
|
|
2404
|
+
SYNTAX
|
|
2405
|
+
|
|
2406
|
+
Macros follow the format $MACRO_NAME() or $MACRO_NAME(args).
|
|
2407
|
+
They can appear standalone or inline within a larger string:
|
|
2408
|
+
|
|
2409
|
+
Standalone: $EMAIL()
|
|
2410
|
+
Inline: user-$UUID()@corp.com
|
|
2411
|
+
Escaped: \\$EMAIL() produces the literal text $EMAIL()
|
|
2412
|
+
|
|
2413
|
+
Each run resolves macros independently \u2014 every execution gets fresh values.
|
|
2414
|
+
|
|
2415
|
+
VARIABLES
|
|
2416
|
+
|
|
2417
|
+
Variables reference values set during a workflow run. When an action has
|
|
2418
|
+
input fields or extracts data, those become $var.{name} references.
|
|
2419
|
+
|
|
2420
|
+
Sources:
|
|
2421
|
+
Input A value entered in an action's field $var.company
|
|
2422
|
+
Macro A macro expression in a field $var.email
|
|
2423
|
+
Extracted Data extracted from the page $var.order_id
|
|
2424
|
+
Credential Values from the login credential $var.username
|
|
2425
|
+
|
|
2426
|
+
Use $var.{name} in subsequent step formFields and descriptions.
|
|
2427
|
+
Don't re-hardcode values \u2014 reference the variable from the earlier step.
|
|
2428
|
+
|
|
2429
|
+
HOW VARIABLES GET BOUND
|
|
2430
|
+
|
|
2431
|
+
Two distinct paths create $var.{name} bindings. Picking the wrong one is a
|
|
2432
|
+
common foot-gun.
|
|
2433
|
+
|
|
2434
|
+
1. Typed input (auto-promote)
|
|
2435
|
+
|
|
2436
|
+
Add an entry to inputValues on an action node:
|
|
2437
|
+
|
|
2438
|
+
inputValues: { company: "$RANDOM_STRING(8)" }
|
|
2439
|
+
|
|
2440
|
+
The key "company" auto-promotes to $var.company for every downstream
|
|
2441
|
+
node. Do NOT also list it in declaredOutputs \u2014 the input itself IS the
|
|
2442
|
+
binding.
|
|
2443
|
+
|
|
2444
|
+
2. DOM capture (declaredOutputs + playbook capture)
|
|
2445
|
+
|
|
2446
|
+
For values that only exist after the page renders (URL fragment, badge
|
|
2447
|
+
text, generated ID in the breadcrumb), declare them upfront and capture
|
|
2448
|
+
them inside the playbook:
|
|
2449
|
+
|
|
2450
|
+
declaredOutputs: ["order_id"]
|
|
2451
|
+
playbookSteps: [
|
|
2452
|
+
{ order: N, action: "capture", target: "<selector or rule>",
|
|
2453
|
+
outputKey: "order_id" }
|
|
2454
|
+
]
|
|
2455
|
+
|
|
2456
|
+
The capture step writes into $var.order_id at runtime. Without
|
|
2457
|
+
declaredOutputs the downstream resolver won't recognize the binding.
|
|
2458
|
+
|
|
2459
|
+
Rule of thumb: if you can type the value into a field before the run, it's
|
|
2460
|
+
an input. If the value only exists after something happens in the browser,
|
|
2461
|
+
it's a captured output.
|
|
2462
|
+
|
|
2463
|
+
SEED VARIABLES
|
|
2464
|
+
|
|
2465
|
+
When seed workflows are configured, their outputs are available as
|
|
2466
|
+
$var.{dataKey} references. If a seed provides data you need (a project ID,
|
|
2467
|
+
a user record), use the seed variable \u2014 don't create that data again.
|
|
2468
|
+
|
|
2469
|
+
COMMON PATTERNS
|
|
2470
|
+
|
|
2471
|
+
Unique user registration:
|
|
2472
|
+
Email: $EMAIL()
|
|
2473
|
+
Name: $FIRST_NAME() $LAST_NAME()
|
|
2474
|
+
|
|
2475
|
+
Numbered items:
|
|
2476
|
+
$SEQUENCE("orders") \u2192 1, 2, 3... (resets each run, independent per name)
|
|
2477
|
+
|
|
2478
|
+
Date-relative testing:
|
|
2479
|
+
$DATE(30) for 30 days out, $DATE(-7) for last week, $TODAY() for now
|
|
2480
|
+
|
|
2481
|
+
Unique identifiers:
|
|
2482
|
+
$UUID() for globally unique, $RANDOM_STRING(8) for shorter strings
|
|
2483
|
+
Combine with text: order-$RANDOM_STRING(6)
|
|
2484
|
+
|
|
2485
|
+
BEST PRACTICES
|
|
2486
|
+
|
|
2487
|
+
- Use $EMAIL() instead of hardcoded addresses for uniqueness
|
|
2488
|
+
- Use $SEQUENCE("name") when you need predictable ordering within a run
|
|
2489
|
+
- Prefer $DATE(days) over fixed dates so tests stay valid over time
|
|
2490
|
+
- Always include a value for every formField \u2014 don't leave fields empty`;
|
|
2491
|
+
|
|
2492
|
+
// ../content/src/workflow-guide/seed-workflows.txt
|
|
2493
|
+
var seed_workflows_default = `Seed workflows are the preferred way to provision test data. A seed
|
|
2494
|
+
workflow creates data your test needs and exports named variables that
|
|
2495
|
+
downstream workflows can reference.
|
|
2496
|
+
|
|
2497
|
+
WHY USE SEEDS
|
|
2498
|
+
|
|
2499
|
+
- Reusable: one seed can serve many test workflows
|
|
2500
|
+
- Fast: seeds can use API sequences instead of slow browser interactions
|
|
2501
|
+
- Clean: paired teardown workflows handle cleanup automatically
|
|
2502
|
+
- Isolated: each run gets its own fresh data via macros
|
|
2503
|
+
|
|
2504
|
+
HOW SEEDS WORK
|
|
2505
|
+
|
|
2506
|
+
1. Create a workflow with flowType "seed"
|
|
2507
|
+
2. Define seedOutputs \u2014 the named values the seed exports
|
|
2508
|
+
(e.g., project_id, user_email, order_number)
|
|
2509
|
+
3. The seed creates data and captures those values during execution
|
|
2510
|
+
4. Downstream workflows add a Seed node that references this seed
|
|
2511
|
+
5. The seed's outputs are available as $var.{dataKey} in the test
|
|
2512
|
+
|
|
2513
|
+
Example:
|
|
2514
|
+
Seed "Create Test Project" exports: project_id, project_name
|
|
2515
|
+
Test workflow references the seed, then uses:
|
|
2516
|
+
Navigate to $var.project_id
|
|
2517
|
+
Assert page title contains $var.project_name
|
|
2518
|
+
|
|
2519
|
+
CREATING SEEDS
|
|
2520
|
+
|
|
2521
|
+
A seed workflow shell needs a name, declared outputs, and usually a paired
|
|
2522
|
+
teardown. Outputs are the contract downstream workflows depend on; define
|
|
2523
|
+
them before building the seed body so every node captures the right values.
|
|
2524
|
+
|
|
2525
|
+
Simple data outputs need:
|
|
2526
|
+
- dataKey: stable variable name, used as $var.{dataKey}
|
|
2527
|
+
- name: human label
|
|
2528
|
+
- description: optional context for builders and reviewers
|
|
2529
|
+
|
|
2530
|
+
Credential outputs additionally define credentialOutput, including provider
|
|
2531
|
+
type, generated field mapping, inbox behavior, and loginUrl.
|
|
2532
|
+
|
|
2533
|
+
After creating the shell, add nodes through the Flow Designer, workflow sync,
|
|
2534
|
+
or API, then publish once the seed and paired teardown both pass.
|
|
2535
|
+
|
|
2536
|
+
CLI users: see \`canary workflow guide cli-seeds\` for command usage.
|
|
2537
|
+
|
|
2538
|
+
BUILDING A SEED: START WITH THE BROWSER
|
|
2539
|
+
|
|
2540
|
+
The easiest way to build a seed is to start with browser-based steps:
|
|
2541
|
+
|
|
2542
|
+
1. Create a seed workflow (flowType: seed)
|
|
2543
|
+
2. Add a login node with credentials that have permission to create data
|
|
2544
|
+
3. Add action nodes that create the data through the UI
|
|
2545
|
+
- Use macros for all names and identifiers ($EMAIL, $RANDOM_STRING, etc.)
|
|
2546
|
+
4. Add declaredOutputs on action nodes to capture generated values
|
|
2547
|
+
(IDs, URLs, confirmation numbers)
|
|
2548
|
+
5. Define seedOutputs on the workflow to export those captured values
|
|
2549
|
+
6. Test and publish the seed
|
|
2550
|
+
|
|
2551
|
+
This approach lets you discover exactly what steps and data are needed
|
|
2552
|
+
by interacting with the real application.
|
|
2553
|
+
|
|
2554
|
+
CONVERTING TO API SEQUENCES
|
|
2555
|
+
|
|
2556
|
+
Once a browser-based seed works reliably, convert it to an API sequence
|
|
2557
|
+
for speed and reliability. Canary can do this automatically.
|
|
2558
|
+
|
|
2559
|
+
Auto-conversion from a workflow run:
|
|
2560
|
+
1. Open the seed workflow in the Flow Designer
|
|
2561
|
+
2. Add an api_sequence node
|
|
2562
|
+
3. In the node config, choose "Generate from workflow run"
|
|
2563
|
+
4. Select a completed run of the browser-based seed
|
|
2564
|
+
5. The AI agent analyzes the network requests captured during
|
|
2565
|
+
that run and generates an equivalent API sequence automatically
|
|
2566
|
+
6. The agent tests each step against the live API to verify it works
|
|
2567
|
+
7. Review the generated sequence and publish
|
|
2568
|
+
|
|
2569
|
+
The AI agent has access to your application's API catalog and can:
|
|
2570
|
+
- Search for endpoints by keyword
|
|
2571
|
+
- Inspect request/response schemas
|
|
2572
|
+
- Test individual API calls to verify they work
|
|
2573
|
+
- Compose a multi-step sequence with variable passing between steps
|
|
2574
|
+
- Validate the full sequence end-to-end before finalizing
|
|
2575
|
+
|
|
2576
|
+
You can also generate an API sequence from a prompt \u2014 describe what
|
|
2577
|
+
data to create and the agent will find the right endpoints and build
|
|
2578
|
+
the sequence.
|
|
2579
|
+
|
|
2580
|
+
API sequences support two kinds of dynamic values:
|
|
2581
|
+
- Step references: {{stepId.response.path.to.value}} to pass data
|
|
2582
|
+
between steps (e.g., use the project ID from step 1 in step 2)
|
|
2583
|
+
- Macros: $EMAIL(), $UUID(), $RANDOM_STRING(), etc. for fresh
|
|
2584
|
+
unique data each run
|
|
2585
|
+
|
|
2586
|
+
Exports map response fields to seed output variables:
|
|
2587
|
+
- stepId: which step to extract from
|
|
2588
|
+
- responsePath: dot notation path (e.g., "data.id")
|
|
2589
|
+
- variableName: the $var.{name} downstream workflows will use
|
|
2590
|
+
|
|
2591
|
+
EXECUTION MODES
|
|
2592
|
+
|
|
2593
|
+
Seeds automatically choose the best execution mode:
|
|
2594
|
+
|
|
2595
|
+
browser Runs through the browser like a normal workflow
|
|
2596
|
+
api Runs the API sequence directly \u2014 no browser needed
|
|
2597
|
+
recommended Uses the API sequence if one exists, otherwise browser
|
|
2598
|
+
|
|
2599
|
+
Once you've added an API sequence to a seed, it will automatically
|
|
2600
|
+
run via API \u2014 faster, cheaper, and more reliable than browser execution.
|
|
2601
|
+
|
|
2602
|
+
Not all seeds can be converted \u2014 some data creation flows require
|
|
2603
|
+
browser interaction (file uploads, complex wizards, third-party
|
|
2604
|
+
integrations). Keep those as browser-based seeds.
|
|
2605
|
+
|
|
2606
|
+
API CALL TIMEOUT
|
|
2607
|
+
|
|
2608
|
+
When a seed runs in API mode (executionMode "api", or "recommended"
|
|
2609
|
+
resolving to API), each HTTP call uses a 2-minute (120s) timeout by
|
|
2610
|
+
default. Seeds/teardowns that hit slow provisioning endpoints can raise
|
|
2611
|
+
this per seed node via apiTimeoutSeconds \u2014 it applies to both the seed
|
|
2612
|
+
and its paired teardown. Ignored for browser-mode seeds.
|
|
2613
|
+
|
|
2614
|
+
Set it three equivalent ways:
|
|
2615
|
+
- Flow Designer: the "API call timeout (seconds)" field on the Seed node
|
|
2616
|
+
- .canary.md: a "- apiTimeoutSeconds: 300" bullet under the Seed node
|
|
2617
|
+
- workflow JSON (create / sync): on the seed node's configJson, e.g.
|
|
2618
|
+
{ "nodeType": "seed",
|
|
2619
|
+
"configJson": { "seedExecutionMode": "api", "apiTimeoutSeconds": 300 } }
|
|
2620
|
+
|
|
2621
|
+
Leave it unset to keep the 120s default; an invalid value falls back to
|
|
2622
|
+
the default rather than erroring.
|
|
2623
|
+
|
|
2624
|
+
TEARDOWN WORKFLOWS
|
|
2625
|
+
|
|
2626
|
+
Every seed can have a paired teardown workflow that cleans up the
|
|
2627
|
+
data it created. Teardowns run automatically after the seed's
|
|
2628
|
+
downstream workflows complete.
|
|
2629
|
+
|
|
2630
|
+
- Set flowType to "teardown" and link it to the seed
|
|
2631
|
+
- The teardown receives the same $var.{dataKey} variables the seed exported
|
|
2632
|
+
- Use those variables to find and delete the created data
|
|
2633
|
+
|
|
2634
|
+
Teardowns are the cleanest way to handle cleanup \u2014 they keep cleanup
|
|
2635
|
+
logic separate from test logic and run automatically.
|
|
2636
|
+
|
|
2637
|
+
SEED BEST PRACTICES
|
|
2638
|
+
|
|
2639
|
+
- Use macros in all created data ($EMAIL, $RANDOM_STRING, etc.)
|
|
2640
|
+
so each run gets unique, non-conflicting data
|
|
2641
|
+
- Keep seeds focused \u2014 one seed per logical dataset
|
|
2642
|
+
(e.g., "Create Project with Members" not "Create Everything")
|
|
2643
|
+
- Export only what downstream workflows need \u2014 don't over-export
|
|
2644
|
+
- Start with browser-based seeds, then use auto-conversion to generate
|
|
2645
|
+
an API sequence from a completed run
|
|
2646
|
+
- Pair seeds with teardown workflows for automatic cleanup
|
|
2647
|
+
- Use suite-level setup nodes to share seed data across multiple
|
|
2648
|
+
workflows in the same test suite
|
|
2649
|
+
`;
|
|
2650
|
+
|
|
2651
|
+
// ../content/src/workflow-guide/data-lifecycle.txt
|
|
2652
|
+
var data_lifecycle_default = `Test data discipline keeps workflows reliable and concurrent-safe.
|
|
2653
|
+
|
|
2654
|
+
PREFER SEED WORKFLOWS FOR DATA
|
|
2655
|
+
|
|
2656
|
+
Seed workflows are the best way to provision test data. They run before
|
|
2657
|
+
your test workflow, create the data it needs, and export variables that
|
|
2658
|
+
downstream workflows can reference as $var.{dataKey}.
|
|
2659
|
+
|
|
2660
|
+
Benefits of seeds over inline data creation:
|
|
2661
|
+
- Reusable across multiple workflows
|
|
2662
|
+
- Faster \u2014 can use API sequences instead of browser interactions
|
|
2663
|
+
- Cleaner separation of setup and test logic
|
|
2664
|
+
- Paired teardown workflows handle cleanup automatically
|
|
2665
|
+
|
|
2666
|
+
See the Seed Workflows section for how to build and use them.
|
|
2667
|
+
|
|
2668
|
+
THE DATA OWNERSHIP PRINCIPLE
|
|
2669
|
+
|
|
2670
|
+
Own your data:
|
|
2671
|
+
- Data you create \u2192 clean it up when the workflow finishes
|
|
2672
|
+
- Settings you modify \u2192 revert them when the workflow finishes
|
|
2673
|
+
- Data that was already there \u2192 never delete it
|
|
2674
|
+
|
|
2675
|
+
It's fine to depend on pre-existing data (existing integrations, org
|
|
2676
|
+
settings, reference data). Only create what doesn't already exist or
|
|
2677
|
+
what the test specifically needs to own.
|
|
2678
|
+
|
|
2679
|
+
CREATING TEST DATA
|
|
2680
|
+
|
|
2681
|
+
If the test operates on a record (edit, delete, archive), create that
|
|
2682
|
+
record as an early step \u2014 unless it already exists and the test is meant
|
|
2683
|
+
to operate on it.
|
|
2684
|
+
|
|
2685
|
+
Never reference "any" resource:
|
|
2686
|
+
Bad: "Pick a project from the list" (which one? may not exist)
|
|
2687
|
+
Good: "Create a project named 'QA Test \u2014 $RANDOM_STRING(6)'"
|
|
2688
|
+
Good: "Use the seed output $var.project_id"
|
|
2689
|
+
|
|
2690
|
+
Always use macros in names to ensure uniqueness across parallel runs:
|
|
2691
|
+
Bad: "Test Item" (generic, will collide with other runs)
|
|
2692
|
+
Bad: "QA Test Project" (still collides if two runs overlap)
|
|
2693
|
+
Good: "QA Test \u2014 $RANDOM_STRING(6)" (unique every run)
|
|
2694
|
+
Good: "QA Test $FIRST_NAME() $LAST_NAME()" (unique and readable)
|
|
2695
|
+
|
|
2696
|
+
After creating data, verify it exists before continuing.
|
|
2697
|
+
|
|
2698
|
+
NAMING CONVENTION
|
|
2699
|
+
|
|
2700
|
+
All test data should follow this pattern:
|
|
2701
|
+
|
|
2702
|
+
Name/Title: "QA Test $FIRST_NAME() $LAST_NAME()"
|
|
2703
|
+
Email: "$EMAIL()"
|
|
2704
|
+
Phone: "$PHONE()"
|
|
2705
|
+
Project/Item: "QA Test \u2014 $RANDOM_STRING(6)"
|
|
2706
|
+
Unique ID: "QA$TIMESTAMP()"
|
|
2707
|
+
|
|
2708
|
+
The "QA Test" prefix makes test data instantly recognizable. The macro
|
|
2709
|
+
suffix ensures every run creates unique data that won't collide with
|
|
2710
|
+
other runs or leftover data from previous executions.
|
|
2711
|
+
|
|
2712
|
+
CLEANING UP (STRONGLY RECOMMENDED)
|
|
2713
|
+
|
|
2714
|
+
Workflows that create data should include cleanup steps at the end.
|
|
2715
|
+
Workflows that modify settings should revert them.
|
|
2716
|
+
|
|
2717
|
+
This isn't strictly required, but it's strongly recommended:
|
|
2718
|
+
- Cleanup prevents data accumulation over time
|
|
2719
|
+
- It keeps the test environment clean for other workflows
|
|
2720
|
+
- The "QA Test" naming convention makes orphaned data identifiable
|
|
2721
|
+
even when cleanup is skipped or fails
|
|
2722
|
+
|
|
2723
|
+
Don't over-engineer cleanup \u2014 a simple "navigate to the item and
|
|
2724
|
+
delete it" is sufficient. For seed workflows, use a paired teardown
|
|
2725
|
+
workflow instead.
|
|
2726
|
+
|
|
2727
|
+
WHAT NOT TO TOUCH
|
|
2728
|
+
|
|
2729
|
+
- Don't create users, roles, or org-level config unless that's the goal
|
|
2730
|
+
- Don't modify shared resources (billing, integrations, API keys) as a side effect
|
|
2731
|
+
- Don't delete or modify data not owned by this workflow
|
|
2732
|
+
|
|
2733
|
+
AVOIDING COLLISIONS WITH MACROS
|
|
2734
|
+
|
|
2735
|
+
The best way to avoid data collisions is to use macros for every
|
|
2736
|
+
name, email, and identifier. When every run generates unique values,
|
|
2737
|
+
there's no risk of conflicting with parallel runs or leftover data:
|
|
2738
|
+
|
|
2739
|
+
$EMAIL() Unique email every run
|
|
2740
|
+
$RANDOM_STRING(6) Short unique suffix for names
|
|
2741
|
+
$UUID() Globally unique identifier
|
|
2742
|
+
$TIMESTAMP() Unique-per-millisecond numeric ID
|
|
2743
|
+
|
|
2744
|
+
Combined with the "QA Test" prefix, this means you never need to
|
|
2745
|
+
worry about whether data from a previous run still exists. Each run
|
|
2746
|
+
creates its own isolated dataset.
|
|
2747
|
+
|
|
2748
|
+
READ-ONLY FLOWS
|
|
2749
|
+
|
|
2750
|
+
Workflows that only read data (verify dashboard loads, check reports)
|
|
2751
|
+
don't need creation or cleanup steps. Not every workflow needs to
|
|
2752
|
+
create data \u2014 only create what the test specifically requires.`;
|
|
2753
|
+
|
|
2754
|
+
// ../content/src/workflow-guide/assertions.txt
|
|
2755
|
+
var assertions_default = `Assertions verify that the application behaves as expected. There are two
|
|
2756
|
+
ways to add assertions: inline on action nodes, or as standalone assertion
|
|
2757
|
+
nodes. Inline assertions are strongly preferred.
|
|
2758
|
+
|
|
2759
|
+
INLINE ASSERTIONS (PREFERRED)
|
|
2760
|
+
|
|
2761
|
+
Attach assertions directly to action nodes using the inlineAssertions
|
|
2762
|
+
field. These are verified immediately after the action completes and
|
|
2763
|
+
cached for fast replay.
|
|
2764
|
+
|
|
2765
|
+
Inline assertions are the best way to verify outcomes because they:
|
|
2766
|
+
- Keep the test on track \u2014 verification happens right where the action is
|
|
2767
|
+
- Produce clean failure reports \u2014 each assertion is tied to the step that caused it
|
|
2768
|
+
- Are cacheable \u2014 fast replay on subsequent runs
|
|
2769
|
+
|
|
2770
|
+
Example \u2014 after submitting a form:
|
|
2771
|
+
Action: "Submit the support ticket"
|
|
2772
|
+
Inline assertions:
|
|
2773
|
+
- "Success toast appears" (severity: warn)
|
|
2774
|
+
- "Form is no longer visible" (severity: fail)
|
|
2775
|
+
|
|
2776
|
+
Severity levels:
|
|
2777
|
+
fail Critical \u2014 the test stops if this fails.
|
|
2778
|
+
Use for: data saved, redirect happened, error absent.
|
|
2779
|
+
warn Non-critical \u2014 logged but doesn't fail the test.
|
|
2780
|
+
Use for: toast appeared, animation completed.
|
|
2781
|
+
|
|
2782
|
+
If verifying something requires significant interaction (scrolling,
|
|
2783
|
+
navigating, expanding sections), model that as an action node with
|
|
2784
|
+
inline assertions \u2014 not a detailed standalone assertion node.
|
|
2785
|
+
|
|
2786
|
+
STANDALONE ASSERTION NODES (USE SPARINGLY)
|
|
2787
|
+
|
|
2788
|
+
Use dedicated assertion nodes only when the check doesn't belong to
|
|
2789
|
+
any action:
|
|
2790
|
+
|
|
2791
|
+
- Verifying a final page state after navigation (no action to attach to)
|
|
2792
|
+
- Checking that data was NOT modified (absence checks after a sequence)
|
|
2793
|
+
- Confirming a state visible only after a wait or page transition
|
|
2794
|
+
|
|
2795
|
+
A typical workflow has 0-1 standalone assertion nodes. If you find
|
|
2796
|
+
yourself writing a complex assertion node that requires multiple steps
|
|
2797
|
+
to verify, consider whether it should be an action node with inline
|
|
2798
|
+
assertions instead.
|
|
2799
|
+
|
|
2800
|
+
AGENTIC ASSERTIONS
|
|
2801
|
+
|
|
2802
|
+
For visual or layout checks that can't be mechanically validated,
|
|
2803
|
+
enable agent evaluation. These require the AI agent during replay
|
|
2804
|
+
and cannot be cached.
|
|
2805
|
+
|
|
2806
|
+
Use for:
|
|
2807
|
+
- Screenshots that should "look correct"
|
|
2808
|
+
- Charts or graphs rendering properly
|
|
2809
|
+
- Dynamic content that changes between runs
|
|
2810
|
+
- Timestamps or real-time data
|
|
2811
|
+
|
|
2812
|
+
Standard assertions are preferred \u2014 only use agentic when the
|
|
2813
|
+
check is inherently subjective or dynamic.
|
|
2814
|
+
|
|
2815
|
+
WRITING GOOD CONDITIONS
|
|
2816
|
+
|
|
2817
|
+
Assertions must be specific and observable:
|
|
2818
|
+
|
|
2819
|
+
Good:
|
|
2820
|
+
- "The status badge shows 'Active'"
|
|
2821
|
+
- "The table contains a row with name 'Test Record'"
|
|
2822
|
+
- "Error message is not visible"
|
|
2823
|
+
- "The page title is 'Dashboard'"
|
|
2824
|
+
|
|
2825
|
+
Bad:
|
|
2826
|
+
- "The page looks correct"
|
|
2827
|
+
- "The action was successful"
|
|
2828
|
+
- "Everything works"
|
|
2829
|
+
|
|
2830
|
+
Start with what's observable: "The page displays...", "The table
|
|
2831
|
+
contains...", "The field shows...". Include the specific value
|
|
2832
|
+
you expect.
|
|
2833
|
+
|
|
2834
|
+
ASSERTION PLACEMENT
|
|
2835
|
+
|
|
2836
|
+
Place assertions at critical verification points:
|
|
2837
|
+
|
|
2838
|
+
1. After data creation: verify the record exists (inline on the create action)
|
|
2839
|
+
2. After data modification: verify the change persisted (inline on the edit action)
|
|
2840
|
+
3. After deletion: verify the item is gone (inline on the delete action)
|
|
2841
|
+
4. At the goal completion point: verify the journey succeeded
|
|
2842
|
+
|
|
2843
|
+
Don't assert everything \u2014 focus on the claims that matter.
|
|
2844
|
+
A focused assertion that proves the test's core claim is better
|
|
2845
|
+
than a dozen checks on every UI element.`;
|
|
2846
|
+
|
|
2847
|
+
// ../content/src/workflow-guide/api-sequences.txt
|
|
2848
|
+
var api_sequences_default = 'API Sequences are the API-only counterpart to browser workflows. Instead of\ndriving a browser node-by-node, an API sequence is a single node\n(nodeType: "api_sequence") inside an api-only workflow that executes an\nordered list of HTTP calls, chaining outputs from one step into the inputs\nof the next.\n\nWHEN TO USE\n\n - Exercise an API without a UI (integration tests, fixture setup).\n - Seed or tear down state via API before/after a browser run.\n - Fast, deterministic smoke test of a critical flow.\n\nCONFIG SHAPE (ApiSequenceConfig)\n\n {\n "steps": [ ... ], // ordered; required\n "description": "...", // optional\n "stopOnFailure": true, // default: true\n "exports": [ ... ] // optional; see below\n }\n\nSTEP SHAPE (ApiSequenceStep)\n\n The authoritative type is in packages/types/src/api-sequence.ts. Key fields:\n\n {\n "stepId": "createProject", // optional; defaults to "step{index}"\n "apiRequestId": "<catalog id>", // required \u2014 points to api_requests row\n "credentialId": "<uuid>" | null, // null = unauthenticated\n "method": "POST", // optional; falls back to catalog default\n "urlTemplate": "/projects/{{createProject.response.id}}",\n "queryParams": [ { "key": "limit", "value": "10" } ],\n "bodyTemplate": "{\\"name\\": \\"$EMAIL()\\"}", // STRING (JSON), not object\n "headersTemplate": { "X-Trace": "$UUID()" }, // optional\n "responseVariableName": "createProject", // optional alias for stepId\n "expectedStatusCodes": [200, 201], // step fails if status is outside\n "description": "...", // optional UI label\n "validations": [ ... ] // assertions against the response\n }\n\n CREDENTIAL ROLES \u2014 `credentialId` is overloaded; two distinct uses exist:\n\n - Step-level `credentialId` (and a top-level/default credential on the\n sequence) is AUTH: it\'s what the api-sequence runner uses to\n authenticate each HTTP call. Editing this changes who is calling.\n\n - `credentialId` appearing INSIDE a step\'s `bodyTemplate` JSON string is\n DATA: it gets written into the request body and becomes part of the\n provisioned resource (e.g. the credential a created workflow logs in\n with). Editing this changes what the call creates.\n\n The two are independent. Swapping the auth credential won\'t change what\n the call provisions; swapping the body credential won\'t change auth.\n Mixing them up silently produces confusing test failures.\n\n `apiRequestId` references a row in the `api_requests` table. Discover IDs\n from the API catalog for the target property. If `urlTemplate`, `method`,\n or `bodyTemplate` are omitted, they are backfilled from the catalog entry\'s\n defaults at execution time.\n\n `bodyTemplate` is a JSON *string*, not a JSON object \u2014 templates and macros\n are interpolated into it as raw text before parsing.\n\nTEMPLATE SYNTAX\n\n Three ways to inject dynamic values. All three can appear in the same\n string (urlTemplate, bodyTemplate, headersTemplate values, or queryParams\n values).\n\n 1. STEP REFERENCES \u2014 outputs of prior steps.\n\n {{stepId.response.path.to.value}}\n\n The `.response.` segment is REQUIRED. This is the most common foot-gun:\n\n \u2713 {{createProject.response.data.id}}\n \u2717 {{createProject.data.id}} \u2014 missing .response., will warn at\n runtime and resolve to undefined\n \u2717 {{projectId}} \u2014 no step reference at all\n\n The executor logs a warning for malformed references but does NOT\n block execution \u2014 a malformed template resolves to an empty string and\n the request goes out anyway. Always run once and check the run log.\n\n 2. MACROS \u2014 dynamic data generated fresh per run.\n\n $UUID() Random UUID v4\n $NOW() ISO 8601 timestamp (UTC)\n $TODAY() YYYY-MM-DD\n $DATE(N) Date offset by N days\n $TIMESTAMP() Unix ms\n $RANDOM_INT(min,max) Random integer in range\n $RANDOM_STRING(N) Alphanumeric string of length N\n $EMAIL() test-<rand>@example-test.com\n $FIRST_NAME() Random first name\n $LAST_NAME() Random last name\n $PHONE() +1-555-...\n $SEQUENCE("name") Per-run auto-increment counter\n\n Each run resolves macros independently. $SEQUENCE counters persist\n across steps within a single run.\n\n 3. WORKFLOW VARIABLES \u2014 values injected by the workflow runtime.\n\n $var.<name>\n\n These come from two places:\n\n a) Upstream credential variables \u2014 pre-provisioned before the\n sequence runs. Typical names: `$var.credential.username`,\n `$var.credential.password`, `$var.credential.email`. If the\n workflow has a credential, USE these for the matching fields\n instead of `$EMAIL()` / `$RANDOM_STRING()` \u2014 the values must\n match the already-provisioned account.\n\n b) Upstream exports \u2014 values exported by earlier nodes in the same\n workflow (e.g. a seed step produced `$var.project_id`).\n\n Naming convention for exported variables: `resource_field` with\n underscores (e.g. `project_id`, `user_email`).\n\nEXPORTS\n\n After all steps finish, the sequence can register values from the final\n state into the workflow variable registry for downstream nodes:\n\n {\n "exports": [\n {\n "variableName": "project_id", // appears as $var.project_id\n "stepId": "createProject",\n "responsePath": "data.id",\n "source": "response", // "response" (default) | "request"\n "description": "..." // optional UI label\n }\n ]\n }\n\n Only export fields downstream nodes actually need. Use the\n `resource_field` naming convention.\n\nVALIDATIONS\n\n Per-step assertions against the response. Each validation:\n\n {\n "left": "createProject.response.data.status", // "stepId.path" form\n "operator": "equals", // see list\n "right": "active", // omit for is_set/is_not_set\n "description": "..."\n }\n\n Operators: equals, not_equals, gt, lt, gte, lte, contains, not_contains,\n is_set, is_not_set.\n\nWHAT IS VALIDATED AT COMMIT TIME\n\n Very little. The save endpoint accepts anything matching\n { steps: any[], description?, stopOnFailure?, exports? }. It does NOT\n verify that apiRequestId exists in the catalog, that stepIds are unique,\n that {{stepId.response.path}} references a real step, that exports\'\n stepId resolves, or that macro names are known.\n\n The only template sanity check is `validateStepTemplates()` in\n apps/api/src/workflows/execution/api-sequence-executor.ts, which runs\n at EXECUTE time, only flags `{{stepId.X}}` missing `.response.`, and only\n LOGS A WARNING \u2014 the sequence still runs. Always smoke-test the sequence\n before committing it to a workflow.\n\n CLI users: see `canary workflow guide cli-api-sequences` for command usage.\n\nEXAMPLE (3-step sequence, correct shape)\n\n {\n "description": "Create an item, fetch it, delete it",\n "stopOnFailure": true,\n "steps": [\n {\n "stepId": "create",\n "apiRequestId": "<POST /items id>",\n "credentialId": null,\n "method": "POST",\n "bodyTemplate": "{\\"name\\": \\"item-$RANDOM_STRING(6)\\"}",\n "expectedStatusCodes": [201]\n },\n {\n "stepId": "fetch",\n "apiRequestId": "<GET /items/:id id>",\n "credentialId": null,\n "method": "GET",\n "urlTemplate": "/items/{{create.response.id}}",\n "expectedStatusCodes": [200],\n "validations": [\n { "left": "fetch.response.name", "operator": "is_set" }\n ]\n },\n {\n "stepId": "remove",\n "apiRequestId": "<DELETE /items/:id id>",\n "credentialId": null,\n "method": "DELETE",\n "urlTemplate": "/items/{{create.response.id}}",\n "expectedStatusCodes": [200, 204]\n }\n ],\n "exports": [\n { "variableName": "item_id", "stepId": "create", "responsePath": "id" }\n ]\n }\n\nTIPS\n\n - Give every step a short, stable `stepId`. Later steps and exports\n reference it; the default `stepN` label is brittle to reordering.\n - Always include `.response.` in step references. The runtime won\'t\n error out, but values will silently resolve to empty strings.\n - Prefer `$EMAIL()`, `$UUID()`, `$RANDOM_STRING()` for fields that must\n be unique per run \u2014 unless the workflow has an upstream credential,\n in which case use `$var.credential.username` / `.password` / `.email`.\n - Set `expectedStatusCodes` on every step so unexpected 4xx/5xx fails\n the step instead of silently flowing into the next template.\n - `bodyTemplate` is a JSON STRING. Escape inner quotes.\n - `workflow api-sequence generate` can AI-draft a config from a prompt\n or a prior browser run; refine with `update`. The generated output is\n just JSON \u2014 review it before committing.\n';
|
|
2849
|
+
|
|
2850
|
+
// ../content/src/workflow-guide/cli-seeds.txt
|
|
2851
|
+
var cli_seeds_default = 'Use this section when you need to create or inspect seed workflows from the\nCanary CLI. The conceptual model lives in `seed-workflows`; this section is\nonly command orientation. Prefer each command\'s `--help` output for the\ncurrent flag list.\n\nCREATE A SIMPLE SEED SHELL\n\n For simple data outputs, create the seed shell with repeatable output\n declarations:\n\n canary workflow seed create \\\n --name "Create Test Project" \\\n --description "Provisions a test project with default settings" \\\n --output "project_id:Project ID:The created project\'s UUID" \\\n --output "project_name:Project Name"\n\n This creates a draft seed workflow and, by default, a paired teardown\n workflow linked to the seed.\n\nCREATE A COMPLEX SEED SHELL\n\n For credential outputs or larger configs, put the full seed definition in a\n JSON file and create from that file:\n\n canary workflow seed create --from-file seed.json\n\n Keep generated credential fields in credentialOutput, and keep exported data\n fields in outputs. Downstream workflows consume both through $var.{dataKey}.\n\nAFTER CREATION\n\n Add the seed body through the Flow Designer or workflow sync, test both the\n seed and teardown, then publish. If the seed can be API-backed, generate or\n attach an API sequence after the browser version is proven.\n\nREFERENCE\n\n - Seed command help: `canary workflow seed --help`\n - Workflow sync help: `canary workflow sync --help`\n';
|
|
2852
|
+
|
|
2853
|
+
// ../content/src/workflow-guide/cli-api-sequences.txt
|
|
2854
|
+
var cli_api_sequences_default = 'Use this section when you need to drive API sequence authoring from the\nCanary CLI. The conceptual model lives in `api-sequences`; this section is\nonly command orientation. Prefer each command\'s `--help` output for the\ncurrent flag list.\n\nCOMMON FLOW\n\n 1. Inspect the API catalog for candidate endpoints.\n\n canary api-catalog sources list\n canary api-catalog endpoints list --search <keyword>\n canary api-catalog endpoints get <apiRequestId>\n\n 2. Compose a sequence JSON file locally. Reference endpoints by\n apiRequestId and chain steps with {{stepId.response.path}}.\n\n 3. Create an API-only workflow from the sequence file.\n\n canary workflow create --api-only --name "smoke: create project" \\\n --sequence-from-file seq.json\n\n The command returns the workflow, sequence, and node IDs.\n\n 4. Execute immediately to surface template warnings and API failures.\n\n canary workflow api-sequence execute <workflowId>\n\n 5. Inspect the run and iterate on the sequence file.\n\n canary workflow runs list <workflowId> --side api\n canary workflow runs get <workflowId> <runId>\n canary workflow api-sequence update <workflowId> --from-file seq.json\n\nREFERENCE\n\n - Command help: `canary workflow api-sequence --help`\n - Workflow creation help: `canary workflow create --help`\n - API catalog help: `canary api-catalog --help`\n';
|
|
2855
|
+
|
|
2856
|
+
// ../content/src/workflow-guide/index.ts
|
|
2857
|
+
var WORKFLOW_GUIDE_CONCEPT_SECTIONS = [
|
|
2858
|
+
{ title: "Workflow Overview", slug: "overview", content: overview_default },
|
|
2859
|
+
{ title: "Node Types", slug: "node-types", content: node_types_default },
|
|
2860
|
+
{ title: "Playbooks & Cache", slug: "playbook", content: playbook_default },
|
|
2861
|
+
{ title: "Best Practices", slug: "best-practices", content: best_practices_default },
|
|
2862
|
+
{ title: "Macros & Variables", slug: "macros", content: macros_default },
|
|
2863
|
+
{ title: "Seed Workflows", slug: "seed-workflows", content: seed_workflows_default },
|
|
2864
|
+
{ title: "Test Data Lifecycle", slug: "data-lifecycle", content: data_lifecycle_default },
|
|
2865
|
+
{ title: "Assertions", slug: "assertions", content: assertions_default },
|
|
2866
|
+
{ title: "API Sequences", slug: "api-sequences", content: api_sequences_default }
|
|
2867
|
+
];
|
|
2868
|
+
var WORKFLOW_GUIDE_CLI_SECTIONS = [
|
|
2869
|
+
{ title: "CLI: Seed Workflows", slug: "cli-seeds", content: cli_seeds_default },
|
|
2870
|
+
{ title: "CLI: API Sequences", slug: "cli-api-sequences", content: cli_api_sequences_default }
|
|
2871
|
+
];
|
|
2872
|
+
var WORKFLOW_GUIDE_SECTIONS = [
|
|
2873
|
+
...WORKFLOW_GUIDE_CONCEPT_SECTIONS,
|
|
2874
|
+
...WORKFLOW_GUIDE_CLI_SECTIONS
|
|
2875
|
+
];
|
|
2876
|
+
|
|
2877
|
+
// src/workflow.ts
|
|
2878
|
+
var WORKFLOW_STATUS_DESCRIPTIONS = {
|
|
2879
|
+
draft: "editable, not run by suites",
|
|
2880
|
+
published: "active, included in regression runs"
|
|
2881
|
+
};
|
|
2882
|
+
var formatWorkflowStatusList = () => USER_SETTABLE_WORKFLOW_STATUSES.map(
|
|
2883
|
+
(s) => `${s} (${WORKFLOW_STATUS_DESCRIPTIONS[s]})`
|
|
2884
|
+
).join(", ");
|
|
2885
|
+
function truncate(text, max) {
|
|
2886
|
+
if (text.length <= max) return text;
|
|
2887
|
+
return text.slice(0, max - 1) + "\u2026";
|
|
2888
|
+
}
|
|
2889
|
+
function getNodeTitle(node) {
|
|
2890
|
+
const c = node.configJson ?? {};
|
|
2891
|
+
switch (node.nodeType) {
|
|
2892
|
+
case "login":
|
|
2893
|
+
return c.credentialName || NODE_TYPE_LABEL.login;
|
|
2894
|
+
case "navigate":
|
|
2895
|
+
return c.pageTitle || truncate(c.pageUrl || "Navigate", 40);
|
|
2896
|
+
case "action":
|
|
2897
|
+
return c.customActionTitle || truncate(c.actionDescription || "Action", 50);
|
|
2898
|
+
case "assertion":
|
|
2899
|
+
return truncate(c.condition || "Assertion", 50);
|
|
2900
|
+
case "setup":
|
|
2901
|
+
return c.setupFlowName || "Setup";
|
|
2902
|
+
case "seed":
|
|
2903
|
+
return c.seedWorkflowName || "Seed";
|
|
2904
|
+
case "wait":
|
|
2905
|
+
return "Wait";
|
|
2906
|
+
case "condition":
|
|
2907
|
+
return truncate(c.conditionDescription || "Condition", 50);
|
|
2908
|
+
case "end":
|
|
2909
|
+
return "End";
|
|
2910
|
+
case "api_sequence":
|
|
2911
|
+
return "API Sequence";
|
|
2912
|
+
default:
|
|
2913
|
+
return node.nodeType;
|
|
2914
|
+
}
|
|
2915
|
+
}
|
|
2916
|
+
function getCleanConfig(node) {
|
|
2917
|
+
const c = node.configJson ?? {};
|
|
2918
|
+
switch (node.nodeType) {
|
|
2919
|
+
case "login":
|
|
2920
|
+
return pick(c, ["credentialName", "performLogin"]);
|
|
2921
|
+
case "navigate":
|
|
2922
|
+
return {
|
|
2923
|
+
...pick(c, ["pageUrl", "pageTitle"]),
|
|
2924
|
+
...c.openInNewTab ? { openInNewTab: true } : {}
|
|
2925
|
+
};
|
|
2926
|
+
case "action":
|
|
2927
|
+
return pick(c, [
|
|
2928
|
+
"actionDescription",
|
|
2929
|
+
"customActionInstructions",
|
|
2930
|
+
"playbookSteps",
|
|
2931
|
+
"inputValues",
|
|
2932
|
+
"declaredOutputs",
|
|
2933
|
+
"capturedVariableKeys",
|
|
2934
|
+
"dataRequirements",
|
|
2935
|
+
"alwaysUseAgent"
|
|
2936
|
+
]);
|
|
2937
|
+
case "assertion":
|
|
2938
|
+
return pick(c, ["condition", "expectedOutcome", "strict", "alwaysUseAgent"]);
|
|
2939
|
+
case "setup":
|
|
2940
|
+
return pick(c, ["setupFlowId", "setupFlowName"]);
|
|
2941
|
+
case "seed":
|
|
2942
|
+
return pick(c, ["seedWorkflowId", "seedWorkflowName", "snapshotVersion"]);
|
|
2943
|
+
case "end":
|
|
2944
|
+
return pick(c, ["outcome"]);
|
|
2945
|
+
case "wait":
|
|
2946
|
+
case "condition":
|
|
2947
|
+
case "api_sequence":
|
|
2948
|
+
return c;
|
|
2949
|
+
default:
|
|
2950
|
+
return c;
|
|
2951
|
+
}
|
|
2952
|
+
}
|
|
2953
|
+
function pick(obj, keys) {
|
|
2954
|
+
const result = {};
|
|
2955
|
+
for (const key of keys) {
|
|
2956
|
+
if (obj[key] !== void 0 && obj[key] !== null) {
|
|
2957
|
+
result[key] = obj[key];
|
|
2958
|
+
}
|
|
2959
|
+
}
|
|
2960
|
+
return result;
|
|
2961
|
+
}
|
|
2962
|
+
function formatWorkflowMarkdown(workflow) {
|
|
2963
|
+
const seedOutputs = workflow.seedOutputsJson ?? workflow.seedOutputs;
|
|
2964
|
+
return workflowToSpec({ ...workflow, seedOutputs });
|
|
2965
|
+
}
|
|
2966
|
+
function formatWorkflowJson(workflow) {
|
|
2967
|
+
const sorted = [...workflow.nodes].sort((a, b) => a.orderIndex - b.orderIndex);
|
|
2968
|
+
const seedOutputs = workflow.seedOutputsJson;
|
|
2969
|
+
return {
|
|
2970
|
+
workflow: {
|
|
2971
|
+
id: workflow.id,
|
|
2972
|
+
name: workflow.name,
|
|
2973
|
+
description: workflow.description,
|
|
2974
|
+
status: workflow.status,
|
|
2975
|
+
flowType: workflow.flowType,
|
|
2976
|
+
...seedOutputs && seedOutputs.length > 0 ? { seedOutputs } : {}
|
|
2977
|
+
},
|
|
2978
|
+
steps: sorted.map((node, i) => ({
|
|
2979
|
+
index: i + 1,
|
|
2980
|
+
nodeId: node.id,
|
|
2981
|
+
nodeType: node.nodeType,
|
|
2982
|
+
title: getNodeTitle(node),
|
|
2983
|
+
config: getCleanConfig(node)
|
|
2984
|
+
}))
|
|
2985
|
+
};
|
|
2986
|
+
}
|
|
2987
|
+
async function handleList(argv, apiUrl, token) {
|
|
2988
|
+
const jsonOutput = hasFlag(argv, "--json");
|
|
2989
|
+
const params = new URLSearchParams();
|
|
2990
|
+
const search = getArgValue(argv, "--search");
|
|
2991
|
+
const flowType = getArgValue(argv, "--flow-type");
|
|
2992
|
+
const status = getArgValue(argv, "--status");
|
|
2993
|
+
const page = getArgValue(argv, "--page");
|
|
2994
|
+
const pageSize = getArgValue(argv, "--page-size");
|
|
2995
|
+
if (search) params.set("search", search);
|
|
2996
|
+
if (flowType) params.set("flowType", flowType);
|
|
2997
|
+
if (status) params.set("status", status);
|
|
2998
|
+
if (page) params.set("page", page);
|
|
2999
|
+
if (pageSize) params.set("pageSize", pageSize);
|
|
3000
|
+
const qs = params.toString();
|
|
3001
|
+
const path = `/workflows${qs ? `?${qs}` : ""}`;
|
|
3002
|
+
const result = await apiRequest(apiUrl, token, "GET", path);
|
|
3003
|
+
if (!result.ok) {
|
|
3004
|
+
console.error(`Error: ${result.error}`);
|
|
3005
|
+
process9.exit(1);
|
|
3006
|
+
}
|
|
3007
|
+
if (jsonOutput) {
|
|
3008
|
+
console.log(JSON.stringify({ data: result.data, pagination: result.pagination }, null, 2));
|
|
3009
|
+
return;
|
|
3010
|
+
}
|
|
3011
|
+
const { data, pagination } = result;
|
|
3012
|
+
console.log(
|
|
3013
|
+
`Workflows: ${pagination.totalItems} total (page ${pagination.page}/${pagination.totalPages})
|
|
3014
|
+
`
|
|
3015
|
+
);
|
|
3016
|
+
if (data.length === 0) {
|
|
3017
|
+
console.log("No workflows found.");
|
|
3018
|
+
return;
|
|
3019
|
+
}
|
|
3020
|
+
const idW = 36;
|
|
3021
|
+
const nameW = 30;
|
|
3022
|
+
const statusW = 12;
|
|
3023
|
+
const typeW = 10;
|
|
3024
|
+
const header = [
|
|
3025
|
+
"ID".padEnd(idW),
|
|
3026
|
+
"Name".padEnd(nameW),
|
|
3027
|
+
"Status".padEnd(statusW),
|
|
3028
|
+
"Type".padEnd(typeW)
|
|
3029
|
+
].join(" ");
|
|
3030
|
+
console.log(header);
|
|
3031
|
+
for (const w of data) {
|
|
3032
|
+
const row = [
|
|
3033
|
+
w.id.padEnd(idW),
|
|
3034
|
+
truncate(w.name, nameW).padEnd(nameW),
|
|
3035
|
+
w.status.padEnd(statusW),
|
|
3036
|
+
w.flowType.padEnd(typeW)
|
|
3037
|
+
].join(" ");
|
|
3038
|
+
console.log(row);
|
|
3039
|
+
}
|
|
3040
|
+
}
|
|
3041
|
+
async function handleGet2(argv, apiUrl, token) {
|
|
3042
|
+
const workflowId = argv[0];
|
|
3043
|
+
if (!workflowId || workflowId.startsWith("--")) {
|
|
3044
|
+
console.error("Error: Missing workflow ID.");
|
|
3045
|
+
console.error("Usage: canary workflow get <workflowId>");
|
|
3046
|
+
process9.exit(1);
|
|
3047
|
+
}
|
|
3048
|
+
const _jsonOutput = hasFlag(argv, "--json") || getArgValue(argv, "--format") === "json";
|
|
3049
|
+
const markdownOutput = hasFlag(argv, "--markdown") || getArgValue(argv, "--format") === "markdown" || getArgValue(argv, "--format") === "spec";
|
|
3050
|
+
const outputFile = getArgValue(argv, "--output");
|
|
3051
|
+
const apiRes = await apiFetch(`${apiUrl}/workflows/${workflowId}`, {
|
|
3052
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
3053
|
+
token
|
|
3054
|
+
});
|
|
3055
|
+
if (!apiRes.ok) exitOnFailure(apiRes.failure);
|
|
3056
|
+
const result = await apiRes.response.json();
|
|
3057
|
+
if (result.error === "WORKFLOW_NOT_FOUND") {
|
|
3058
|
+
console.error("Error: Workflow not found.");
|
|
3059
|
+
process9.exit(1);
|
|
3060
|
+
}
|
|
3061
|
+
if (result.error === "WORKFLOW_WRONG_ORG") {
|
|
3062
|
+
console.error(
|
|
3063
|
+
`Error: Workflow belongs to another org: ${result.targetOrgName ?? result.targetOrgId}`
|
|
3064
|
+
);
|
|
3065
|
+
process9.exit(1);
|
|
3066
|
+
}
|
|
3067
|
+
if (!result.workflow) {
|
|
3068
|
+
console.error(`Error: ${result.error ?? "Unexpected response"}`);
|
|
3069
|
+
process9.exit(1);
|
|
3070
|
+
}
|
|
3071
|
+
const workflow = result.workflow;
|
|
3072
|
+
let output;
|
|
3073
|
+
if (markdownOutput) {
|
|
3074
|
+
output = formatWorkflowMarkdown(workflow);
|
|
3075
|
+
} else {
|
|
3076
|
+
output = JSON.stringify(formatWorkflowJson(workflow), null, 2);
|
|
3077
|
+
}
|
|
3078
|
+
if (outputFile) {
|
|
3079
|
+
try {
|
|
3080
|
+
await fs5.writeFile(outputFile, output, "utf-8");
|
|
3081
|
+
console.log(`Written to ${outputFile}`);
|
|
3082
|
+
} catch (err) {
|
|
3083
|
+
console.error(
|
|
3084
|
+
`Error writing to ${outputFile}: ${err instanceof Error ? err.message : String(err)}`
|
|
3085
|
+
);
|
|
3086
|
+
process9.exit(1);
|
|
3087
|
+
}
|
|
3088
|
+
return;
|
|
3089
|
+
}
|
|
3090
|
+
console.log(output);
|
|
3091
|
+
if (!markdownOutput) {
|
|
3092
|
+
const appUrl = resolveAppUrl(apiUrl);
|
|
3093
|
+
console.log(`
|
|
3094
|
+
URL: ${appUrl}/flows/${workflowId}`);
|
|
3095
|
+
}
|
|
3096
|
+
}
|
|
3097
|
+
async function handlePatch(argv, apiUrl, token) {
|
|
3098
|
+
const workflowId = argv.find((a) => !a.startsWith("-"));
|
|
3099
|
+
if (!workflowId) {
|
|
3100
|
+
console.error("Error: Missing workflow ID.");
|
|
3101
|
+
console.error("Usage: canary workflow patch <workflowId> [--name ...] [--description ...] [--status ...] [--flow-type ...]");
|
|
3102
|
+
process9.exit(1);
|
|
3103
|
+
}
|
|
3104
|
+
const name = getArgValue(argv, "--name");
|
|
3105
|
+
const description = getArgValue(argv, "--description");
|
|
3106
|
+
const status = getArgValue(argv, "--status");
|
|
3107
|
+
const flowType = getArgValue(argv, "--flow-type");
|
|
3108
|
+
const seedOutputsJsonRaw = getArgValue(argv, "--seed-outputs-json");
|
|
3109
|
+
const seedOutputsFile = getArgValue(argv, "--seed-outputs-file");
|
|
3110
|
+
const jsonOutput = hasFlag(argv, "--json");
|
|
3111
|
+
const hasTagFlag = getArgValue(argv, "--tags") !== void 0 || getArgValue(argv, "--tags-add") !== void 0 || getArgValue(argv, "--tags-remove") !== void 0 || getArgValue(argv, "--tag-id") !== void 0;
|
|
3112
|
+
if (!name && !description && !status && !flowType && !seedOutputsJsonRaw && !seedOutputsFile && !hasTagFlag) {
|
|
3113
|
+
console.error(
|
|
3114
|
+
"Error: No fields to update. Provide at least one of: --name, --description, --status, --flow-type, --seed-outputs-json, --seed-outputs-file, --tags, --tags-add, --tags-remove, --tag-id"
|
|
3115
|
+
);
|
|
3116
|
+
process9.exit(1);
|
|
3117
|
+
}
|
|
3118
|
+
if (status && !USER_SETTABLE_WORKFLOW_STATUSES.includes(status)) {
|
|
3119
|
+
console.error(`Error: Invalid status "${status}". Valid: ${formatWorkflowStatusList()}`);
|
|
3120
|
+
process9.exit(1);
|
|
3121
|
+
}
|
|
3122
|
+
const validFlowTypes = ["standard", "setup", "seed", "teardown"];
|
|
3123
|
+
if (flowType && !validFlowTypes.includes(flowType)) {
|
|
3124
|
+
console.error(`Error: Invalid flow type "${flowType}". Must be one of: ${validFlowTypes.join(", ")}`);
|
|
3125
|
+
process9.exit(1);
|
|
3126
|
+
}
|
|
3127
|
+
const body = {};
|
|
3128
|
+
if (name) body.name = name;
|
|
3129
|
+
if (description) body.description = description;
|
|
3130
|
+
if (status) body.status = status;
|
|
3131
|
+
if (flowType) body.flowType = flowType;
|
|
3132
|
+
if (seedOutputsJsonRaw || seedOutputsFile) {
|
|
3133
|
+
let raw;
|
|
3134
|
+
if (seedOutputsFile) {
|
|
3135
|
+
try {
|
|
3136
|
+
raw = await fs5.readFile(seedOutputsFile, "utf-8");
|
|
3137
|
+
} catch (err) {
|
|
3138
|
+
console.error(
|
|
3139
|
+
`Error reading ${seedOutputsFile}: ${err instanceof Error ? err.message : String(err)}`
|
|
3140
|
+
);
|
|
3141
|
+
process9.exit(1);
|
|
3142
|
+
}
|
|
3143
|
+
} else {
|
|
3144
|
+
raw = seedOutputsJsonRaw;
|
|
3145
|
+
}
|
|
3146
|
+
try {
|
|
3147
|
+
const parsed = JSON.parse(raw);
|
|
3148
|
+
if (!Array.isArray(parsed)) {
|
|
3149
|
+
throw new Error("seedOutputsJson must be a JSON array");
|
|
3150
|
+
}
|
|
3151
|
+
body.seedOutputsJson = parsed;
|
|
3152
|
+
} catch (err) {
|
|
3153
|
+
console.error(
|
|
3154
|
+
`Error: Invalid --seed-outputs-json: ${err instanceof Error ? err.message : String(err)}`
|
|
3155
|
+
);
|
|
3156
|
+
process9.exit(1);
|
|
3157
|
+
}
|
|
3158
|
+
}
|
|
3159
|
+
let tagResult = null;
|
|
3160
|
+
if (Object.keys(body).length > 0) {
|
|
3161
|
+
const result = await apiRequest(
|
|
3162
|
+
apiUrl,
|
|
3163
|
+
token,
|
|
3164
|
+
"PUT",
|
|
3165
|
+
`/workflows/${workflowId}`,
|
|
3166
|
+
body
|
|
3167
|
+
);
|
|
3168
|
+
if (!result.ok) {
|
|
3169
|
+
if (jsonOutput) {
|
|
3170
|
+
console.log(JSON.stringify({ ok: false, error: result.error ?? "Unknown error" }, null, 2));
|
|
3171
|
+
} else {
|
|
3172
|
+
console.error(`Error: ${result.error ?? "Unknown error"}`);
|
|
3173
|
+
}
|
|
3174
|
+
process9.exit(1);
|
|
3175
|
+
}
|
|
3176
|
+
}
|
|
3177
|
+
if (hasTagFlag) {
|
|
3178
|
+
try {
|
|
3179
|
+
tagResult = await applyWorkflowTagFlags({ apiUrl, token, argv, workflowId });
|
|
3180
|
+
} catch (err) {
|
|
3181
|
+
if (jsonOutput) {
|
|
3182
|
+
console.log(
|
|
3183
|
+
JSON.stringify(
|
|
3184
|
+
{ ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
3185
|
+
null,
|
|
3186
|
+
2
|
|
3187
|
+
)
|
|
3188
|
+
);
|
|
3189
|
+
} else {
|
|
3190
|
+
console.error(`Error applying tags:
|
|
3191
|
+
${err instanceof Error ? err.message : String(err)}`);
|
|
3192
|
+
}
|
|
3193
|
+
process9.exit(1);
|
|
3194
|
+
}
|
|
3195
|
+
}
|
|
3196
|
+
if (jsonOutput) {
|
|
3197
|
+
console.log(
|
|
3198
|
+
JSON.stringify(
|
|
3199
|
+
{ ok: true, workflowId, updated: body, tags: tagResult?.tagIds ?? null },
|
|
3200
|
+
null,
|
|
3201
|
+
2
|
|
3202
|
+
)
|
|
3203
|
+
);
|
|
3204
|
+
return;
|
|
3205
|
+
}
|
|
3206
|
+
const updatedKeys = Object.keys(body);
|
|
3207
|
+
if (updatedKeys.length > 0) {
|
|
3208
|
+
console.log(`Workflow ${workflowId} updated (${updatedKeys.join(", ")}).`);
|
|
3209
|
+
}
|
|
3210
|
+
if (tagResult) {
|
|
3211
|
+
console.log(`Tags: ${tagResult.tagIds.length} assigned.`);
|
|
3212
|
+
}
|
|
3213
|
+
}
|
|
3214
|
+
async function handleArchive(argv, apiUrl, token) {
|
|
3215
|
+
const workflowId = argv.find((a) => !a.startsWith("-"));
|
|
3216
|
+
if (!workflowId) {
|
|
3217
|
+
console.error("Error: Missing workflow ID.");
|
|
3218
|
+
console.error("Usage: canary workflow archive <workflowId> [--yes] [--json]");
|
|
3219
|
+
process9.exit(1);
|
|
3220
|
+
}
|
|
3221
|
+
const jsonOutput = hasFlag(argv, "--json");
|
|
3222
|
+
const skipConfirm = hasFlag(argv, "--yes", "-y");
|
|
3223
|
+
if (!skipConfirm) {
|
|
3224
|
+
if (!process9.stdin.isTTY) {
|
|
3225
|
+
console.error(
|
|
3226
|
+
"Error: Archiving requires confirmation. Pass --yes (or -y) for non-interactive use."
|
|
3227
|
+
);
|
|
3228
|
+
process9.exit(1);
|
|
3229
|
+
}
|
|
3230
|
+
process9.stdout.write(
|
|
3231
|
+
`Archive workflow ${workflowId}? It will be hidden from lists and skipped by test suites. [y/N] `
|
|
3232
|
+
);
|
|
3233
|
+
const answer = await new Promise((resolve) => {
|
|
3234
|
+
process9.stdin.setEncoding("utf-8");
|
|
3235
|
+
process9.stdin.once("data", (chunk) => resolve(String(chunk).trim().toLowerCase()));
|
|
3236
|
+
});
|
|
3237
|
+
if (answer !== "y" && answer !== "yes") {
|
|
3238
|
+
console.error("Aborted.");
|
|
3239
|
+
process9.exit(1);
|
|
3240
|
+
}
|
|
3241
|
+
}
|
|
3242
|
+
const result = await apiRequest(
|
|
3243
|
+
apiUrl,
|
|
3244
|
+
token,
|
|
3245
|
+
"DELETE",
|
|
3246
|
+
`/workflows/${workflowId}`
|
|
3247
|
+
);
|
|
3248
|
+
if (result.error) {
|
|
3249
|
+
if (jsonOutput) {
|
|
3250
|
+
console.log(JSON.stringify(result, null, 2));
|
|
3251
|
+
} else {
|
|
3252
|
+
renderWorkflowUserError(result);
|
|
3253
|
+
}
|
|
3254
|
+
process9.exit(1);
|
|
3255
|
+
}
|
|
3256
|
+
if (jsonOutput) {
|
|
3257
|
+
console.log(JSON.stringify(result, null, 2));
|
|
3258
|
+
} else {
|
|
3259
|
+
console.log(`Archived workflow ${workflowId}. Use 'canary workflow unarchive ${workflowId}' to restore.`);
|
|
3260
|
+
}
|
|
3261
|
+
}
|
|
3262
|
+
async function handleUnarchive(argv, apiUrl, token) {
|
|
3263
|
+
const workflowId = argv.find((a) => !a.startsWith("-"));
|
|
3264
|
+
if (!workflowId) {
|
|
3265
|
+
console.error("Error: Missing workflow ID.");
|
|
3266
|
+
console.error("Usage: canary workflow unarchive <workflowId> [--json]");
|
|
3267
|
+
process9.exit(1);
|
|
3268
|
+
}
|
|
3269
|
+
const jsonOutput = hasFlag(argv, "--json");
|
|
3270
|
+
const result = await apiRequest(
|
|
3271
|
+
apiUrl,
|
|
3272
|
+
token,
|
|
3273
|
+
"POST",
|
|
3274
|
+
`/workflows/${workflowId}/unarchive`,
|
|
3275
|
+
{}
|
|
3276
|
+
);
|
|
3277
|
+
if (result.error) {
|
|
3278
|
+
if (jsonOutput) {
|
|
3279
|
+
console.log(JSON.stringify(result, null, 2));
|
|
3280
|
+
} else {
|
|
3281
|
+
renderWorkflowUserError(result);
|
|
3282
|
+
}
|
|
3283
|
+
process9.exit(1);
|
|
3284
|
+
}
|
|
3285
|
+
if (jsonOutput) {
|
|
3286
|
+
console.log(JSON.stringify(result, null, 2));
|
|
3287
|
+
} else {
|
|
3288
|
+
console.log(`Restored workflow ${workflowId}.`);
|
|
3289
|
+
}
|
|
3290
|
+
}
|
|
3291
|
+
async function handlePublish(argv, apiUrl, token) {
|
|
3292
|
+
const workflowId = argv.find((a) => !a.startsWith("-"));
|
|
3293
|
+
if (!workflowId) {
|
|
3294
|
+
console.error("Error: Missing workflow ID.");
|
|
3295
|
+
console.error("Usage: canary workflow publish <workflowId> [--force] [--json]");
|
|
3296
|
+
process9.exit(1);
|
|
3297
|
+
}
|
|
3298
|
+
const force = hasFlag(argv, "--force");
|
|
3299
|
+
const jsonOutput = hasFlag(argv, "--json");
|
|
3300
|
+
const result = await apiRequest(
|
|
3301
|
+
apiUrl,
|
|
3302
|
+
token,
|
|
3303
|
+
"POST",
|
|
3304
|
+
`/workflows/${workflowId}/publish`,
|
|
3305
|
+
force ? { force: true } : {}
|
|
3306
|
+
);
|
|
3307
|
+
if (result.error) {
|
|
3308
|
+
if (jsonOutput) {
|
|
3309
|
+
console.log(JSON.stringify(result, null, 2));
|
|
3310
|
+
} else {
|
|
3311
|
+
const hint = result.code === "FLOW_NOT_READY_TO_PUBLISH" ? "\n Trigger a successful run first (canary workflow run <id>), or pass --force." : "";
|
|
3312
|
+
console.error(`Error: ${result.error}${hint}`);
|
|
3313
|
+
}
|
|
3314
|
+
process9.exit(1);
|
|
3315
|
+
}
|
|
3316
|
+
const version = result.workflow?.snapshotVersion;
|
|
3317
|
+
if (jsonOutput) {
|
|
3318
|
+
console.log(
|
|
3319
|
+
JSON.stringify({ ok: true, workflowId, workflow: result.workflow ?? null }, null, 2)
|
|
3320
|
+
);
|
|
3321
|
+
return;
|
|
3322
|
+
}
|
|
3323
|
+
console.log(
|
|
3324
|
+
`Workflow ${workflowId} published${version !== void 0 ? ` as snapshot v${version}` : ""}.`
|
|
3325
|
+
);
|
|
3326
|
+
}
|
|
3327
|
+
function getAllArgValues2(argv, flag) {
|
|
3328
|
+
const values = [];
|
|
3329
|
+
for (let i = 0; i < argv.length; i++) {
|
|
3330
|
+
if (argv[i] === flag && i + 1 < argv.length) {
|
|
3331
|
+
values.push(argv[i + 1]);
|
|
3332
|
+
i++;
|
|
3333
|
+
}
|
|
3334
|
+
}
|
|
3335
|
+
return values;
|
|
3336
|
+
}
|
|
3337
|
+
async function handleNodePatch(argv, apiUrl, token) {
|
|
3338
|
+
const positional = argv.filter((a) => !a.startsWith("-"));
|
|
3339
|
+
const workflowId = positional[0];
|
|
3340
|
+
const nodeId = positional[1];
|
|
3341
|
+
if (!workflowId || !nodeId) {
|
|
3342
|
+
console.error("Error: Missing workflow ID and/or node ID.");
|
|
3343
|
+
console.error(
|
|
3344
|
+
`Usage: canary workflow node patch <workflowId> <nodeId> --set-input key=value [--config '{"..."}'] [--json]`
|
|
3345
|
+
);
|
|
3346
|
+
process9.exit(1);
|
|
3347
|
+
}
|
|
3348
|
+
const configArg = getArgValue(argv, "--config");
|
|
3349
|
+
const setInputArgs = getAllArgValues2(argv, "--set-input");
|
|
3350
|
+
const jsonOutput = hasFlag(argv, "--json");
|
|
3351
|
+
if (!configArg && setInputArgs.length === 0) {
|
|
3352
|
+
console.error(`Error: No changes specified. Provide --set-input key=value and/or --config '{"..."}'`);
|
|
3353
|
+
process9.exit(1);
|
|
3354
|
+
}
|
|
3355
|
+
let configJson = {};
|
|
3356
|
+
if (configArg) {
|
|
3357
|
+
try {
|
|
3358
|
+
configJson = JSON.parse(configArg);
|
|
3359
|
+
} catch (e) {
|
|
3360
|
+
console.error(`Error: Invalid JSON in --config: ${e instanceof Error ? e.message : String(e)}`);
|
|
3361
|
+
process9.exit(1);
|
|
3362
|
+
}
|
|
3363
|
+
}
|
|
3364
|
+
if (setInputArgs.length > 0) {
|
|
3365
|
+
const inputValues = configJson.inputValues ?? {};
|
|
3366
|
+
for (const arg of setInputArgs) {
|
|
3367
|
+
const eqIdx = arg.indexOf("=");
|
|
3368
|
+
if (eqIdx === -1) {
|
|
3369
|
+
console.error(`Error: --set-input must be key=value, got: "${arg}"`);
|
|
3370
|
+
process9.exit(1);
|
|
3371
|
+
}
|
|
3372
|
+
inputValues[arg.slice(0, eqIdx)] = arg.slice(eqIdx + 1);
|
|
3373
|
+
}
|
|
3374
|
+
configJson.inputValues = inputValues;
|
|
3375
|
+
}
|
|
3376
|
+
const getRes = await fetchOrExit(`${apiUrl}/workflows/${workflowId}`, {
|
|
3377
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
3378
|
+
token
|
|
3379
|
+
});
|
|
3380
|
+
const getResult = await getRes.json();
|
|
3381
|
+
if (!getResult.workflow) {
|
|
3382
|
+
console.error(`Error: Workflow not found.`);
|
|
3383
|
+
process9.exit(1);
|
|
3384
|
+
}
|
|
3385
|
+
const existingNode = getResult.workflow.nodes.find((n) => n.id === nodeId);
|
|
3386
|
+
if (!existingNode) {
|
|
3387
|
+
console.error(`Error: Node ${nodeId} not found in workflow ${workflowId}.`);
|
|
3388
|
+
console.error("\nAvailable nodes:");
|
|
3389
|
+
for (const n of getResult.workflow.nodes.sort((a, b) => a.orderIndex - b.orderIndex)) {
|
|
3390
|
+
console.error(` ${n.id} [${n.nodeType}] ${getNodeTitle(n)}`);
|
|
3391
|
+
}
|
|
3392
|
+
process9.exit(1);
|
|
3393
|
+
}
|
|
3394
|
+
const patchRes = await fetchOrExit(`${apiUrl}/workflows/${workflowId}/nodes/${nodeId}`, {
|
|
3395
|
+
method: "PUT",
|
|
3396
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
3397
|
+
token,
|
|
3398
|
+
body: JSON.stringify({ configJson })
|
|
3399
|
+
});
|
|
3400
|
+
if (!patchRes.ok) {
|
|
3401
|
+
const errBody = await patchRes.text();
|
|
3402
|
+
if (jsonOutput) {
|
|
3403
|
+
console.log(errBody || JSON.stringify({ ok: false, status: patchRes.status }, null, 2));
|
|
3404
|
+
} else {
|
|
3405
|
+
let rendered = null;
|
|
3406
|
+
try {
|
|
3407
|
+
const parsed = JSON.parse(errBody);
|
|
3408
|
+
rendered = renderWorkflowUserError(parsed);
|
|
3409
|
+
} catch {
|
|
3410
|
+
rendered = null;
|
|
3411
|
+
}
|
|
3412
|
+
console.error(rendered ?? `Error (${patchRes.status}): ${errBody.slice(0, 500)}`);
|
|
3413
|
+
}
|
|
3414
|
+
process9.exit(1);
|
|
3415
|
+
}
|
|
3416
|
+
const patchResult = await patchRes.json();
|
|
3417
|
+
if (jsonOutput) {
|
|
3418
|
+
console.log(JSON.stringify({ ok: true, node: patchResult.node }, null, 2));
|
|
3419
|
+
return;
|
|
3420
|
+
}
|
|
3421
|
+
const title = getNodeTitle(existingNode);
|
|
3422
|
+
console.log(`Updated node [${existingNode.nodeType}] "${title}" (${nodeId}):
|
|
3423
|
+
`);
|
|
3424
|
+
const oldConfig = existingNode.configJson ?? {};
|
|
3425
|
+
for (const [key, newVal] of Object.entries(configJson)) {
|
|
3426
|
+
if (typeof newVal === "object" && newVal !== null && typeof oldConfig[key] === "object" && oldConfig[key] !== null) {
|
|
3427
|
+
const oldObj = oldConfig[key];
|
|
3428
|
+
const newObj = newVal;
|
|
3429
|
+
for (const [subKey, subVal] of Object.entries(newObj)) {
|
|
3430
|
+
const oldVal = oldObj[subKey];
|
|
3431
|
+
if (oldVal !== subVal) {
|
|
3432
|
+
console.log(` ${key}.${subKey}: ${JSON.stringify(oldVal ?? null)} -> ${JSON.stringify(subVal)}`);
|
|
3433
|
+
}
|
|
3434
|
+
}
|
|
3435
|
+
} else {
|
|
3436
|
+
const oldVal = oldConfig[key];
|
|
3437
|
+
if (oldVal !== newVal) {
|
|
3438
|
+
console.log(` ${key}: ${JSON.stringify(oldVal ?? null)} -> ${JSON.stringify(newVal)}`);
|
|
3439
|
+
}
|
|
3440
|
+
}
|
|
3441
|
+
}
|
|
3442
|
+
}
|
|
3443
|
+
function printWorkflowHelp() {
|
|
3444
|
+
console.log(
|
|
3445
|
+
[
|
|
3446
|
+
"Usage: canary workflow <sub-command> [options]",
|
|
3447
|
+
"",
|
|
3448
|
+
' Tip: Run "canary workflow guide" for the full authoring reference',
|
|
3449
|
+
" (node types, playbooks, macros, seeds, assertions, best practices)",
|
|
3450
|
+
"",
|
|
3451
|
+
"Sub-commands:",
|
|
3452
|
+
" guide [section] [--json] Authoring guide (node-types, macros, etc.)",
|
|
3453
|
+
" list [options] List workflows",
|
|
3454
|
+
" get <workflowId> [options] Get workflow definition with steps",
|
|
3455
|
+
" create [options] Create workflow from JSON",
|
|
3456
|
+
" sync <workflowId> [options] Replace all nodes (same format as create)",
|
|
3457
|
+
" patch <workflowId> [options] Update workflow properties",
|
|
3458
|
+
" publish <workflowId> [--force] [--json] Promote the current draft to a new published snapshot",
|
|
3459
|
+
" archive <workflowId> [--yes] [--json] Soft-delete: hide from lists, skip in test suites (reversible)",
|
|
3460
|
+
" unarchive <workflowId> [--json] Restore a previously archived workflow",
|
|
3461
|
+
" run <workflowId> [options] Trigger a workflow execution",
|
|
3462
|
+
" api-sequence <verb> <workflowId> Manage the API sequence attached to a workflow",
|
|
3463
|
+
" generate-api-sequence <workflowId> [opts] (deprecated alias for `api-sequence generate`)",
|
|
3464
|
+
" seed <sub-command> Create seed workflows",
|
|
3465
|
+
" node <sub-command> Manage individual nodes",
|
|
3466
|
+
" runs <sub-command> Query workflow run results",
|
|
3467
|
+
" debug <runId|sub-command> Debug a run (overview/steps/cache/thoughts/logs/trace)",
|
|
3468
|
+
"",
|
|
3469
|
+
"List options:",
|
|
3470
|
+
" --search <query> Search by name",
|
|
3471
|
+
" --flow-type <type> Filter: standard, setup, seed, teardown",
|
|
3472
|
+
" --status <status> Filter: draft, published",
|
|
3473
|
+
" --page <n> Page number (default: 1)",
|
|
3474
|
+
" --page-size <n> Page size (default: 25)",
|
|
3475
|
+
" --json Output raw JSON",
|
|
3476
|
+
"",
|
|
3477
|
+
"Get options:",
|
|
3478
|
+
" --format json|markdown|spec Output format (default: json)",
|
|
3479
|
+
" --json Shorthand for --format json",
|
|
3480
|
+
" --markdown Shorthand for editable .canary.md spec output",
|
|
3481
|
+
" --output <file> Write output to file",
|
|
3482
|
+
"",
|
|
3483
|
+
"Create options:",
|
|
3484
|
+
" --from-stdin Read workflow JSON from stdin (default)",
|
|
3485
|
+
" --from-file <path> Read workflow JSON from file",
|
|
3486
|
+
" --api-only Create an API-only workflow with a backing ApiSequence",
|
|
3487
|
+
" Requires: --name <name>",
|
|
3488
|
+
" Optional: --credential <id>, --guidance <text>,",
|
|
3489
|
+
" --sequence-from-file <path> or --sequence-from-stdin",
|
|
3490
|
+
" Input shape: { steps, description?, stopOnFailure?, exports? }",
|
|
3491
|
+
" Returns: { workflowId, apiSequenceId, nodeId }",
|
|
3492
|
+
" --run-and-publish Run test first, publish on success",
|
|
3493
|
+
" --publish-timeout <sec> Timeout waiting for the test to finish (default: 900)",
|
|
3494
|
+
" --property <name|id> Property for run-and-publish",
|
|
3495
|
+
" --environment <name|id> Environment for run-and-publish",
|
|
3496
|
+
" --tags <names> Comma-separated tag names to assign (names, not IDs)",
|
|
3497
|
+
" --tag-id <ids> Comma-separated raw tag IDs (escape hatch)",
|
|
3498
|
+
"",
|
|
3499
|
+
" Input: { name, nodes: [{ nodeType, configJson }] }",
|
|
3500
|
+
' Run "canary workflow guide node-types" for available node types.',
|
|
3501
|
+
' Run "canary workflow guide macros" for dynamic test data ($EMAIL, $RANDOM_STRING, etc.)',
|
|
3502
|
+
"",
|
|
3503
|
+
"Sync options:",
|
|
3504
|
+
" --from-file <path> Read nodes JSON from file",
|
|
3505
|
+
" --from-stdin Read nodes JSON from stdin",
|
|
3506
|
+
" --no-snapshot Skip creating a draft snapshot before sync",
|
|
3507
|
+
" --propose Create a proposal instead of applying directly",
|
|
3508
|
+
" --title <title> Proposal title (with --propose)",
|
|
3509
|
+
" --description <desc> Proposal description (with --propose)",
|
|
3510
|
+
" --json Output raw JSON",
|
|
3511
|
+
"",
|
|
3512
|
+
" Sync accepts the same minimal node format as create.",
|
|
3513
|
+
" Edges, IDs, positions, and ordering are auto-generated when omitted.",
|
|
3514
|
+
" Input: { nodes: [{ nodeType, configJson }] }",
|
|
3515
|
+
"",
|
|
3516
|
+
" When using --propose on an existing workflow, run `canary workflow get",
|
|
3517
|
+
" <id>` first and reuse each node's existing `id` for steps you intend",
|
|
3518
|
+
" to keep. Only omit `id` for genuinely new steps. Without ID reuse the",
|
|
3519
|
+
" proposal will diff as a full remove-everything/add-everything rewrite.",
|
|
3520
|
+
"",
|
|
3521
|
+
"Patch options:",
|
|
3522
|
+
" --name <name> Update workflow name",
|
|
3523
|
+
" --description <desc> Update description",
|
|
3524
|
+
" --status <status> Set status. Valid:",
|
|
3525
|
+
" draft \u2014 editable, not run by suites",
|
|
3526
|
+
" published \u2014 active, included in regression runs",
|
|
3527
|
+
" (reliability is tracked automatically by health)",
|
|
3528
|
+
" --flow-type <type> Set type: standard, setup, seed, teardown",
|
|
3529
|
+
" --seed-outputs-json <json> Replace seedOutputsJson (array) \u2014 seed flows only",
|
|
3530
|
+
" --seed-outputs-file <path> Replace seedOutputsJson from a JSON file",
|
|
3531
|
+
" --tags <names> Replace tags with these names (comma-separated)",
|
|
3532
|
+
" --tags-add <names> Add these tag names to the existing set",
|
|
3533
|
+
" --tags-remove <names> Remove these tag names from the existing set",
|
|
3534
|
+
" --tag-id <ids> Raw tag IDs (comma-separated)",
|
|
3535
|
+
" --json Output raw JSON",
|
|
3536
|
+
"",
|
|
3537
|
+
"Publish options:",
|
|
3538
|
+
" --force Publish without requiring a successful run (skips the",
|
|
3539
|
+
" ready-to-publish gate; the new snapshot has no linked run)",
|
|
3540
|
+
" --json Output raw JSON",
|
|
3541
|
+
"",
|
|
3542
|
+
" Publish snapshots the current workflow definition and promotes it",
|
|
3543
|
+
" into the suite. Without --force, the workflow must have a passing",
|
|
3544
|
+
" run (ready_to_publish=true). Different from --status=published which",
|
|
3545
|
+
" only sets a column and does NOT create a new snapshot.",
|
|
3546
|
+
"",
|
|
3547
|
+
"Run options:",
|
|
3548
|
+
" --stream Follow execution until completion",
|
|
3549
|
+
" --json Output raw JSON",
|
|
3550
|
+
"",
|
|
3551
|
+
"API Sequence sub-commands:",
|
|
3552
|
+
" api-sequence get <wf> Show config + steps",
|
|
3553
|
+
" api-sequence update <wf> --from-file <f> Replace config",
|
|
3554
|
+
" api-sequence execute <wf> Run the sequence",
|
|
3555
|
+
" api-sequence delete <wf> Delete the sequence",
|
|
3556
|
+
" api-sequence set-api-only <wf> [--off] Toggle workflow apiOnly flag",
|
|
3557
|
+
" api-sequence generate <wf> [--prompt ...] AI-generate a config (streams)",
|
|
3558
|
+
"",
|
|
3559
|
+
' Run "canary workflow guide api-sequences" for step shape and template syntax.',
|
|
3560
|
+
"",
|
|
3561
|
+
"Seed sub-commands:",
|
|
3562
|
+
" seed create [options] Create a seed workflow with outputs",
|
|
3563
|
+
"",
|
|
3564
|
+
"Seed create options:",
|
|
3565
|
+
" --name <name> Seed workflow name (required unless JSON input)",
|
|
3566
|
+
" --description <desc> Description",
|
|
3567
|
+
" --output <key:name[:desc]> Declare an output (repeatable)",
|
|
3568
|
+
" --outputs-json <json> Full outputs array as inline JSON",
|
|
3569
|
+
" --from-stdin Read full config from stdin",
|
|
3570
|
+
" --from-file <path> Read full config from file",
|
|
3571
|
+
" --no-teardown Skip creating paired teardown workflow",
|
|
3572
|
+
" --status <status> Initial status (default: draft)",
|
|
3573
|
+
" --json Output raw JSON",
|
|
3574
|
+
"",
|
|
3575
|
+
"Node sub-commands:",
|
|
3576
|
+
" node patch <workflowId> <nodeId> [options] Update a node's config",
|
|
3577
|
+
"",
|
|
3578
|
+
"Node patch options:",
|
|
3579
|
+
" --set-input key=value Set an inputValue (repeatable)",
|
|
3580
|
+
` --config '{"..."}' Merge arbitrary configJson (partial update)`,
|
|
3581
|
+
" --json Output raw JSON",
|
|
3582
|
+
"",
|
|
3583
|
+
"Runs sub-commands:",
|
|
3584
|
+
" runs list <workflowId> List runs for a workflow",
|
|
3585
|
+
" runs get <workflowId> <runId> Get run detail with steps",
|
|
3586
|
+
" runs diagnostics <workflowId> <runId> Get failure diagnostic",
|
|
3587
|
+
"",
|
|
3588
|
+
"Cache sub-commands:",
|
|
3589
|
+
" cache status <workflowId> Show per-node cache version, health, recent failures",
|
|
3590
|
+
" cache clear <workflowId> [--node <id>] [--below-version <n>] [--stale-only] [--mode all]",
|
|
3591
|
+
" Targeted cache invalidation (rows kept, isValid=false)",
|
|
3592
|
+
" cache clear <workflowId> --mode hard --yes DESTRUCTIVE: delete cache rows, strip playbookSteps,",
|
|
3593
|
+
' null lastRunId. Same as the UI "Reset flow" button',
|
|
3594
|
+
" cache optimize <workflowId> Enqueue a shadow-cache-writer heal run",
|
|
3595
|
+
"",
|
|
3596
|
+
"Common options:",
|
|
3597
|
+
" --env <env> Target environment (prod, dev, local)",
|
|
3598
|
+
" --api-url <url> API URL override",
|
|
3599
|
+
" --token <key> API token override"
|
|
3600
|
+
].join("\n")
|
|
3601
|
+
);
|
|
3602
|
+
}
|
|
3603
|
+
async function runWorkflow(argv) {
|
|
3604
|
+
const [subCommand, ...rest] = argv;
|
|
3605
|
+
if (!subCommand || subCommand === "help") {
|
|
3606
|
+
printWorkflowHelp();
|
|
3607
|
+
return;
|
|
3608
|
+
}
|
|
3609
|
+
if (subCommand === "debug" && hasFlag(rest, "--help", "-h")) {
|
|
3610
|
+
const { handleWorkflowDebug } = await import("./workflow-debug-BGD4UIXU.js");
|
|
3611
|
+
await handleWorkflowDebug(rest, "", "");
|
|
3612
|
+
return;
|
|
3613
|
+
}
|
|
3614
|
+
if (hasFlag(argv, "--help", "-h")) {
|
|
3615
|
+
printWorkflowHelp();
|
|
3616
|
+
return;
|
|
3617
|
+
}
|
|
3618
|
+
if (subCommand === "guide") {
|
|
3619
|
+
const sectionSlug = rest.find((a) => !a.startsWith("-"));
|
|
3620
|
+
const jsonOutput = hasFlag(rest, "--json");
|
|
3621
|
+
printGuide(WORKFLOW_GUIDE_SECTIONS, sectionSlug, jsonOutput);
|
|
3622
|
+
return;
|
|
3623
|
+
}
|
|
3624
|
+
const { apiUrl, token } = await resolveConfig(argv);
|
|
3625
|
+
switch (subCommand) {
|
|
3626
|
+
case "list":
|
|
3627
|
+
await handleList(rest, apiUrl, token);
|
|
3628
|
+
break;
|
|
3629
|
+
case "get":
|
|
3630
|
+
await handleGet2(rest, apiUrl, token);
|
|
3631
|
+
break;
|
|
3632
|
+
case "create":
|
|
3633
|
+
await handleWorkflowCreate(rest, apiUrl, token);
|
|
3634
|
+
break;
|
|
3635
|
+
case "patch":
|
|
3636
|
+
await handlePatch(rest, apiUrl, token);
|
|
3637
|
+
break;
|
|
3638
|
+
case "publish":
|
|
3639
|
+
await handlePublish(rest, apiUrl, token);
|
|
3640
|
+
break;
|
|
3641
|
+
case "archive":
|
|
3642
|
+
await handleArchive(rest, apiUrl, token);
|
|
3643
|
+
break;
|
|
3644
|
+
case "unarchive":
|
|
3645
|
+
await handleUnarchive(rest, apiUrl, token);
|
|
3646
|
+
break;
|
|
3647
|
+
case "run":
|
|
3648
|
+
await handleWorkflowRun(rest, apiUrl, token);
|
|
3649
|
+
break;
|
|
3650
|
+
case "runs":
|
|
3651
|
+
await runWorkflowRuns(rest, apiUrl, token);
|
|
3652
|
+
break;
|
|
3653
|
+
case "node": {
|
|
3654
|
+
const [nodeSub, ...nodeRest] = rest;
|
|
3655
|
+
if (nodeSub === "patch") {
|
|
3656
|
+
await handleNodePatch(nodeRest, apiUrl, token);
|
|
3657
|
+
} else {
|
|
3658
|
+
console.error(`Unknown node sub-command: ${nodeSub ?? "(none)"}`);
|
|
3659
|
+
printWorkflowHelp();
|
|
3660
|
+
process9.exit(1);
|
|
3661
|
+
}
|
|
3662
|
+
break;
|
|
3663
|
+
}
|
|
3664
|
+
case "seed": {
|
|
3665
|
+
const [seedSub, ...seedRest] = rest;
|
|
3666
|
+
if (seedSub === "create") {
|
|
3667
|
+
await handleWorkflowSeedCreate(seedRest, apiUrl, token);
|
|
3668
|
+
} else {
|
|
3669
|
+
console.error(`Unknown seed sub-command: ${seedSub ?? "(none)"}`);
|
|
3670
|
+
printWorkflowHelp();
|
|
3671
|
+
process9.exit(1);
|
|
3672
|
+
}
|
|
3673
|
+
break;
|
|
3674
|
+
}
|
|
3675
|
+
case "sync":
|
|
3676
|
+
await handleWorkflowSync(rest, apiUrl, token);
|
|
3677
|
+
break;
|
|
3678
|
+
case "proposals": {
|
|
3679
|
+
const { handleWorkflowProposals } = await import("./workflow-proposals-MTUDX46O.js");
|
|
3680
|
+
await handleWorkflowProposals(rest, apiUrl, token);
|
|
3681
|
+
break;
|
|
3682
|
+
}
|
|
3683
|
+
case "generate-api-sequence":
|
|
3684
|
+
await handleWorkflowGenerateApiSequence(rest, apiUrl, token);
|
|
3685
|
+
break;
|
|
3686
|
+
case "api-sequence":
|
|
3687
|
+
await handleWorkflowApiSequence(rest, apiUrl, token);
|
|
3688
|
+
break;
|
|
3689
|
+
case "cache": {
|
|
3690
|
+
const { handleWorkflowCache } = await import("./workflow-cache-NQJQFWMV.js");
|
|
3691
|
+
await handleWorkflowCache(rest, apiUrl, token);
|
|
3692
|
+
break;
|
|
3693
|
+
}
|
|
3694
|
+
case "debug": {
|
|
3695
|
+
const { handleWorkflowDebug } = await import("./workflow-debug-BGD4UIXU.js");
|
|
3696
|
+
await handleWorkflowDebug(rest, apiUrl, token);
|
|
3697
|
+
break;
|
|
3698
|
+
}
|
|
3699
|
+
default:
|
|
3700
|
+
console.error(`Unknown sub-command: ${subCommand}`);
|
|
3701
|
+
printWorkflowHelp();
|
|
3702
|
+
process9.exit(1);
|
|
3703
|
+
}
|
|
3704
|
+
}
|
|
3705
|
+
export {
|
|
3706
|
+
runWorkflow
|
|
3707
|
+
};
|
|
3708
|
+
//# sourceMappingURL=workflow-XYFH5LTP.js.map
|