Auto-sync: 2026-03-13 06:30:01
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# py: 3.12
|
||||
@@ -0,0 +1,219 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
|
||||
def main(
|
||||
task_contract: dict[str, Any],
|
||||
steps: list[dict[str, Any]],
|
||||
context: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Standalone Windmill runner flow for Butler delegated step execution.
|
||||
|
||||
This file is intentionally self-contained so it can be pasted or synced to
|
||||
Windmill without requiring the Butler repository on the worker.
|
||||
"""
|
||||
timeout_sec = _resolve_timeout_sec(task_contract)
|
||||
resolved_context = dict(context or {})
|
||||
results: list[dict[str, Any]] = []
|
||||
|
||||
for idx, raw_step in enumerate(steps):
|
||||
result = _execute_step(raw_step, idx, timeout_sec, resolved_context)
|
||||
|
||||
if raw_step.get("kind") in {"cmd", "check"} and result["exit_code"] != 0:
|
||||
err_type = _classify_error(str(raw_step.get("value", "")), result["stderr"] or result["stdout"])
|
||||
if err_type == "transient":
|
||||
time.sleep(30)
|
||||
result = _execute_step(raw_step, idx, timeout_sec, resolved_context)
|
||||
|
||||
results.append(result)
|
||||
|
||||
if result["exit_code"] != 0:
|
||||
evidence = _build_evidence(results)
|
||||
evidence["ok"] = False
|
||||
return {
|
||||
"ok": False,
|
||||
"summary": _failure_summary(raw_step, result),
|
||||
"failed_step_index": idx,
|
||||
"step_results": results,
|
||||
"evidence": evidence,
|
||||
}
|
||||
|
||||
evidence = _build_evidence(results)
|
||||
evidence["ok"] = True
|
||||
return {
|
||||
"ok": True,
|
||||
"summary": f"Executed {len(results)} step(s) successfully.",
|
||||
"step_results": results,
|
||||
"evidence": evidence,
|
||||
}
|
||||
|
||||
|
||||
def _resolve_timeout_sec(task_contract: dict[str, Any]) -> int:
|
||||
constraints = task_contract.get("constraints", {})
|
||||
max_minutes = constraints.get("max_minutes", 1)
|
||||
try:
|
||||
return max(1, int(max_minutes) * 60)
|
||||
except (TypeError, ValueError):
|
||||
return 60
|
||||
|
||||
|
||||
def _execute_step(
|
||||
step: dict[str, Any],
|
||||
step_index: int,
|
||||
timeout_sec: int,
|
||||
context: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
kind = str(step.get("kind", "")).strip()
|
||||
value = str(step.get("value", "") or "")
|
||||
|
||||
if kind == "wait":
|
||||
started = time.perf_counter()
|
||||
seconds = _parse_wait_seconds(value)
|
||||
time.sleep(seconds)
|
||||
duration_ms = int((time.perf_counter() - started) * 1000)
|
||||
return _step_result(step_index, kind, value, 0, "", "", duration_ms)
|
||||
|
||||
if kind == "mcp_call":
|
||||
return _execute_mcp_call(step, step_index, timeout_sec, context)
|
||||
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
value,
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
timeout=timeout_sec,
|
||||
text=True,
|
||||
)
|
||||
duration_ms = int((time.perf_counter() - started) * 1000)
|
||||
return _step_result(
|
||||
step_index,
|
||||
kind,
|
||||
value,
|
||||
proc.returncode,
|
||||
proc.stdout,
|
||||
proc.stderr,
|
||||
duration_ms,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
duration_ms = int((time.perf_counter() - started) * 1000)
|
||||
stdout = exc.stdout if isinstance(exc.stdout, str) else ""
|
||||
return _step_result(
|
||||
step_index,
|
||||
kind,
|
||||
value,
|
||||
124,
|
||||
stdout,
|
||||
f"timeout after {timeout_sec}s",
|
||||
duration_ms,
|
||||
)
|
||||
|
||||
|
||||
def _execute_mcp_call(
|
||||
step: dict[str, Any],
|
||||
step_index: int,
|
||||
timeout_sec: int,
|
||||
context: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Placeholder for future Windmill-side MCP execution.
|
||||
|
||||
The first real connectivity test uses `check` steps, so we keep the
|
||||
deployment artifact dependency-free for now and fail explicitly if a flow
|
||||
attempts `mcp_call`.
|
||||
"""
|
||||
_ = timeout_sec, context
|
||||
server = str(step.get("server", "") or "").strip()
|
||||
tool = str(step.get("tool", "") or "").strip()
|
||||
return _step_result(
|
||||
step_index,
|
||||
"mcp_call",
|
||||
tool,
|
||||
1,
|
||||
"",
|
||||
f"mcp_call is not supported in the standalone Windmill runner yet (server={server}, tool={tool})",
|
||||
0,
|
||||
)
|
||||
|
||||
|
||||
def _step_result(
|
||||
step_index: int,
|
||||
kind: str,
|
||||
value: str,
|
||||
exit_code: int,
|
||||
stdout: str,
|
||||
stderr: str,
|
||||
duration_ms: int,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"step_index": step_index,
|
||||
"kind": kind,
|
||||
"value": value,
|
||||
"exit_code": exit_code,
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
"duration_ms": duration_ms,
|
||||
}
|
||||
|
||||
|
||||
def _build_evidence(results: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
executed_commands = [str(result.get("value", "")) for result in results]
|
||||
key_outputs: list[str] = []
|
||||
error_lines: list[str] = []
|
||||
|
||||
for result in results:
|
||||
stdout = str(result.get("stdout", "") or "")
|
||||
stderr = str(result.get("stderr", "") or "")
|
||||
if stdout:
|
||||
key_outputs.extend(stdout.splitlines()[:5])
|
||||
if stderr:
|
||||
lines = stderr.splitlines()
|
||||
error_lines.extend(lines[:5])
|
||||
if len(lines) > 5:
|
||||
error_lines.extend(lines[-5:])
|
||||
|
||||
return {
|
||||
"executed_commands": executed_commands,
|
||||
"key_outputs": key_outputs,
|
||||
"error_head_tail": "\n".join(error_lines) if error_lines else None,
|
||||
}
|
||||
|
||||
|
||||
def _failure_summary(step: dict[str, Any], result: dict[str, Any]) -> str:
|
||||
kind = str(step.get("kind", "") or "")
|
||||
stderr = str(result.get("stderr", "") or "")
|
||||
stdout = str(result.get("stdout", "") or "")
|
||||
if kind == "mcp_call":
|
||||
return stderr or stdout or "mcp_call failed."
|
||||
return stderr or stdout or f"{kind} step failed."
|
||||
|
||||
|
||||
def _classify_error(command: str, output: str) -> str:
|
||||
lowered = (command + "\n" + output).lower()
|
||||
transient_markers = [
|
||||
"timeout",
|
||||
"timed out",
|
||||
"temporarily unavailable",
|
||||
"connection reset",
|
||||
"connection aborted",
|
||||
"connection refused",
|
||||
"503",
|
||||
"502",
|
||||
"rate limit",
|
||||
]
|
||||
for marker in transient_markers:
|
||||
if marker in lowered:
|
||||
return "transient"
|
||||
return "permanent"
|
||||
|
||||
|
||||
def _parse_wait_seconds(value: str) -> float:
|
||||
normalized = value.strip().lower()
|
||||
if re.fullmatch(r"\d+(\.\d+)?s", normalized):
|
||||
return float(normalized[:-1])
|
||||
if re.fullmatch(r"\d+(\.\d+)?", normalized):
|
||||
return float(normalized)
|
||||
raise ValueError(f"Invalid wait value: {value}")
|
||||
47
workflows/f/butler/execute_task_steps__flow/flow.yaml
Normal file
47
workflows/f/butler/execute_task_steps__flow/flow.yaml
Normal file
@@ -0,0 +1,47 @@
|
||||
summary: Butler generic runner - delegated step execution
|
||||
description: >-
|
||||
Receives a serialized TaskContract and resolved step list from Butler,
|
||||
executes steps server-side with Butler-compatible semantics
|
||||
(cmd/check/wait/retry), and returns ok/summary/step_results/evidence.
|
||||
value:
|
||||
modules:
|
||||
- id: a
|
||||
summary: Execute Butler task steps
|
||||
value:
|
||||
type: rawscript
|
||||
content: '!inline execute_butler_task_steps.py'
|
||||
input_transforms:
|
||||
context:
|
||||
type: javascript
|
||||
expr: flow_input.context
|
||||
steps:
|
||||
type: javascript
|
||||
expr: flow_input.steps
|
||||
task_contract:
|
||||
type: javascript
|
||||
expr: flow_input.task_contract
|
||||
lock: '!inline execute_butler_task_steps.lock'
|
||||
language: python3
|
||||
schema:
|
||||
$schema: 'https://json-schema.org/draft/2020-12/schema'
|
||||
type: object
|
||||
order:
|
||||
- task_contract
|
||||
- steps
|
||||
- context
|
||||
properties:
|
||||
context:
|
||||
type: object
|
||||
description: 'Execution context (target, payload)'
|
||||
default: {}
|
||||
steps:
|
||||
type: array
|
||||
description: Resolved SOP step list
|
||||
items:
|
||||
type: object
|
||||
task_contract:
|
||||
type: object
|
||||
description: Serialized Butler TaskContract
|
||||
required:
|
||||
- task_contract
|
||||
- steps
|
||||
@@ -5,6 +5,8 @@ locks:
|
||||
'f/app_custom/system_heartbeat__flow+step2:_データ検証.py': d7f4e6e04ed116ba3836cb32793a0187a69359a3f2a807b533030b01d42bed39
|
||||
'f/app_custom/system_heartbeat__flow+step3:_httpヘルスチェック.py': 5d3bce0ddb4f521444bf01bc80670e7321933ad09f935044f4d6123c658ca7a8
|
||||
'f/app_custom/system_heartbeat__flow+step4:_年度判定_&_最終レポート.py': 6889bfac9a629fa42cf0505cbc945ba3782c59e1697b8493ce6101ef5ffa8b32
|
||||
f/butler/execute_task_steps__flow+__flow_hash: 4b331a51d9f4bd6fbfc4714a859a08df86184f81fd902a382725541c002bdca8
|
||||
f/butler/execute_task_steps__flow+execute_butler_task_steps.py: 90e90680a89ff3e7bd05d6c32513e9893b0c2064ae1c9e3dc3e2f3e05bad2166
|
||||
f/dev/hello_world__flow+__flow_hash: 08a256433d5978b05d08e2ba6cfa8e4324c23be4875c9775777d683f32c6015e
|
||||
f/dev/hello_world__flow+a.py: 63bf18351b5b0e81067254a03c9811e6bb388c890ad72e18092ac5ec2690a456
|
||||
f/dev/konnnichiha__flow+__flow_hash: 0d40e9e9fe2cf6944028d671b6facb9e0598d41abc3682993d5339800188b8f1
|
||||
|
||||
Reference in New Issue
Block a user