@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.
|
|
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",
|
|
@@ -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 = "
|
|
3
|
+
export const BRANDING_VERSION = "2.0.0";
|
|
4
4
|
|
|
5
5
|
export const BRANDING_CANONICAL = {
|
|
6
6
|
managed_kubernetes: {
|
|
7
|
-
long_form: "
|
|
8
|
-
short_form: "XCKS",
|
|
9
|
-
full_acronym: "XC Kubernetes Service",
|
|
7
|
+
long_form: "Managed Kubernetes",
|
|
10
8
|
description:
|
|
11
|
-
"Enterprise-grade Kubernetes cluster management
|
|
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
|
-
|
|
22
|
-
long_form: "
|
|
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
|
|
27
|
-
legacy_names: ["
|
|
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 =
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
64
|
+
definition: "F5 XC edge deployment infrastructure for distributed applications",
|
|
54
65
|
},
|
|
55
66
|
RE: {
|
|
56
67
|
term: "Regional Edge",
|
|
57
|
-
definition: "F5
|
|
68
|
+
definition: "F5 XC globally distributed edge network infrastructure",
|
|
58
69
|
},
|
|
59
70
|
} as const;
|
|
60
71
|
|
|
61
72
|
export const BRANDING_DOMAIN = {
|
|
62
|
-
|
|
63
|
-
title: "
|
|
73
|
+
virtual_kubernetes: {
|
|
74
|
+
title: "Virtual Kubernetes",
|
|
64
75
|
description:
|
|
65
|
-
'
|
|
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: "
|
|
79
|
+
title: "Managed Kubernetes",
|
|
69
80
|
description:
|
|
70
|
-
'
|
|
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),
|
|
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.
|
|
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,
|