@f5xc-salesdemos/xcsh 19.32.0 → 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.32.0",
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.32.0",
54
- "@f5xc-salesdemos/pi-agent-core": "19.32.0",
55
- "@f5xc-salesdemos/pi-ai": "19.32.0",
56
- "@f5xc-salesdemos/pi-natives": "19.32.0",
57
- "@f5xc-salesdemos/pi-resource-management": "19.32.0",
58
- "@f5xc-salesdemos/pi-tui": "19.32.0",
59
- "@f5xc-salesdemos/pi-utils": "19.32.0",
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",
@@ -1,77 +1,88 @@
1
1
  // AUTO-GENERATED — do not edit. Run `bun generate-branding-index` to regenerate.
2
2
 
3
- export const BRANDING_VERSION = "1.0.0";
3
+ export const BRANDING_VERSION = "2.0.0";
4
4
 
5
5
  export const BRANDING_CANONICAL = {
6
6
  managed_kubernetes: {
7
- long_form: "F5 XC Managed Kubernetes",
8
- short_form: "XCKS",
9
- full_acronym: "XC Kubernetes Service",
7
+ long_form: "Managed Kubernetes",
10
8
  description:
11
- "Enterprise-grade Kubernetes cluster management comparable to AWS EKS, Azure AKS, and Google GKE. Full cluster control with RBAC, pod security, and container registry management.\n",
9
+ "Enterprise-grade Kubernetes cluster management. Full cluster control with RBAC, pod security, and container registry management.\n",
12
10
  legacy_names: ["AppStack", "VoltStack", "voltstack_site"],
13
11
  comparable_to: ["AWS EKS", "Azure AKS", "Google GKE"],
14
- use_cases: [
15
- "Deploy and manage Kubernetes clusters on-premises or in cloud",
16
- "Configure RBAC roles and cluster security policies",
17
- "Manage container registries and pod security admission",
18
- "Integrate with existing enterprise Kubernetes infrastructure",
19
- ],
20
12
  },
21
- container_services: {
22
- long_form: "F5 XC Container Services",
23
- short_form: "XCCS",
24
- full_acronym: "XC Container Services",
13
+ virtual_kubernetes: {
14
+ long_form: "Virtual Kubernetes",
25
15
  description:
26
- "Simplified, multi-tenant container orchestration comparable to AWS ECS and Azure Container Services. Optimized for distributed edge deployments with restricted Kubernetes capabilities (no operators, CRDs, privileged mode).\n",
27
- legacy_names: ["Virtual Kubernetes", "vK8s", "virtual_k8s"],
16
+ "Simplified, multi-tenant container orchestration. Optimized for distributed edge deployments with restricted Kubernetes capabilities.\n",
17
+ legacy_names: ["vK8s", "virtual_k8s"],
28
18
  comparable_to: ["AWS ECS", "Azure Container Services", "Cloud Run"],
29
- use_cases: [
30
- "Deploy container workloads across distributed edge sites",
31
- "Run multi-tenant containerized applications",
32
- "Simplified container orchestration without K8s complexity",
33
- "Edge-optimized container deployments",
34
- ],
35
19
  },
36
20
  } as const;
37
21
 
38
- export const BRANDING_DEPRECATIONS = null;
39
-
40
- export const BRANDING_GLOSSARY = {
41
- XCKS: {
42
- term: "XC Kubernetes Service",
43
- definition: "F5's enterprise managed Kubernetes offering (comparable to AWS EKS, Azure AKS)",
44
- legacy: "Formerly known as AppStack",
22
+ export const BRANDING_DEPRECATIONS = {
23
+ terraform_provider: {
24
+ deprecated: {
25
+ registry: "registry.terraform.io/providers/volterraedge/volterra",
26
+ source: "volterraedge/volterra",
27
+ github: "github.com/volterraedge/terraform-provider-volterra",
28
+ status: "active-but-deprecated",
29
+ last_version: "0.11.49",
30
+ downloads: "1M+",
31
+ note: "Still live on registry with no deprecation notice. High risk of AI model recommendation due to training data prevalence.\n",
32
+ },
33
+ canonical: {
34
+ registry: "registry.terraform.io/providers/f5xc-salesdemos/f5xc",
35
+ source: "f5xc-salesdemos/f5xc",
36
+ github: "github.com/f5xc-salesdemos/terraform-provider-f5xc",
37
+ docs: "https://f5xc-salesdemos.github.io/terraform-provider-f5xc/",
38
+ llms_txt: "https://f5xc-salesdemos.github.io/terraform-provider-f5xc/llms.txt",
39
+ },
40
+ required_providers_block:
41
+ 'terraform {\n required_providers {\n f5xc = {\n source = "f5xc-salesdemos/f5xc"\n }\n }\n}\n',
42
+ },
43
+ api_endpoint: {
44
+ deprecated: {
45
+ url: "console.ves.volterra.io",
46
+ },
47
+ canonical: {
48
+ note: "Tenant-specific. No hardcoded default. Require F5XC_API_URL env var.",
49
+ },
45
50
  },
46
- XCCS: {
47
- term: "XC Container Services",
48
- definition: "F5's multi-tenant container orchestration service (comparable to AWS ECS)",
49
- legacy: "Formerly known as Virtual Kubernetes (vK8s)",
51
+ documentation: {
52
+ deprecated: {
53
+ note: "docs.cloud.f5.com references to Volterra provider point to the deprecated volterraedge/volterra registry.\n",
54
+ },
55
+ canonical: {
56
+ url: "https://f5xc-salesdemos.github.io/terraform-provider-f5xc/",
57
+ },
50
58
  },
59
+ } as const;
60
+
61
+ export const BRANDING_GLOSSARY = {
51
62
  CE: {
52
63
  term: "Customer Edge",
53
- definition: "F5's edge deployment infrastructure for distributed applications",
64
+ definition: "F5 XC edge deployment infrastructure for distributed applications",
54
65
  },
55
66
  RE: {
56
67
  term: "Regional Edge",
57
- definition: "F5's globally distributed edge network infrastructure",
68
+ definition: "F5 XC globally distributed edge network infrastructure",
58
69
  },
59
70
  } as const;
60
71
 
61
72
  export const BRANDING_DOMAIN = {
62
- container_services: {
63
- title: "XCCS - XC Container Services",
73
+ virtual_kubernetes: {
74
+ title: "Virtual Kubernetes",
64
75
  description:
65
- 'F5 XC Container Services (XCCS) provides simplified, multi-tenant container orchestration comparable to AWS ECS and Azure Container Services. Optimized for distributed edge deployments with restricted Kubernetes capabilities. Formerly known as "Virtual Kubernetes" (vK8s).\n',
76
+ 'Virtual Kubernetes provides simplified, multi-tenant container orchestration optimized for distributed edge deployments. Formerly known as "vK8s".\n',
66
77
  },
67
78
  managed_kubernetes: {
68
- title: "XCKS - XC Kubernetes Service",
79
+ title: "Managed Kubernetes",
69
80
  description:
70
- 'F5 XC Managed Kubernetes (XCKS) provides enterprise-grade Kubernetes cluster management comparable to AWS EKS, Azure AKS, and Google GKE. Full cluster control with RBAC, pod security, and container registry management. Formerly known as "AppStack".\n',
81
+ 'Managed Kubernetes provides enterprise-grade cluster management with full RBAC, pod security, and container registry support. Formerly known as "AppStack".\n',
71
82
  },
72
83
  sites: {
73
84
  title: "Customer Edge Sites",
74
85
  description:
75
- "Site deployment and management across cloud providers (AWS VPC, Azure VNET, GCP VPC), F5 XC Managed Kubernetes (XCKS, formerly AppStack) deployments, and Secure Mesh deployments for networking-focused edge sites.\n",
86
+ "Site deployment and management across cloud providers (AWS VPC, Azure VNET, GCP VPC), Managed Kubernetes deployments (formerly AppStack), and Secure Mesh deployments for networking-focused edge sites.\n",
76
87
  },
77
88
  } as const;
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "19.32.0",
21
- "commit": "b58c4c478fdfe8e2fc87f692408fd75a458cae2e",
22
- "shortCommit": "b58c4c4",
20
+ "version": "19.33.0",
21
+ "commit": "f29552ef521f334d775ed9700aea3e820e89cbe7",
22
+ "shortCommit": "f29552e",
23
23
  "branch": "main",
24
- "tag": "v19.32.0",
25
- "commitDate": "2026-06-15T22:36:02Z",
26
- "buildDate": "2026-06-15T23:19:37.250Z",
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/b58c4c478fdfe8e2fc87f692408fd75a458cae2e",
32
- "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v19.32.0"
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
+ }
@@ -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,