* feat: add R language support Add R as a new supported scripting language in Windmill, following the same pattern used for Ruby. Includes: - Backend: ScriptLang::Rlang enum variant, DB migration, tree-sitter-r parser crate with tests, WASM parser binding, R executor with NSJail sandboxing, job dispatch and signature parsing - Frontend: language picker, R icon, syntax highlighting, editor bar insertions (Sys.getenv, get_variable, get_resource), schema inference, init code template, BETA badge - CLI: .r extension mapping, sync support, bootstrap template R scripts use `main <- function(...)` syntax, jsonlite for JSON serialization, and system curl for the Windmill client helper. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add R package resolution and installation Parse library()/require() calls from R scripts to extract dependencies. Resolve versions from CRAN, cache lockfiles in pip_resolution_cache, and install packages to a shared R library cache. The run step sets R_LIBS_USER so installed packages are available to the script. - Parser: parse_r_requirements() extracts package names from AST - Executor: resolve() generates lockfile, install() installs from CRAN - Worker lockfiles: wire up R resolve for dependency jobs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add nsjail sandboxing for R resolve and install phases Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: fix R get_variable/get_resource and add sandbox annotation + e2e tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: fix R arg inference with JS fallback parser and get_variable/get_resource Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix flake * nsjail * nits * fix: R install improvements - suppress verbose output, flat lockfile logging, Dockerfile R support, rlimits - Suppress renv verbose output during resolve and install (controlled by #verbose annotation) - Filter renv from install list (already loaded, causes noisy restart message) - Log compact "resolved N packages" instead of full renv.lock JSON - Add R (r-base, r-cran-renv) to DockerfileFull and DockerfileFullEe - Use disable_rl for nsjail install config (R compiles from source) - Reduce default concurrency from 20 to 5 - Add rlang to openflow.openapi.yaml - Fix MainArgSignature (no_main_func -> auto_kind) after main merge Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * final * fix: remove accidental R install from multiplayer Dockerfile Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: remove R from Windows build and DockerfileExtra Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: rename R migration to avoid timestamp collision with trigger_filter_logic Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * all * fix: R install improvements - suppress verbose output, flat lockfile logging, Dockerfile R support, rlimits Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: add clear error when Rscript binary is missing Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: fix type errors in R fallback parser, use format! in wrap(), add R system prompts Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: pyranota <pyra@duck.com>
502 lines
18 KiB
Python
502 lines
18 KiB
Python
"""
|
|
Utility functions and constants for system prompts generation.
|
|
|
|
This module contains:
|
|
- Path constants for SDK files and output directories
|
|
- Field exclusion lists for CLI format
|
|
- Language metadata for script generation
|
|
- String/file utility functions
|
|
- Parsing utilities for TypeScript/Python code
|
|
- Schema transformation utilities
|
|
"""
|
|
|
|
import json
|
|
import re
|
|
from pathlib import Path
|
|
|
|
# =============================================================================
|
|
# Path Constants
|
|
# =============================================================================
|
|
|
|
SCRIPT_DIR = Path(__file__).parent
|
|
ROOT_DIR = SCRIPT_DIR.parent
|
|
|
|
TS_SDK_DIR = ROOT_DIR / "typescript-client"
|
|
PY_SDK_PATH = ROOT_DIR / "python-client" / "wmill" / "wmill" / "client.py"
|
|
OPENFLOW_SCHEMA_PATH = ROOT_DIR / "openflow.openapi.yaml"
|
|
BACKEND_OPENAPI_PATH = ROOT_DIR / "backend" / "windmill-api" / "openapi.yaml"
|
|
|
|
OUTPUT_SDKS_DIR = SCRIPT_DIR / "auto-generated" / "sdks"
|
|
OUTPUT_GENERATED_DIR = SCRIPT_DIR / "auto-generated"
|
|
OUTPUT_CLI_DIR = SCRIPT_DIR / "auto-generated" / "cli"
|
|
OUTPUT_SKILLS_DIR = SCRIPT_DIR / "auto-generated" / "skills"
|
|
OUTPUT_SCHEMAS_DIR = SCRIPT_DIR / "auto-generated" / "schemas"
|
|
|
|
# =============================================================================
|
|
# Schema Mappings for Triggers and Schedules
|
|
# =============================================================================
|
|
|
|
# Maps skill names to their schema types and file patterns
|
|
# Used for generating standalone schema files and combining at CLI init time
|
|
SCHEMA_MAPPINGS = {
|
|
'triggers': [
|
|
('HttpTrigger', 'http_trigger'),
|
|
('WebsocketTrigger', 'websocket_trigger'),
|
|
('KafkaTrigger', 'kafka_trigger'),
|
|
('NatsTrigger', 'nats_trigger'),
|
|
('PostgresTrigger', 'postgres_trigger'),
|
|
('MqttTrigger', 'mqtt_trigger'),
|
|
('SqsTrigger', 'sqs_trigger'),
|
|
('GcpTrigger', 'gcp_trigger'),
|
|
],
|
|
'schedules': [
|
|
('Schedule', 'schedule'),
|
|
],
|
|
}
|
|
|
|
# CLI guidance directory (DNT can't import from outside cli/, so we copy files there)
|
|
CLI_GUIDANCE_DIR = ROOT_DIR / "cli" / "src" / "guidance"
|
|
|
|
# CLI source paths for extracting command documentation
|
|
CLI_DIR = ROOT_DIR / "cli"
|
|
CLI_MAIN = CLI_DIR / "src" / "main.ts"
|
|
CLI_COMMANDS_DIR = CLI_DIR / "src" / "commands"
|
|
|
|
# =============================================================================
|
|
# Field Exclusion Lists
|
|
# =============================================================================
|
|
|
|
# Fields stripped by CLI/sync format (to_string_without_metadata equivalent)
|
|
# These are server-managed fields that don't appear in YAML/JSON files pulled via CLI
|
|
CLI_EXCLUDED_FIELDS = [
|
|
'workspace_id', 'path', 'name', 'versions', 'id',
|
|
'created_at', 'updated_at', 'created_by', 'updated_by',
|
|
'edited_at', 'edited_by', 'archived', 'has_draft',
|
|
'error', 'last_server_ping', 'server_id',
|
|
'extra_perms', 'email', 'mode'
|
|
]
|
|
|
|
# =============================================================================
|
|
# Language Metadata
|
|
# =============================================================================
|
|
|
|
# Mapping of language file names to friendly names and descriptions
|
|
LANGUAGE_METADATA = {
|
|
'bun': {
|
|
'name': 'TypeScript (Bun)',
|
|
'description': 'MUST use when writing Bun/TypeScript scripts.',
|
|
'use_cases': 'TypeScript automation, npm packages, data processing, API integrations'
|
|
},
|
|
'deno': {
|
|
'name': 'TypeScript (Deno)',
|
|
'description': 'MUST use when writing Deno/TypeScript scripts.',
|
|
'use_cases': 'TypeScript with Deno stdlib, secure sandboxed execution'
|
|
},
|
|
'nativets': {
|
|
'name': 'Native TypeScript',
|
|
'description': 'MUST use when writing Native TypeScript scripts.',
|
|
'use_cases': 'simple API calls, lightweight TypeScript, no dependencies'
|
|
},
|
|
'bunnative': {
|
|
'name': 'Bun Native',
|
|
'description': 'MUST use when writing Bun Native scripts.',
|
|
'use_cases': 'simple Bun scripts, lightweight, no dependencies'
|
|
},
|
|
'python3': {
|
|
'name': 'Python',
|
|
'description': 'MUST use when writing Python scripts.',
|
|
'use_cases': 'Python automation, data processing, machine learning, scripting'
|
|
},
|
|
'bash': {
|
|
'name': 'Bash',
|
|
'description': 'MUST use when writing Bash scripts.',
|
|
'use_cases': 'shell scripts, system administration, CLI tools'
|
|
},
|
|
'go': {
|
|
'name': 'Go',
|
|
'description': 'MUST use when writing Go scripts.',
|
|
'use_cases': 'Go automation, high performance, concurrent processing'
|
|
},
|
|
'rust': {
|
|
'name': 'Rust',
|
|
'description': 'MUST use when writing Rust scripts.',
|
|
'use_cases': 'Rust automation, high performance, memory safety'
|
|
},
|
|
'postgresql': {
|
|
'name': 'PostgreSQL',
|
|
'description': 'MUST use when writing PostgreSQL queries.',
|
|
'use_cases': 'PostgreSQL database queries, data analysis'
|
|
},
|
|
'mysql': {
|
|
'name': 'MySQL',
|
|
'description': 'MUST use when writing MySQL queries.',
|
|
'use_cases': 'MySQL database queries, data operations'
|
|
},
|
|
'mssql': {
|
|
'name': 'MS SQL Server',
|
|
'description': 'MUST use when writing MS SQL Server queries.',
|
|
'use_cases': 'SQL Server database queries, enterprise data'
|
|
},
|
|
'bigquery': {
|
|
'name': 'BigQuery',
|
|
'description': 'MUST use when writing BigQuery queries.',
|
|
'use_cases': 'BigQuery analytics, large-scale data analysis'
|
|
},
|
|
'snowflake': {
|
|
'name': 'Snowflake',
|
|
'description': 'MUST use when writing Snowflake queries.',
|
|
'use_cases': 'Snowflake data warehouse queries, analytics'
|
|
},
|
|
'duckdb': {
|
|
'name': 'DuckDB',
|
|
'description': 'MUST use when writing DuckDB queries.',
|
|
'use_cases': 'DuckDB analytics, local data processing, Ducklake'
|
|
},
|
|
'graphql': {
|
|
'name': 'GraphQL',
|
|
'description': 'MUST use when writing GraphQL queries.',
|
|
'use_cases': 'GraphQL API calls, federated queries'
|
|
},
|
|
'php': {
|
|
'name': 'PHP',
|
|
'description': 'MUST use when writing PHP scripts.',
|
|
'use_cases': 'PHP automation, web integrations'
|
|
},
|
|
'powershell': {
|
|
'name': 'PowerShell',
|
|
'description': 'MUST use when writing PowerShell scripts.',
|
|
'use_cases': 'Windows automation, system administration'
|
|
},
|
|
'csharp': {
|
|
'name': 'C#',
|
|
'description': 'MUST use when writing C# scripts.',
|
|
'use_cases': 'C# automation, .NET integrations'
|
|
},
|
|
'java': {
|
|
'name': 'Java',
|
|
'description': 'MUST use when writing Java scripts.',
|
|
'use_cases': 'Java automation, enterprise integrations'
|
|
},
|
|
'rlang': {
|
|
'name': 'R',
|
|
'description': 'MUST use when writing R scripts.',
|
|
'use_cases': 'R statistical computing, data analysis, visualization'
|
|
},
|
|
}
|
|
|
|
# Languages that use TypeScript SDK
|
|
TS_SDK_LANGUAGES = ['bun', 'deno', 'nativets', 'bunnative']
|
|
|
|
# Languages that use Python SDK
|
|
PY_SDK_LANGUAGES = ['python3']
|
|
|
|
# =============================================================================
|
|
# String/File Utilities
|
|
# =============================================================================
|
|
|
|
|
|
def clean_jsdoc(jsdoc: str) -> str:
|
|
"""Clean up JSDoc comment, removing delimiters and leading asterisks."""
|
|
# Remove /** and */
|
|
jsdoc = re.sub(r'^/\*\*\s*', '', jsdoc)
|
|
jsdoc = re.sub(r'\s*\*/$', '', jsdoc)
|
|
# Remove leading * from each line
|
|
lines = jsdoc.split('\n')
|
|
cleaned = []
|
|
for line in lines:
|
|
line = re.sub(r'^\s*\*\s?', '', line)
|
|
cleaned.append(line)
|
|
return '\n'.join(cleaned).strip()
|
|
|
|
|
|
def clean_params(params: str) -> str:
|
|
"""Clean up parameter string."""
|
|
if not params:
|
|
return ''
|
|
# Remove excessive whitespace and newlines
|
|
params = re.sub(r'\s+', ' ', params).strip()
|
|
return params
|
|
|
|
|
|
def escape_for_ts(content: str) -> str:
|
|
"""Escape content for TypeScript template literal."""
|
|
return content.replace('\\', '\\\\').replace('`', '\\`').replace('${', '\\${')
|
|
|
|
|
|
def read_markdown_file(path: Path) -> str:
|
|
"""Read a markdown file and return its content."""
|
|
if path.exists():
|
|
return path.read_text()
|
|
return ''
|
|
|
|
|
|
# =============================================================================
|
|
# Parsing Utilities
|
|
# =============================================================================
|
|
|
|
|
|
def extract_balanced(content: str, start_pos: int, open_char: str, close_char: str) -> tuple[str, int]:
|
|
"""
|
|
Extract content between balanced brackets starting at start_pos.
|
|
Returns (extracted_content, end_position) or ('', -1) if not found.
|
|
"""
|
|
if start_pos >= len(content) or content[start_pos] != open_char:
|
|
return '', -1
|
|
|
|
depth = 0
|
|
i = start_pos
|
|
while i < len(content):
|
|
if content[i] == open_char:
|
|
depth += 1
|
|
elif content[i] == close_char:
|
|
depth -= 1
|
|
if depth == 0:
|
|
return content[start_pos + 1:i], i
|
|
i += 1
|
|
return '', -1
|
|
|
|
|
|
def extract_return_type(content: str, start_pos: int) -> tuple[str, int]:
|
|
"""
|
|
Extract return type from position after ')', handling nested braces.
|
|
Returns (return_type, end_position of function body open brace).
|
|
"""
|
|
i = start_pos
|
|
# Skip whitespace
|
|
while i < len(content) and content[i] in ' \t\n':
|
|
i += 1
|
|
|
|
if i >= len(content) or content[i] != ':':
|
|
# No return type, find opening brace
|
|
while i < len(content) and content[i] != '{':
|
|
i += 1
|
|
return '', i
|
|
|
|
i += 1 # Skip ':'
|
|
|
|
# Now extract the return type, handling nested braces and angle brackets
|
|
return_type_start = i
|
|
brace_depth = 0
|
|
angle_depth = 0
|
|
|
|
while i < len(content):
|
|
char = content[i]
|
|
if char == '<':
|
|
angle_depth += 1
|
|
elif char == '>':
|
|
angle_depth -= 1
|
|
elif char == '{':
|
|
if angle_depth > 0:
|
|
# Inside a type like Promise<{...}>
|
|
brace_depth += 1
|
|
else:
|
|
# This is the function body opening brace
|
|
return content[return_type_start:i].strip(), i
|
|
elif char == '}':
|
|
brace_depth -= 1
|
|
i += 1
|
|
|
|
return '', -1
|
|
|
|
|
|
def parse_default_imports(content: str) -> dict[str, str]:
|
|
"""
|
|
Parse default imports from TypeScript content.
|
|
Returns a dict mapping variable names to relative file paths.
|
|
E.g., 'import devCommand from "./dev.ts"' -> {'devCommand': './dev.ts'}
|
|
"""
|
|
imports = {}
|
|
# Match: import varName from "./path.ts" or import varName from './path.ts'
|
|
import_pattern = re.compile(
|
|
r'import\s+(\w+)\s+from\s+["\']([^"\']+)["\']',
|
|
re.MULTILINE
|
|
)
|
|
for match in import_pattern.finditer(content):
|
|
var_name, path = match.groups()
|
|
imports[var_name] = path
|
|
return imports
|
|
|
|
|
|
def extract_options(text: str, option_pattern: re.Pattern) -> list[dict]:
|
|
"""
|
|
Extract options from text using the given regex pattern.
|
|
|
|
The pattern should have 4 groups: (dq_flag, dq_desc, sq_flag, sq_desc)
|
|
for double-quoted and single-quoted variants.
|
|
|
|
Returns a list of dicts with 'flag' and 'description' keys.
|
|
"""
|
|
options = []
|
|
for match in option_pattern.finditer(text):
|
|
groups = match.groups()
|
|
# Pattern has 4 groups: (dq_flag, dq_desc, sq_flag, sq_desc)
|
|
# Either double-quoted or single-quoted pair will be non-None
|
|
flag = groups[0] or groups[2]
|
|
desc = groups[1] or groups[3]
|
|
if flag and desc:
|
|
options.append({'flag': flag, 'description': desc})
|
|
return options
|
|
|
|
|
|
# =============================================================================
|
|
# Schema Utilities
|
|
# =============================================================================
|
|
|
|
|
|
def extract_cli_schema(schema: dict, all_schemas: dict, openflow_schemas: dict | None = None) -> dict:
|
|
"""
|
|
Transform an OpenAPI schema to CLI format by removing server-managed fields.
|
|
Resolves $ref references and handles allOf compositions.
|
|
|
|
Args:
|
|
schema: The schema to transform
|
|
all_schemas: All schemas from the backend OpenAPI
|
|
openflow_schemas: Schemas from the openflow.openapi.yaml file (for external refs)
|
|
"""
|
|
if not schema:
|
|
return {}
|
|
|
|
openflow_schemas = openflow_schemas or {}
|
|
result = {'type': 'object', 'properties': {}, 'required': []}
|
|
|
|
# Handle allOf (used for composition, e.g., TriggerExtraProperty)
|
|
if 'allOf' in schema:
|
|
for item in schema['allOf']:
|
|
if '$ref' in item:
|
|
ref_name = item['$ref'].split('/')[-1]
|
|
if ref_name in all_schemas:
|
|
ref_schema = extract_cli_schema(all_schemas[ref_name], all_schemas, openflow_schemas)
|
|
result['properties'].update(ref_schema.get('properties', {}))
|
|
result['required'].extend(ref_schema.get('required', []))
|
|
|
|
# Handle direct properties
|
|
if 'properties' in schema:
|
|
for key, value in schema['properties'].items():
|
|
if key not in CLI_EXCLUDED_FIELDS:
|
|
# Resolve $ref in property values
|
|
if '$ref' in value:
|
|
ref_path = value['$ref']
|
|
# Handle local references
|
|
if ref_path.startswith('#/components/schemas/'):
|
|
ref_name = ref_path.split('/')[-1]
|
|
if ref_name in all_schemas:
|
|
result['properties'][key] = all_schemas[ref_name]
|
|
else:
|
|
result['properties'][key] = {'type': 'string', 'description': f'See {ref_name}'}
|
|
elif 'openflow.openapi.yaml' in ref_path:
|
|
# External reference to openflow schema - resolve it
|
|
ref_name = ref_path.split('/')[-1]
|
|
if ref_name in openflow_schemas:
|
|
result['properties'][key] = openflow_schemas[ref_name]
|
|
else:
|
|
result['properties'][key] = {'type': 'object', 'description': value.get('description', f'See {ref_name}')}
|
|
else:
|
|
# Other external reference
|
|
result['properties'][key] = {'type': 'object', 'description': value.get('description', f'See {ref_path}')}
|
|
else:
|
|
result['properties'][key] = value
|
|
|
|
# Handle required fields
|
|
if 'required' in schema:
|
|
result['required'].extend([
|
|
r for r in schema['required']
|
|
if r not in CLI_EXCLUDED_FIELDS
|
|
])
|
|
|
|
# Remove duplicates from required
|
|
result['required'] = list(dict.fromkeys(result['required']))
|
|
|
|
# Filter out required fields that don't exist in properties
|
|
result['required'] = [r for r in result['required'] if r in result['properties']]
|
|
|
|
return result
|
|
|
|
|
|
def format_schema_as_json(schema: dict) -> dict:
|
|
"""Convert a CLI schema to a clean JSON Schema representation."""
|
|
if not schema or not schema.get('properties'):
|
|
return {}
|
|
|
|
result = {
|
|
'type': 'object',
|
|
'properties': {},
|
|
}
|
|
|
|
props = schema.get('properties', {})
|
|
required = schema.get('required', [])
|
|
|
|
for key, value in props.items():
|
|
prop_def = {}
|
|
|
|
# Get type
|
|
prop_type = value.get('type', 'string')
|
|
if prop_type == 'array':
|
|
items = value.get('items', {})
|
|
prop_def['type'] = 'array'
|
|
item_type = items.get('type', 'object')
|
|
if item_type == 'object' and items.get('properties'):
|
|
prop_def['items'] = {'type': 'object', 'properties': items.get('properties', {})}
|
|
else:
|
|
prop_def['items'] = {'type': item_type}
|
|
elif '$ref' in value:
|
|
# For refs, just indicate the type
|
|
ref_name = value['$ref'].split('/')[-1]
|
|
prop_def['type'] = ref_name
|
|
elif prop_type == 'object' and value.get('properties'):
|
|
# Nested object with properties - include them
|
|
prop_def['type'] = 'object'
|
|
prop_def['properties'] = value.get('properties', {})
|
|
else:
|
|
prop_def['type'] = prop_type
|
|
|
|
# Add enum if present
|
|
if 'enum' in value:
|
|
prop_def['enum'] = value['enum']
|
|
|
|
# Add description if present
|
|
if value.get('description'):
|
|
prop_def['description'] = value['description']
|
|
|
|
result['properties'][key] = prop_def
|
|
|
|
if required:
|
|
result['required'] = required
|
|
|
|
return result
|
|
|
|
|
|
def format_schema_for_markdown(schema: dict, schema_name: str, as_json_schema: bool = False) -> str:
|
|
"""Format a CLI schema as markdown documentation."""
|
|
if not schema or not schema.get('properties'):
|
|
return ''
|
|
|
|
if as_json_schema:
|
|
# Output as JSON Schema
|
|
json_schema = format_schema_as_json(schema)
|
|
schema_json = json.dumps(json_schema, indent=2)
|
|
return f"## {schema_name}\n\nMust be a YAML file that adheres to the following schema:\n\n```json\n{schema_json}\n```"
|
|
else:
|
|
# Output as field list (for schedules)
|
|
lines = [f"### {schema_name} Schema (CLI Format)\n"]
|
|
lines.append("Fields available in `.yaml`/`.json` files:\n")
|
|
|
|
props = schema.get('properties', {})
|
|
required = set(schema.get('required', []))
|
|
|
|
for key, value in sorted(props.items()):
|
|
prop_type = value.get('type', 'any')
|
|
if prop_type == 'array':
|
|
items = value.get('items', {})
|
|
item_type = items.get('type', 'any')
|
|
prop_type = f"array[{item_type}]"
|
|
elif '$ref' in value:
|
|
prop_type = value['$ref'].split('/')[-1]
|
|
|
|
req_marker = ' (required)' if key in required else ''
|
|
desc = value.get('description', '')
|
|
desc_str = f" - {desc}" if desc else ''
|
|
|
|
lines.append(f"- `{key}`: {prop_type}{req_marker}{desc_str}")
|
|
|
|
return '\n'.join(lines)
|