@f5xc-salesdemos/xcsh 19.32.1 → 19.33.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/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5xc-salesdemos/xcsh",
|
|
4
|
-
"version": "19.
|
|
4
|
+
"version": "19.33.0",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://github.com/f5xc-salesdemos/xcsh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -50,13 +50,13 @@
|
|
|
50
50
|
"dependencies": {
|
|
51
51
|
"@agentclientprotocol/sdk": "0.16.1",
|
|
52
52
|
"@mozilla/readability": "^0.6",
|
|
53
|
-
"@f5xc-salesdemos/xcsh-stats": "19.
|
|
54
|
-
"@f5xc-salesdemos/pi-agent-core": "19.
|
|
55
|
-
"@f5xc-salesdemos/pi-ai": "19.
|
|
56
|
-
"@f5xc-salesdemos/pi-natives": "19.
|
|
57
|
-
"@f5xc-salesdemos/pi-resource-management": "19.
|
|
58
|
-
"@f5xc-salesdemos/pi-tui": "19.
|
|
59
|
-
"@f5xc-salesdemos/pi-utils": "19.
|
|
53
|
+
"@f5xc-salesdemos/xcsh-stats": "19.33.0",
|
|
54
|
+
"@f5xc-salesdemos/pi-agent-core": "19.33.0",
|
|
55
|
+
"@f5xc-salesdemos/pi-ai": "19.33.0",
|
|
56
|
+
"@f5xc-salesdemos/pi-natives": "19.33.0",
|
|
57
|
+
"@f5xc-salesdemos/pi-resource-management": "19.33.0",
|
|
58
|
+
"@f5xc-salesdemos/pi-tui": "19.33.0",
|
|
59
|
+
"@f5xc-salesdemos/pi-utils": "19.33.0",
|
|
60
60
|
"@sinclair/typebox": "^0.34",
|
|
61
61
|
"@xterm/headless": "^6.0",
|
|
62
62
|
"ajv": "^8.20",
|
|
@@ -17,17 +17,17 @@ export interface BuildInfo {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export const BUILD_INFO: BuildInfo = {
|
|
20
|
-
"version": "19.
|
|
21
|
-
"commit": "
|
|
22
|
-
"shortCommit": "
|
|
20
|
+
"version": "19.33.0",
|
|
21
|
+
"commit": "f29552ef521f334d775ed9700aea3e820e89cbe7",
|
|
22
|
+
"shortCommit": "f29552e",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v19.
|
|
25
|
-
"commitDate": "2026-06-
|
|
26
|
-
"buildDate": "2026-06-
|
|
24
|
+
"tag": "v19.33.0",
|
|
25
|
+
"commitDate": "2026-06-16T01:41:37Z",
|
|
26
|
+
"buildDate": "2026-06-16T02:10:01.349Z",
|
|
27
27
|
"dirty": true,
|
|
28
28
|
"prNumber": "",
|
|
29
29
|
"repoUrl": "https://github.com/f5xc-salesdemos/xcsh",
|
|
30
30
|
"repoSlug": "f5xc-salesdemos/xcsh",
|
|
31
|
-
"commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/
|
|
32
|
-
"releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v19.
|
|
31
|
+
"commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/f29552ef521f334d775ed9700aea3e820e89cbe7",
|
|
32
|
+
"releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v19.33.0"
|
|
33
33
|
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Runs a workflow from the F5 XC console catalog against a live browser session.
|
|
2
|
+
|
|
3
|
+
<instruction>
|
|
4
|
+
- Reads a YAML workflow definition from the catalog directory
|
|
5
|
+
- Executes browser automation steps sequentially (navigate, click, fill, assert, etc.)
|
|
6
|
+
- Resolves {param} placeholders from the provided params map
|
|
7
|
+
- Supports conditional steps, sub-steps, and observable mode with screenshots
|
|
8
|
+
- Use this tool when you need to automate F5 XC console operations defined in catalog workflows
|
|
9
|
+
</instruction>
|
|
10
|
+
|
|
11
|
+
<output>
|
|
12
|
+
Returns a per-step pass/fail report with timing and optional screenshot paths.
|
|
13
|
+
</output>
|
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { AgentTool, AgentToolResult } from "@f5xc-salesdemos/pi-agent-core";
|
|
4
|
+
import { logger, prompt, untilAborted } from "@f5xc-salesdemos/pi-utils";
|
|
5
|
+
import { type Static, Type } from "@sinclair/typebox";
|
|
6
|
+
import { parse as parseYaml } from "yaml";
|
|
7
|
+
import catalogWorkflowRunnerDescription from "../prompts/tools/catalog-workflow-runner.md" with { type: "text" };
|
|
8
|
+
import type { ToolSession } from ".";
|
|
9
|
+
import { BrowserTool } from "./browser";
|
|
10
|
+
import type { OutputMeta } from "./output-meta";
|
|
11
|
+
import { ToolError, throwIfAborted } from "./tool-errors";
|
|
12
|
+
import { toolResult } from "./tool-result";
|
|
13
|
+
|
|
14
|
+
// =============================================================================
|
|
15
|
+
// Schema
|
|
16
|
+
// =============================================================================
|
|
17
|
+
|
|
18
|
+
const catalogWorkflowRunnerSchema = Type.Object({
|
|
19
|
+
catalog_path: Type.String({ description: "Path to the console catalog root directory" }),
|
|
20
|
+
resource: Type.String({ description: 'Resource identifier, e.g. "http-load-balancer"' }),
|
|
21
|
+
operation: Type.String({ description: 'Workflow operation, e.g. "create", "delete", "view"' }),
|
|
22
|
+
params: Type.Optional(
|
|
23
|
+
Type.Record(Type.String(), Type.Unknown(), {
|
|
24
|
+
description: "Workflow parameters (name, namespace, etc.) for {placeholder} resolution",
|
|
25
|
+
}),
|
|
26
|
+
),
|
|
27
|
+
observable: Type.Optional(Type.Boolean({ description: "Enable slow execution with screenshots after each step" })),
|
|
28
|
+
observable_delay_ms: Type.Optional(
|
|
29
|
+
Type.Number({ description: "Delay between steps in observable mode (default 1500)" }),
|
|
30
|
+
),
|
|
31
|
+
screenshot_dir: Type.Optional(Type.String({ description: "Directory to save screenshots" })),
|
|
32
|
+
base_url: Type.Optional(Type.String({ description: "F5XC console base URL; falls back to F5XC_API_URL env var" })),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
type CatalogWorkflowRunnerParams = Static<typeof catalogWorkflowRunnerSchema>;
|
|
36
|
+
|
|
37
|
+
// =============================================================================
|
|
38
|
+
// Workflow YAML Types
|
|
39
|
+
// =============================================================================
|
|
40
|
+
|
|
41
|
+
interface WorkflowDefinition {
|
|
42
|
+
schema: string;
|
|
43
|
+
id: string;
|
|
44
|
+
name: string;
|
|
45
|
+
description?: string;
|
|
46
|
+
resource: string;
|
|
47
|
+
operation: string;
|
|
48
|
+
params?: WorkflowParamDef[];
|
|
49
|
+
steps: WorkflowStep[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface WorkflowParamDef {
|
|
53
|
+
name: string;
|
|
54
|
+
required?: boolean;
|
|
55
|
+
default?: unknown;
|
|
56
|
+
description?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface WorkflowStep {
|
|
60
|
+
id: string;
|
|
61
|
+
action: string;
|
|
62
|
+
description?: string;
|
|
63
|
+
url?: string;
|
|
64
|
+
selector?: string;
|
|
65
|
+
value?: string;
|
|
66
|
+
values?: string[];
|
|
67
|
+
key?: string;
|
|
68
|
+
expected_text?: string;
|
|
69
|
+
wait_for?: string;
|
|
70
|
+
condition?: string;
|
|
71
|
+
then?: WorkflowStep[];
|
|
72
|
+
timeout?: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// =============================================================================
|
|
76
|
+
// Details Types
|
|
77
|
+
// =============================================================================
|
|
78
|
+
|
|
79
|
+
export interface StepResult {
|
|
80
|
+
stepId: string;
|
|
81
|
+
action: string;
|
|
82
|
+
description?: string;
|
|
83
|
+
status: "pass" | "fail" | "skipped";
|
|
84
|
+
durationMs: number;
|
|
85
|
+
error?: string;
|
|
86
|
+
screenshotPath?: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface CatalogWorkflowRunnerDetails {
|
|
90
|
+
workflowId: string;
|
|
91
|
+
resource: string;
|
|
92
|
+
operation: string;
|
|
93
|
+
status: "pass" | "fail";
|
|
94
|
+
totalDurationMs: number;
|
|
95
|
+
steps: StepResult[];
|
|
96
|
+
failedAtStep?: string;
|
|
97
|
+
meta?: OutputMeta;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// =============================================================================
|
|
101
|
+
// Constants
|
|
102
|
+
// =============================================================================
|
|
103
|
+
|
|
104
|
+
const EXPECTED_SCHEMA = "urn:f5xc:console:workflow:v1";
|
|
105
|
+
const DEFAULT_OBSERVABLE_DELAY_MS = 1500;
|
|
106
|
+
const DEFAULT_STEP_TIMEOUT_S = 30;
|
|
107
|
+
|
|
108
|
+
// =============================================================================
|
|
109
|
+
// Helpers
|
|
110
|
+
// =============================================================================
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Resolve `{placeholder}` references in a string using the provided params map.
|
|
114
|
+
* Supports dotted paths: `{params.name}` resolves to the `name` key in params.
|
|
115
|
+
*/
|
|
116
|
+
function resolvePlaceholders(template: string, params: Record<string, unknown>): string {
|
|
117
|
+
return template.replace(/\{([^}]+)\}/g, (_match, key: string) => {
|
|
118
|
+
// Support "params.X" → look up X in params
|
|
119
|
+
const resolved = key.startsWith("params.") ? params[key.slice("params.".length)] : params[key];
|
|
120
|
+
return resolved !== undefined ? String(resolved) : `{${key}}`;
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Evaluate a simple condition string.
|
|
126
|
+
* Supports patterns:
|
|
127
|
+
* - "params.X is set" → truthy check
|
|
128
|
+
* - "params.X is not set" → falsy check
|
|
129
|
+
* - "params.X == value" → equality check
|
|
130
|
+
*/
|
|
131
|
+
function evaluateCondition(condition: string, params: Record<string, unknown>): boolean {
|
|
132
|
+
const isSetMatch = condition.match(/^params\.(\w+)\s+is\s+set$/);
|
|
133
|
+
if (isSetMatch) {
|
|
134
|
+
const key = isSetMatch[1]!;
|
|
135
|
+
return params[key] !== undefined && params[key] !== null && params[key] !== "";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const isNotSetMatch = condition.match(/^params\.(\w+)\s+is\s+not\s+set$/);
|
|
139
|
+
if (isNotSetMatch) {
|
|
140
|
+
const key = isNotSetMatch[1]!;
|
|
141
|
+
return params[key] === undefined || params[key] === null || params[key] === "";
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const eqMatch = condition.match(/^params\.(\w+)\s*==\s*(.+)$/);
|
|
145
|
+
if (eqMatch) {
|
|
146
|
+
const key = eqMatch[1]!;
|
|
147
|
+
const expected = eqMatch[2]!.trim();
|
|
148
|
+
return String(params[key] ?? "") === expected;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
logger.warn("catalog-workflow-runner: unrecognized condition, treating as false", { condition });
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Format duration in seconds with one decimal place.
|
|
157
|
+
*/
|
|
158
|
+
function formatDuration(ms: number): string {
|
|
159
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// =============================================================================
|
|
163
|
+
// Tool Class
|
|
164
|
+
// =============================================================================
|
|
165
|
+
|
|
166
|
+
export class CatalogWorkflowRunnerTool
|
|
167
|
+
implements AgentTool<typeof catalogWorkflowRunnerSchema, CatalogWorkflowRunnerDetails>
|
|
168
|
+
{
|
|
169
|
+
readonly name = "catalog_workflow_runner";
|
|
170
|
+
readonly label = "Catalog Workflow";
|
|
171
|
+
readonly description: string;
|
|
172
|
+
readonly parameters = catalogWorkflowRunnerSchema;
|
|
173
|
+
readonly strict = true;
|
|
174
|
+
|
|
175
|
+
readonly #session: ToolSession;
|
|
176
|
+
#browserTool: BrowserTool | null = null;
|
|
177
|
+
|
|
178
|
+
constructor(session: ToolSession) {
|
|
179
|
+
this.#session = session;
|
|
180
|
+
this.description = prompt.render(catalogWorkflowRunnerDescription);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// -------------------------------------------------------------------------
|
|
184
|
+
// Browser facade
|
|
185
|
+
// -------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
async #ensureBrowser(): Promise<BrowserTool> {
|
|
188
|
+
if (!this.#browserTool) {
|
|
189
|
+
this.#browserTool = new BrowserTool(this.#session);
|
|
190
|
+
}
|
|
191
|
+
return this.#browserTool;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Execute a single BrowserTool action, forwarding abort signals.
|
|
196
|
+
* Returns the text content from the result.
|
|
197
|
+
*/
|
|
198
|
+
async #browserAction(action: string, actionParams: Record<string, unknown>, signal?: AbortSignal): Promise<string> {
|
|
199
|
+
const browser = await this.#ensureBrowser();
|
|
200
|
+
const result = await browser.execute(`wf-${Date.now()}`, { action, ...actionParams } as any, signal);
|
|
201
|
+
const text = result.content?.find((c: { type: string }) => c.type === "text") as
|
|
202
|
+
| { type: "text"; text: string }
|
|
203
|
+
| undefined;
|
|
204
|
+
return text?.text ?? "";
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// -------------------------------------------------------------------------
|
|
208
|
+
// YAML loading & validation
|
|
209
|
+
// -------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
#loadWorkflow(workflowPath: string): WorkflowDefinition {
|
|
212
|
+
if (!fs.existsSync(workflowPath)) {
|
|
213
|
+
throw new ToolError(`Workflow file not found: ${workflowPath}`);
|
|
214
|
+
}
|
|
215
|
+
const raw = fs.readFileSync(workflowPath, "utf-8");
|
|
216
|
+
const workflow = parseYaml(raw) as WorkflowDefinition;
|
|
217
|
+
|
|
218
|
+
if (workflow.schema !== EXPECTED_SCHEMA) {
|
|
219
|
+
throw new ToolError(`Invalid workflow schema: expected "${EXPECTED_SCHEMA}", got "${workflow.schema}"`);
|
|
220
|
+
}
|
|
221
|
+
if (!workflow.steps || !Array.isArray(workflow.steps)) {
|
|
222
|
+
throw new ToolError("Workflow has no steps array");
|
|
223
|
+
}
|
|
224
|
+
return workflow;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
#validateParams(workflow: WorkflowDefinition, params: Record<string, unknown>): void {
|
|
228
|
+
if (!workflow.params) return;
|
|
229
|
+
const missing: string[] = [];
|
|
230
|
+
for (const paramDef of workflow.params) {
|
|
231
|
+
if (paramDef.required && !(paramDef.name in params)) {
|
|
232
|
+
if (paramDef.default !== undefined) continue;
|
|
233
|
+
missing.push(paramDef.name);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (missing.length > 0) {
|
|
237
|
+
throw new ToolError(`Missing required workflow params: ${missing.join(", ")}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// -------------------------------------------------------------------------
|
|
242
|
+
// Step execution
|
|
243
|
+
// -------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
async #executeStep(
|
|
246
|
+
step: WorkflowStep,
|
|
247
|
+
params: Record<string, unknown>,
|
|
248
|
+
baseUrl: string,
|
|
249
|
+
options: { observable: boolean; observableDelayMs: number; screenshotDir?: string; stepIndex: number },
|
|
250
|
+
signal?: AbortSignal,
|
|
251
|
+
): Promise<StepResult> {
|
|
252
|
+
const start = performance.now();
|
|
253
|
+
const result: StepResult = {
|
|
254
|
+
stepId: step.id,
|
|
255
|
+
action: step.action,
|
|
256
|
+
description: step.description,
|
|
257
|
+
status: "pass",
|
|
258
|
+
durationMs: 0,
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
throwIfAborted(signal);
|
|
263
|
+
|
|
264
|
+
// Condition gating
|
|
265
|
+
if (step.condition) {
|
|
266
|
+
const conditionMet = evaluateCondition(step.condition, params);
|
|
267
|
+
if (!conditionMet) {
|
|
268
|
+
result.status = "skipped";
|
|
269
|
+
result.durationMs = performance.now() - start;
|
|
270
|
+
return result;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Resolve placeholders in relevant fields
|
|
275
|
+
const resolvedUrl = step.url ? resolvePlaceholders(step.url, params) : undefined;
|
|
276
|
+
const resolvedSelector = step.selector ? resolvePlaceholders(step.selector, params) : undefined;
|
|
277
|
+
const resolvedValue = step.value ? resolvePlaceholders(step.value, params) : undefined;
|
|
278
|
+
const resolvedValues = step.values?.map(v => resolvePlaceholders(v, params));
|
|
279
|
+
const resolvedExpected = step.expected_text ? resolvePlaceholders(step.expected_text, params) : undefined;
|
|
280
|
+
const resolvedWaitFor = step.wait_for ? resolvePlaceholders(step.wait_for, params) : undefined;
|
|
281
|
+
|
|
282
|
+
const timeout = step.timeout ?? DEFAULT_STEP_TIMEOUT_S;
|
|
283
|
+
|
|
284
|
+
switch (step.action) {
|
|
285
|
+
case "navigate": {
|
|
286
|
+
if (!resolvedUrl) throw new ToolError(`Step "${step.id}": navigate requires url`);
|
|
287
|
+
const fullUrl = resolvedUrl.startsWith("http") ? resolvedUrl : `${baseUrl}${resolvedUrl}`;
|
|
288
|
+
await this.#browserAction("goto", { url: fullUrl, timeout }, signal);
|
|
289
|
+
if (resolvedWaitFor) {
|
|
290
|
+
await this.#browserAction("wait_for_selector", { selector: resolvedWaitFor, timeout }, signal);
|
|
291
|
+
}
|
|
292
|
+
break;
|
|
293
|
+
}
|
|
294
|
+
case "click": {
|
|
295
|
+
if (!resolvedSelector) throw new ToolError(`Step "${step.id}": click requires selector`);
|
|
296
|
+
await this.#browserAction("click", { selector: resolvedSelector, timeout }, signal);
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
case "fill": {
|
|
300
|
+
if (!resolvedSelector) throw new ToolError(`Step "${step.id}": fill requires selector`);
|
|
301
|
+
if (resolvedValue === undefined) throw new ToolError(`Step "${step.id}": fill requires value`);
|
|
302
|
+
await this.#browserAction("fill", { selector: resolvedSelector, value: resolvedValue, timeout }, signal);
|
|
303
|
+
break;
|
|
304
|
+
}
|
|
305
|
+
case "fill-list": {
|
|
306
|
+
if (!resolvedSelector) throw new ToolError(`Step "${step.id}": fill-list requires selector`);
|
|
307
|
+
if (!resolvedValues?.length) throw new ToolError(`Step "${step.id}": fill-list requires values`);
|
|
308
|
+
for (const val of resolvedValues) {
|
|
309
|
+
await this.#browserAction("fill", { selector: resolvedSelector, value: val, timeout }, signal);
|
|
310
|
+
await this.#browserAction("press", { key: "Enter" }, signal);
|
|
311
|
+
}
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
case "select": {
|
|
315
|
+
if (!resolvedSelector) throw new ToolError(`Step "${step.id}": select requires selector`);
|
|
316
|
+
// Open dropdown
|
|
317
|
+
await this.#browserAction("click", { selector: resolvedSelector, timeout }, signal);
|
|
318
|
+
// Click the option value
|
|
319
|
+
if (resolvedValue) {
|
|
320
|
+
const optionSelector = `text/${resolvedValue}`;
|
|
321
|
+
await this.#browserAction("click", { selector: optionSelector, timeout }, signal);
|
|
322
|
+
}
|
|
323
|
+
break;
|
|
324
|
+
}
|
|
325
|
+
case "assert": {
|
|
326
|
+
if (!resolvedSelector) throw new ToolError(`Step "${step.id}": assert requires selector`);
|
|
327
|
+
if (!resolvedExpected) throw new ToolError(`Step "${step.id}": assert requires expected_text`);
|
|
328
|
+
const text = await this.#browserAction("get_text", { selector: resolvedSelector, timeout }, signal);
|
|
329
|
+
if (!text.includes(resolvedExpected)) {
|
|
330
|
+
throw new ToolError(
|
|
331
|
+
`Assertion failed at step "${step.id}": expected text "${resolvedExpected}" not found in "${text.slice(0, 200)}"`,
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
break;
|
|
335
|
+
}
|
|
336
|
+
case "screenshot": {
|
|
337
|
+
const screenshotPath = options.screenshotDir
|
|
338
|
+
? path.join(options.screenshotDir, `${step.id}.png`)
|
|
339
|
+
: undefined;
|
|
340
|
+
const screenshotParams: Record<string, unknown> = {};
|
|
341
|
+
if (screenshotPath) screenshotParams.path = screenshotPath;
|
|
342
|
+
if (resolvedSelector) screenshotParams.selector = resolvedSelector;
|
|
343
|
+
await this.#browserAction("screenshot", screenshotParams, signal);
|
|
344
|
+
result.screenshotPath = screenshotPath;
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
case "key-press": {
|
|
348
|
+
if (!step.key) throw new ToolError(`Step "${step.id}": key-press requires key`);
|
|
349
|
+
await this.#browserAction(
|
|
350
|
+
"press",
|
|
351
|
+
{ key: step.key, ...(resolvedSelector ? { selector: resolvedSelector } : {}) },
|
|
352
|
+
signal,
|
|
353
|
+
);
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
case "wait": {
|
|
357
|
+
if (!resolvedSelector) throw new ToolError(`Step "${step.id}": wait requires selector`);
|
|
358
|
+
await this.#browserAction("wait_for_selector", { selector: resolvedSelector, timeout }, signal);
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
default:
|
|
362
|
+
throw new ToolError(`Unknown workflow action "${step.action}" at step "${step.id}"`);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Post-action wait_for
|
|
366
|
+
if (resolvedWaitFor && step.action !== "navigate") {
|
|
367
|
+
await this.#browserAction("wait_for_selector", { selector: resolvedWaitFor, timeout }, signal);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Execute sub-steps (then)
|
|
371
|
+
if (step.then) {
|
|
372
|
+
for (let i = 0; i < step.then.length; i++) {
|
|
373
|
+
const subStep = step.then[i]!;
|
|
374
|
+
const subResult = await this.#executeStep(subStep, params, baseUrl, options, signal);
|
|
375
|
+
if (subResult.status === "fail") {
|
|
376
|
+
result.status = "fail";
|
|
377
|
+
result.error = `Sub-step "${subStep.id}" failed: ${subResult.error}`;
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Observable mode: delay + screenshot
|
|
384
|
+
if (options.observable && result.status !== "fail") {
|
|
385
|
+
await new Promise(resolve => setTimeout(resolve, options.observableDelayMs));
|
|
386
|
+
if (options.screenshotDir) {
|
|
387
|
+
const obsPath = path.join(options.screenshotDir, `step-${options.stepIndex}-${step.id}.png`);
|
|
388
|
+
try {
|
|
389
|
+
await this.#browserAction("screenshot", { path: obsPath }, signal);
|
|
390
|
+
result.screenshotPath = obsPath;
|
|
391
|
+
} catch (e) {
|
|
392
|
+
logger.warn("catalog-workflow-runner: observable screenshot failed", {
|
|
393
|
+
step: step.id,
|
|
394
|
+
error: e instanceof Error ? e.message : String(e),
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
} catch (e) {
|
|
400
|
+
result.status = "fail";
|
|
401
|
+
result.error = e instanceof Error ? e.message : String(e);
|
|
402
|
+
|
|
403
|
+
// Capture failure screenshot
|
|
404
|
+
if (options.screenshotDir) {
|
|
405
|
+
const failPath = path.join(options.screenshotDir, `fail-${step.id}.png`);
|
|
406
|
+
try {
|
|
407
|
+
await this.#browserAction("screenshot", { path: failPath });
|
|
408
|
+
result.screenshotPath = failPath;
|
|
409
|
+
} catch {
|
|
410
|
+
// Best-effort screenshot on failure
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
result.durationMs = performance.now() - start;
|
|
416
|
+
return result;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// -------------------------------------------------------------------------
|
|
420
|
+
// Main execute
|
|
421
|
+
// -------------------------------------------------------------------------
|
|
422
|
+
|
|
423
|
+
async execute(
|
|
424
|
+
_toolCallId: string,
|
|
425
|
+
inputParams: CatalogWorkflowRunnerParams,
|
|
426
|
+
signal?: AbortSignal,
|
|
427
|
+
): Promise<AgentToolResult<CatalogWorkflowRunnerDetails>> {
|
|
428
|
+
return untilAborted(signal, async () => {
|
|
429
|
+
const totalStart = performance.now();
|
|
430
|
+
|
|
431
|
+
// Construct workflow path
|
|
432
|
+
const workflowPath = path.join(
|
|
433
|
+
inputParams.catalog_path,
|
|
434
|
+
"catalog",
|
|
435
|
+
"workflows",
|
|
436
|
+
inputParams.resource,
|
|
437
|
+
`${inputParams.operation}.yaml`,
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
// Load & validate
|
|
441
|
+
const workflow = this.#loadWorkflow(workflowPath);
|
|
442
|
+
const params: Record<string, unknown> = { ...inputParams.params };
|
|
443
|
+
|
|
444
|
+
// Apply defaults from workflow param definitions
|
|
445
|
+
if (workflow.params) {
|
|
446
|
+
for (const paramDef of workflow.params) {
|
|
447
|
+
if (!(paramDef.name in params) && paramDef.default !== undefined) {
|
|
448
|
+
params[paramDef.name] = paramDef.default;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
this.#validateParams(workflow, params);
|
|
454
|
+
|
|
455
|
+
// Resolve base URL from params or environment
|
|
456
|
+
const baseUrl = inputParams.base_url ?? process.env.F5XC_API_URL ?? "";
|
|
457
|
+
if (!baseUrl) {
|
|
458
|
+
throw new ToolError("No base_url provided and F5XC_API_URL env var is not set");
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Options
|
|
462
|
+
const observable = inputParams.observable ?? false;
|
|
463
|
+
const observableDelayMs = inputParams.observable_delay_ms ?? DEFAULT_OBSERVABLE_DELAY_MS;
|
|
464
|
+
const screenshotDir = inputParams.screenshot_dir;
|
|
465
|
+
|
|
466
|
+
// Ensure screenshot directory exists
|
|
467
|
+
if (screenshotDir && !fs.existsSync(screenshotDir)) {
|
|
468
|
+
fs.mkdirSync(screenshotDir, { recursive: true });
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Open browser
|
|
472
|
+
await this.#browserAction("open", {}, signal);
|
|
473
|
+
|
|
474
|
+
// Execute steps
|
|
475
|
+
const stepResults: StepResult[] = [];
|
|
476
|
+
let failedAtStep: string | undefined;
|
|
477
|
+
|
|
478
|
+
for (let i = 0; i < workflow.steps.length; i++) {
|
|
479
|
+
throwIfAborted(signal);
|
|
480
|
+
const step = workflow.steps[i]!;
|
|
481
|
+
const stepResult = await this.#executeStep(
|
|
482
|
+
step,
|
|
483
|
+
params,
|
|
484
|
+
baseUrl,
|
|
485
|
+
{ observable, observableDelayMs, screenshotDir, stepIndex: i },
|
|
486
|
+
signal,
|
|
487
|
+
);
|
|
488
|
+
stepResults.push(stepResult);
|
|
489
|
+
|
|
490
|
+
if (stepResult.status === "fail") {
|
|
491
|
+
failedAtStep = step.id;
|
|
492
|
+
break;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const totalDurationMs = performance.now() - totalStart;
|
|
497
|
+
const overallStatus = failedAtStep ? "fail" : "pass";
|
|
498
|
+
const workflowId = `${inputParams.resource}-${inputParams.operation}`;
|
|
499
|
+
|
|
500
|
+
const details: CatalogWorkflowRunnerDetails = {
|
|
501
|
+
workflowId,
|
|
502
|
+
resource: inputParams.resource,
|
|
503
|
+
operation: inputParams.operation,
|
|
504
|
+
status: overallStatus,
|
|
505
|
+
totalDurationMs,
|
|
506
|
+
steps: stepResults,
|
|
507
|
+
failedAtStep,
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
// Format text report
|
|
511
|
+
const statusLabel = overallStatus === "pass" ? "PASS" : "FAIL";
|
|
512
|
+
const header = [
|
|
513
|
+
`Workflow: ${workflow.name} (${workflowId})`,
|
|
514
|
+
`Status: ${statusLabel}`,
|
|
515
|
+
`Duration: ${formatDuration(totalDurationMs)}`,
|
|
516
|
+
"",
|
|
517
|
+
"Steps:",
|
|
518
|
+
];
|
|
519
|
+
|
|
520
|
+
const stepLines = stepResults.map(sr => {
|
|
521
|
+
const tag = sr.status === "pass" ? "PASS" : sr.status === "fail" ? "FAIL" : "SKIPPED";
|
|
522
|
+
const desc = sr.description ?? sr.error ?? "";
|
|
523
|
+
const dur = formatDuration(sr.durationMs);
|
|
524
|
+
return ` [${tag.padEnd(7)}] ${sr.stepId.padEnd(24)} ${desc.padEnd(40).slice(0, 40)} (${dur})`;
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
const text = [...header, ...stepLines].join("\n");
|
|
528
|
+
|
|
529
|
+
return toolResult(details).text(text).done();
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
}
|
package/src/tools/index.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { BashTool } from "./bash";
|
|
|
25
25
|
import { BrowserTool } from "./browser";
|
|
26
26
|
import { CalculatorTool } from "./calculator";
|
|
27
27
|
import { CancelJobTool } from "./cancel-job";
|
|
28
|
+
import { CatalogWorkflowRunnerTool } from "./catalog-workflow-runner";
|
|
28
29
|
import { type CheckpointState, CheckpointTool, RewindTool } from "./checkpoint";
|
|
29
30
|
import { DebugTool } from "./debug";
|
|
30
31
|
import { DisplayImageTool } from "./display-image";
|
|
@@ -64,6 +65,7 @@ export * from "./bash";
|
|
|
64
65
|
export * from "./browser";
|
|
65
66
|
export * from "./calculator";
|
|
66
67
|
export * from "./cancel-job";
|
|
68
|
+
export * from "./catalog-workflow-runner";
|
|
67
69
|
export * from "./checkpoint";
|
|
68
70
|
export * from "./debug";
|
|
69
71
|
export * from "./display-image";
|
|
@@ -218,6 +220,7 @@ export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
|
|
|
218
220
|
display_image: s => new DisplayImageTool(s),
|
|
219
221
|
inspect_image: s => new InspectImageTool(s),
|
|
220
222
|
browser: s => new BrowserTool(s),
|
|
223
|
+
catalog_workflow_runner: s => new CatalogWorkflowRunnerTool(s),
|
|
221
224
|
checkpoint: CheckpointTool.createIf,
|
|
222
225
|
rewind: RewindTool.createIf,
|
|
223
226
|
task: TaskTool.create,
|