flowforge-map 0.1.0__py3-none-any.whl
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.
- flowforge/__init__.py +0 -0
- flowforge/_classic.html +289 -0
- flowforge/_dashboard/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- flowforge/_dashboard/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- flowforge/_dashboard/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- flowforge/_dashboard/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- flowforge/_dashboard/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
- flowforge/_dashboard/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
- flowforge/_dashboard/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
- flowforge/_dashboard/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
- flowforge/_dashboard/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
- flowforge/_dashboard/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
- flowforge/_dashboard/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- flowforge/_dashboard/assets/index-BGT5seIN.css +1 -0
- flowforge/_dashboard/assets/index-BoGikST6.js +52 -0
- flowforge/_dashboard/favicon.svg +1 -0
- flowforge/_dashboard/index.html +16 -0
- flowforge/_example/trip-planner/.env.example +3 -0
- flowforge/_example/trip-planner/.gitignore +2 -0
- flowforge/_example/trip-planner/.mcp.json +20 -0
- flowforge/_example/trip-planner/README.md +48 -0
- flowforge/_example/trip-planner/config.py +27 -0
- flowforge/_example/trip-planner/mcp_servers/currency.py +10 -0
- flowforge/_example/trip-planner/planner.py +113 -0
- flowforge/_example/trip-planner/requirements.txt +3 -0
- flowforge/_example/trip-planner/services/__init__.py +0 -0
- flowforge/_example/trip-planner/services/geo.py +49 -0
- flowforge/_example/trip-planner/services/llm.py +18 -0
- flowforge/_example/trip-planner/services/mcp_tools.py +26 -0
- flowforge/_example/trip-planner/services/weather.py +25 -0
- flowforge/_example/trip-planner/tools/packing_list.py +24 -0
- flowforge/_example/trip-planner/trips/.gitkeep +0 -0
- flowforge/_workflows/diamond_mock.json +71 -0
- flowforge/_workflows/stripe_to_razorpay.json +38 -0
- flowforge/agent/__init__.py +5 -0
- flowforge/agent/server.py +333 -0
- flowforge/api/__init__.py +0 -0
- flowforge/api/agent.py +79 -0
- flowforge/api/checks.py +255 -0
- flowforge/api/connectors.py +289 -0
- flowforge/api/flowmap.py +174 -0
- flowforge/api/runs.py +284 -0
- flowforge/api/scan.py +117 -0
- flowforge/api/security.py +31 -0
- flowforge/api/stats.py +80 -0
- flowforge/checks/__init__.py +13 -0
- flowforge/checks/models.py +41 -0
- flowforge/checks/probes.py +359 -0
- flowforge/cli.py +108 -0
- flowforge/connectors/__init__.py +0 -0
- flowforge/connectors/catalog.py +84 -0
- flowforge/connectors/models.py +149 -0
- flowforge/connectors/presets.py +192 -0
- flowforge/connectors/registry.py +85 -0
- flowforge/connectors/runtime.py +47 -0
- flowforge/connectors/secrets.py +53 -0
- flowforge/example_data.py +247 -0
- flowforge/flow/__init__.py +328 -0
- flowforge/flow/advice.py +132 -0
- flowforge/flow/curly.py +884 -0
- flowforge/flow/index.py +116 -0
- flowforge/flow/order.py +181 -0
- flowforge/flow/reach.py +145 -0
- flowforge/flow/rewrite.py +146 -0
- flowforge/flowmap/__init__.py +1 -0
- flowforge/flowmap/diff.py +145 -0
- flowforge/flowmap/fixture.py +283 -0
- flowforge/flowmap/models.py +258 -0
- flowforge/flowmap/planner.py +131 -0
- flowforge/flowmap/service.py +379 -0
- flowforge/flowmap/validate.py +228 -0
- flowforge/home.py +58 -0
- flowforge/main.py +137 -0
- flowforge/nodes/__init__.py +3 -0
- flowforge/nodes/base.py +74 -0
- flowforge/nodes/check_node.py +50 -0
- flowforge/nodes/http_node.py +161 -0
- flowforge/nodes/llm_node.py +158 -0
- flowforge/nodes/local_node.py +164 -0
- flowforge/nodes/mcp_node.py +243 -0
- flowforge/nodes/mock_node.py +40 -0
- flowforge/scan/__init__.py +6 -0
- flowforge/scan/facts.py +47 -0
- flowforge/scan/files.py +112 -0
- flowforge/scan/manifests.py +65 -0
- flowforge/scan/mcp_config.py +75 -0
- flowforge/scan/models.py +73 -0
- flowforge/scan/patterns.py +86 -0
- flowforge/scan/python_ast.py +246 -0
- flowforge/scan/scanner.py +344 -0
- flowforge/scan/services.py +58 -0
- flowforge/scan/urls.py +60 -0
- flowforge/scheduler/__init__.py +0 -0
- flowforge/scheduler/cache.py +76 -0
- flowforge/scheduler/critical_path.py +65 -0
- flowforge/scheduler/durations.py +35 -0
- flowforge/scheduler/executor.py +390 -0
- flowforge/scheduler/graph.py +108 -0
- flowforge/scheduler/rate_limit.py +36 -0
- flowforge/schema.py +81 -0
- flowforge/security/__init__.py +0 -0
- flowforge/security/finders.py +127 -0
- flowforge/security/findings.py +169 -0
- flowforge/security/logfilter.py +129 -0
- flowforge/spa.py +52 -0
- flowforge/state.py +73 -0
- flowforge/storage.py +481 -0
- flowforge/templating.py +60 -0
- flowforge_map-0.1.0.dist-info/METADATA +335 -0
- flowforge_map-0.1.0.dist-info/RECORD +113 -0
- flowforge_map-0.1.0.dist-info/WHEEL +4 -0
- flowforge_map-0.1.0.dist-info/entry_points.txt +3 -0
- flowforge_map-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""Concurrent list scheduler (D4, D5, D7).
|
|
2
|
+
|
|
3
|
+
One dispatcher loop owns all scheduling decisions; each running step is an asyncio task.
|
|
4
|
+
|
|
5
|
+
- ready steps sit in a heap whose key depends on the policy (see _priority)
|
|
6
|
+
- a step starts only when a global slot and its type slot are free
|
|
7
|
+
(a step whose identical call is already cached/in flight starts without a slot —
|
|
8
|
+
it only waits, it doesn't work)
|
|
9
|
+
- inside the task: rate-limit token → node.run under asyncio.wait_for(timeout);
|
|
10
|
+
transient errors retry with full-jitter backoff (or the server's Retry-After),
|
|
11
|
+
and every attempt takes a fresh token
|
|
12
|
+
- on failure every descendant is marked skipped; independent branches keep going
|
|
13
|
+
|
|
14
|
+
Policies (benchmark strategies S1–S5 from D6):
|
|
15
|
+
sequential k = 1, topological order
|
|
16
|
+
levels BFS layers with a barrier: nothing from layer i+1 starts until layer i is done
|
|
17
|
+
greedy unlimited concurrency, no type limits
|
|
18
|
+
fifo k slots, first-ready first-served
|
|
19
|
+
critical_path k slots, highest bottom level first (HLFET) — FlowForge's scheduler
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import heapq
|
|
26
|
+
import itertools
|
|
27
|
+
import math
|
|
28
|
+
import random
|
|
29
|
+
from collections.abc import Callable
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
from enum import Enum
|
|
32
|
+
from typing import Any, Literal
|
|
33
|
+
|
|
34
|
+
from flowforge import templating
|
|
35
|
+
from flowforge.nodes.base import Node, NodeError, TransientNodeError
|
|
36
|
+
from flowforge.scheduler import graph
|
|
37
|
+
from flowforge.scheduler.cache import CallCache, cache_key
|
|
38
|
+
from flowforge.scheduler.critical_path import bottom_levels, critical_path
|
|
39
|
+
from flowforge.scheduler.durations import estimate_weights
|
|
40
|
+
from flowforge.scheduler.rate_limit import TokenBucket
|
|
41
|
+
from flowforge.schema import Step, Workflow
|
|
42
|
+
|
|
43
|
+
Policy = Literal["sequential", "levels", "greedy", "fifo", "critical_path"]
|
|
44
|
+
POLICIES: tuple[Policy, ...] = ("sequential", "levels", "greedy", "fifo", "critical_path")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class StepState(str, Enum):
|
|
48
|
+
PENDING = "pending"
|
|
49
|
+
READY = "ready"
|
|
50
|
+
RUNNING = "running"
|
|
51
|
+
SUCCEEDED = "succeeded"
|
|
52
|
+
FAILED = "failed"
|
|
53
|
+
SKIPPED = "skipped"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
TERMINAL = {StepState.SUCCEEDED, StepState.FAILED, StepState.SKIPPED}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class StepResult:
|
|
61
|
+
step_id: str
|
|
62
|
+
state: StepState = StepState.PENDING
|
|
63
|
+
output: Any = None
|
|
64
|
+
error: str | None = None
|
|
65
|
+
attempts: int = 0
|
|
66
|
+
cache_hit: bool = False
|
|
67
|
+
started_at: float | None = None # seconds since run start
|
|
68
|
+
finished_at: float | None = None
|
|
69
|
+
call_ms: float | None = None # the successful node call alone: no token wait, no backoff
|
|
70
|
+
answered_by: str | None = None # connector that answered (or was last tried); differs on fallback (D10)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def duration_ms(self) -> float:
|
|
74
|
+
if self.started_at is None or self.finished_at is None:
|
|
75
|
+
return 0.0
|
|
76
|
+
return (self.finished_at - self.started_at) * 1000
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class RunResult:
|
|
81
|
+
workflow_id: str
|
|
82
|
+
policy: Policy
|
|
83
|
+
status: Literal["succeeded", "failed"]
|
|
84
|
+
makespan_ms: float
|
|
85
|
+
steps: dict[str, StepResult] = field(default_factory=dict)
|
|
86
|
+
predicted_critical_path: list[str] = field(default_factory=list)
|
|
87
|
+
predicted_critical_path_ms: float = 0.0
|
|
88
|
+
actual_critical_path: list[str] = field(default_factory=list)
|
|
89
|
+
actual_critical_path_ms: float = 0.0
|
|
90
|
+
api_calls: int = 0
|
|
91
|
+
cache_hits: int = 0
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
Event = dict[str, Any]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def backoff_s(attempt: int, error: Exception, rng: random.Random, base_s: float, cap_s: float) -> float:
|
|
98
|
+
"""Full jitter: uniform(0, min(cap, base·2^attempt)); a server Retry-After wins."""
|
|
99
|
+
if isinstance(error, TransientNodeError) and error.retry_after_s is not None:
|
|
100
|
+
return error.retry_after_s
|
|
101
|
+
return rng.uniform(0, min(cap_s, base_s * 2**attempt))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def node_for(step: Step, nodes: dict[str, Node]) -> Node:
|
|
105
|
+
"""The step's connector node if it names one, else its type's default (D10)."""
|
|
106
|
+
return nodes[step.connector or step.type]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def check_nodes(workflow: Workflow, nodes: dict[str, Node]) -> None:
|
|
110
|
+
"""Reject a run up front if any step's connector or fallback can't be resolved (D10)."""
|
|
111
|
+
errors: list[str] = []
|
|
112
|
+
for step in workflow.steps:
|
|
113
|
+
key = step.connector or step.type
|
|
114
|
+
node = nodes.get(key)
|
|
115
|
+
if node is None:
|
|
116
|
+
what = f"connector '{key}'" if step.connector else f"step type '{key}'"
|
|
117
|
+
errors.append(f"step '{step.id}': no node registered for {what}")
|
|
118
|
+
continue
|
|
119
|
+
if node.type != step.type:
|
|
120
|
+
errors.append(f"step '{step.id}' has type '{step.type}' but connector '{key}' has type '{node.type}'")
|
|
121
|
+
if node.fallback is not None:
|
|
122
|
+
backup = nodes.get(node.fallback)
|
|
123
|
+
if backup is None:
|
|
124
|
+
errors.append(f"connector '{key}' names fallback '{node.fallback}', which is not registered")
|
|
125
|
+
elif backup.type != node.type:
|
|
126
|
+
errors.append(f"connector '{key}' falls back to '{node.fallback}' of a different type")
|
|
127
|
+
if errors:
|
|
128
|
+
raise ValueError("; ".join(errors))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
async def run_workflow(
|
|
132
|
+
workflow: Workflow,
|
|
133
|
+
nodes: dict[str, Node],
|
|
134
|
+
*,
|
|
135
|
+
policy: Policy = "critical_path",
|
|
136
|
+
use_cache: bool = True,
|
|
137
|
+
max_concurrency: int | None = None,
|
|
138
|
+
rate_limits: dict[str, TokenBucket] | None = None,
|
|
139
|
+
storage: Any | None = None,
|
|
140
|
+
history: dict[str, float] | None = None,
|
|
141
|
+
on_event: Callable[[Event], None] | None = None,
|
|
142
|
+
retry_base_s: float = 1.0,
|
|
143
|
+
retry_cap_s: float = 30.0,
|
|
144
|
+
seed: int | None = None,
|
|
145
|
+
) -> RunResult:
|
|
146
|
+
"""Execute `workflow`. `nodes` maps step type → the type's default Node, and connector
|
|
147
|
+
id → that connector's Node (D10); a step uses its `connector` if set, else its type.
|
|
148
|
+
`rate_limits` maps a node's rate_limit_key → shared TokenBucket. `storage` (optional)
|
|
149
|
+
enables the persistent cache, supplies duration history and records this run's
|
|
150
|
+
measured durations.
|
|
151
|
+
"""
|
|
152
|
+
if policy not in POLICIES:
|
|
153
|
+
raise ValueError(f"unknown policy {policy!r}")
|
|
154
|
+
check_nodes(workflow, nodes)
|
|
155
|
+
|
|
156
|
+
dag = graph.build_dag(workflow)
|
|
157
|
+
topo = graph.topological_order(dag)
|
|
158
|
+
topo_index = {s: i for i, s in enumerate(topo)}
|
|
159
|
+
level = {s: i for i, layer in enumerate(graph.levels(dag)) for s in layer}
|
|
160
|
+
|
|
161
|
+
if history is None:
|
|
162
|
+
history = storage.duration_history(workflow.id) if storage is not None else {}
|
|
163
|
+
weights = estimate_weights(workflow, history)
|
|
164
|
+
bl = bottom_levels(dag, weights)
|
|
165
|
+
predicted = critical_path(dag, weights)
|
|
166
|
+
|
|
167
|
+
k = max_concurrency or workflow.max_concurrency
|
|
168
|
+
type_limits: dict[str, int] = dict(workflow.type_concurrency)
|
|
169
|
+
if policy == "sequential":
|
|
170
|
+
k = 1
|
|
171
|
+
elif policy == "greedy":
|
|
172
|
+
k, type_limits = math.inf, {}
|
|
173
|
+
|
|
174
|
+
steps = {s.id: s for s in workflow.steps}
|
|
175
|
+
results = {s.id: StepResult(s.id) for s in workflow.steps}
|
|
176
|
+
outputs: dict[str, Any] = {}
|
|
177
|
+
unmet = {s: len(dag.parents[s]) for s in dag.nodes}
|
|
178
|
+
cache = CallCache(storage)
|
|
179
|
+
rate_limits = rate_limits or {}
|
|
180
|
+
rng = random.Random(seed)
|
|
181
|
+
loop = asyncio.get_running_loop()
|
|
182
|
+
t0 = loop.time()
|
|
183
|
+
ready_seq = itertools.count()
|
|
184
|
+
|
|
185
|
+
def now() -> float:
|
|
186
|
+
return loop.time() - t0
|
|
187
|
+
|
|
188
|
+
def emit(event: Event) -> None:
|
|
189
|
+
if on_event is not None:
|
|
190
|
+
on_event({"t": round(now(), 4), **event})
|
|
191
|
+
|
|
192
|
+
def set_state(step_id: str, state: StepState, **extra: Any) -> None:
|
|
193
|
+
results[step_id].state = state
|
|
194
|
+
emit({"type": "step", "step_id": step_id, "state": state.value, **extra})
|
|
195
|
+
|
|
196
|
+
def _priority(step_id: str) -> tuple:
|
|
197
|
+
if policy == "critical_path":
|
|
198
|
+
return (-bl[step_id], topo_index[step_id])
|
|
199
|
+
if policy == "fifo":
|
|
200
|
+
return (next(ready_seq),)
|
|
201
|
+
if policy == "levels":
|
|
202
|
+
return (level[step_id], topo_index[step_id])
|
|
203
|
+
return (topo_index[step_id],)
|
|
204
|
+
|
|
205
|
+
ready: list[tuple[tuple, str]] = []
|
|
206
|
+
|
|
207
|
+
def make_ready(step_id: str) -> None:
|
|
208
|
+
set_state(step_id, StepState.READY)
|
|
209
|
+
heapq.heappush(ready, (_priority(step_id), step_id))
|
|
210
|
+
|
|
211
|
+
# --- one step's execution (runs as its own task) ---------------------------
|
|
212
|
+
|
|
213
|
+
def caching_for(step: Step, params: dict[str, Any]) -> tuple[bool, bool]:
|
|
214
|
+
"""(dedupe within run, persist across runs) per D3."""
|
|
215
|
+
if not use_cache or step.cache is False:
|
|
216
|
+
return False, False
|
|
217
|
+
persistent = step.cache is True or node_for(step, nodes).cacheable_across_runs(params)
|
|
218
|
+
return True, persistent and storage is not None
|
|
219
|
+
|
|
220
|
+
async def attempts_on(node: Node, step: Step, params: dict[str, Any]) -> Any:
|
|
221
|
+
bucket = rate_limits.get(node.rate_limit_key) if node.rate_limit_key else None
|
|
222
|
+
for attempt in range(step.retries + 1):
|
|
223
|
+
if bucket is not None:
|
|
224
|
+
await bucket.acquire()
|
|
225
|
+
results[step.id].attempts += 1
|
|
226
|
+
results[step.id].answered_by = node.connector_id # the last connector tried, so failures are attributed too
|
|
227
|
+
call_start = loop.time()
|
|
228
|
+
try:
|
|
229
|
+
output = await asyncio.wait_for(node.run(params), step.effective_timeout_s)
|
|
230
|
+
results[step.id].call_ms = (loop.time() - call_start) * 1000
|
|
231
|
+
return output
|
|
232
|
+
except (TransientNodeError, TimeoutError) as exc:
|
|
233
|
+
if attempt == step.retries:
|
|
234
|
+
raise
|
|
235
|
+
delay = backoff_s(attempt, exc, rng, retry_base_s, retry_cap_s)
|
|
236
|
+
emit({"type": "retry", "step_id": step.id, "attempt": attempt + 1,
|
|
237
|
+
"error": str(exc) or type(exc).__name__, "delay_s": round(delay, 3)})
|
|
238
|
+
await asyncio.sleep(delay)
|
|
239
|
+
raise AssertionError("unreachable")
|
|
240
|
+
|
|
241
|
+
async def call_with_retries(step: Step, params: dict[str, Any]) -> Any:
|
|
242
|
+
node = node_for(step, nodes)
|
|
243
|
+
try:
|
|
244
|
+
return await attempts_on(node, step, params)
|
|
245
|
+
except (TransientNodeError, TimeoutError) as exc:
|
|
246
|
+
if node.fallback is None:
|
|
247
|
+
raise
|
|
248
|
+
# one hop, only after transient exhaustion; permanent errors never get here (D10)
|
|
249
|
+
emit({"type": "fallback", "step_id": step.id, "from": node.connector_id, "to": node.fallback,
|
|
250
|
+
"error": str(exc) or type(exc).__name__})
|
|
251
|
+
return await attempts_on(nodes[node.fallback], step, params)
|
|
252
|
+
|
|
253
|
+
async def execute(step: Step, params: dict[str, Any]) -> Any:
|
|
254
|
+
dedupe, persistent = caching_for(step, params)
|
|
255
|
+
if not dedupe:
|
|
256
|
+
return await call_with_retries(step, params)
|
|
257
|
+
value, hit = await cache.get_or_run(
|
|
258
|
+
cache_key(node_for(step, nodes).cache_namespace, params), lambda: call_with_retries(step, params),
|
|
259
|
+
persistent,
|
|
260
|
+
)
|
|
261
|
+
results[step.id].cache_hit = hit
|
|
262
|
+
return value
|
|
263
|
+
|
|
264
|
+
# --- dispatcher --------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
running: dict[asyncio.Task[Any], tuple[str, bool]] = {} # task → (step id, holds a slot)
|
|
267
|
+
type_running: dict[str, int] = {}
|
|
268
|
+
slots_used = 0
|
|
269
|
+
|
|
270
|
+
def current_level() -> int:
|
|
271
|
+
return min((level[s] for s, r in results.items() if r.state not in TERMINAL), default=0)
|
|
272
|
+
|
|
273
|
+
def start(step_id: str, params: dict[str, Any], holds_slot: bool) -> None:
|
|
274
|
+
nonlocal slots_used
|
|
275
|
+
step = steps[step_id]
|
|
276
|
+
if holds_slot:
|
|
277
|
+
slots_used += 1
|
|
278
|
+
type_running[step.type] = type_running.get(step.type, 0) + 1
|
|
279
|
+
results[step_id].started_at = now()
|
|
280
|
+
set_state(step_id, StepState.RUNNING)
|
|
281
|
+
running[asyncio.create_task(execute(step, params))] = (step_id, holds_slot)
|
|
282
|
+
|
|
283
|
+
def dispatch() -> None:
|
|
284
|
+
"""Start every ready step that can start now, best priority first."""
|
|
285
|
+
blocked: list[tuple[tuple, str]] = []
|
|
286
|
+
gate = current_level() if policy == "levels" else None
|
|
287
|
+
while ready:
|
|
288
|
+
item = heapq.heappop(ready)
|
|
289
|
+
step_id = item[1]
|
|
290
|
+
step = steps[step_id]
|
|
291
|
+
if gate is not None and level[step_id] > gate:
|
|
292
|
+
blocked.append(item)
|
|
293
|
+
continue
|
|
294
|
+
try:
|
|
295
|
+
params = templating.resolve(step.params, outputs)
|
|
296
|
+
except templating.TemplateError as exc:
|
|
297
|
+
fail(step_id, f"template error: {exc}")
|
|
298
|
+
continue
|
|
299
|
+
dedupe, _ = caching_for(step, params)
|
|
300
|
+
if dedupe and cache.has(cache_key(node_for(step, nodes).cache_namespace, params)):
|
|
301
|
+
start(step_id, params, holds_slot=False)
|
|
302
|
+
continue
|
|
303
|
+
type_full = type_running.get(step.type, 0) >= type_limits.get(step.type, math.inf)
|
|
304
|
+
if slots_used >= k or type_full:
|
|
305
|
+
blocked.append(item)
|
|
306
|
+
continue
|
|
307
|
+
start(step_id, params, holds_slot=True)
|
|
308
|
+
for item in blocked:
|
|
309
|
+
heapq.heappush(ready, item)
|
|
310
|
+
|
|
311
|
+
def fail(step_id: str, error: str) -> None:
|
|
312
|
+
results[step_id].error = error
|
|
313
|
+
results[step_id].finished_at = results[step_id].finished_at or now()
|
|
314
|
+
set_state(step_id, StepState.FAILED, error=error)
|
|
315
|
+
for d in sorted(graph.descendants(dag, step_id), key=topo_index.__getitem__):
|
|
316
|
+
if results[d].state not in TERMINAL:
|
|
317
|
+
set_state(d, StepState.SKIPPED, reason=f"upstream '{step_id}' failed")
|
|
318
|
+
|
|
319
|
+
def succeed(step_id: str, output: Any) -> None:
|
|
320
|
+
outputs[step_id] = output
|
|
321
|
+
results[step_id].output = output
|
|
322
|
+
# the output rides on the event so a live view can show it before the run ends (D16)
|
|
323
|
+
set_state(step_id, StepState.SUCCEEDED, cache_hit=results[step_id].cache_hit,
|
|
324
|
+
duration_ms=round(results[step_id].duration_ms, 1), output=output)
|
|
325
|
+
for child in dag.children[step_id]:
|
|
326
|
+
unmet[child] -= 1
|
|
327
|
+
if unmet[child] == 0 and results[child].state == StepState.PENDING:
|
|
328
|
+
make_ready(child)
|
|
329
|
+
|
|
330
|
+
emit({"type": "run", "state": "started", "workflow_id": workflow.id, "policy": policy,
|
|
331
|
+
"predicted_critical_path": predicted.path, "predicted_critical_path_ms": predicted.length_ms,
|
|
332
|
+
"estimates": weights})
|
|
333
|
+
for s in topo:
|
|
334
|
+
if unmet[s] == 0:
|
|
335
|
+
make_ready(s)
|
|
336
|
+
|
|
337
|
+
try:
|
|
338
|
+
dispatch()
|
|
339
|
+
while running:
|
|
340
|
+
done, _ = await asyncio.wait(running, return_when=asyncio.FIRST_COMPLETED)
|
|
341
|
+
for task in done:
|
|
342
|
+
step_id, held = running.pop(task)
|
|
343
|
+
if held:
|
|
344
|
+
slots_used -= 1
|
|
345
|
+
type_running[steps[step_id].type] -= 1
|
|
346
|
+
results[step_id].finished_at = now()
|
|
347
|
+
exc = task.exception()
|
|
348
|
+
if exc is None:
|
|
349
|
+
succeed(step_id, task.result())
|
|
350
|
+
elif isinstance(exc, (NodeError, TimeoutError)):
|
|
351
|
+
fail(step_id, str(exc) or type(exc).__name__)
|
|
352
|
+
else: # a bug in a node — still contained to this step
|
|
353
|
+
fail(step_id, f"{type(exc).__name__}: {exc}")
|
|
354
|
+
dispatch()
|
|
355
|
+
finally:
|
|
356
|
+
for task in running:
|
|
357
|
+
task.cancel()
|
|
358
|
+
|
|
359
|
+
# --- results -------------------------------------------------------------------
|
|
360
|
+
|
|
361
|
+
stuck = [s for s, r in results.items() if r.state not in TERMINAL]
|
|
362
|
+
assert not stuck, f"scheduler bug: steps never finished: {stuck}"
|
|
363
|
+
|
|
364
|
+
measured = {s: r.duration_ms for s, r in results.items()}
|
|
365
|
+
actual = critical_path(dag, measured)
|
|
366
|
+
status: Literal["succeeded", "failed"] = (
|
|
367
|
+
"succeeded" if all(r.state == StepState.SUCCEEDED for r in results.values()) else "failed"
|
|
368
|
+
)
|
|
369
|
+
result = RunResult(
|
|
370
|
+
workflow_id=workflow.id,
|
|
371
|
+
policy=policy,
|
|
372
|
+
status=status,
|
|
373
|
+
makespan_ms=now() * 1000,
|
|
374
|
+
steps=results,
|
|
375
|
+
predicted_critical_path=predicted.path,
|
|
376
|
+
predicted_critical_path_ms=predicted.length_ms,
|
|
377
|
+
actual_critical_path=actual.path,
|
|
378
|
+
actual_critical_path_ms=actual.length_ms,
|
|
379
|
+
api_calls=sum(r.attempts for r in results.values()),
|
|
380
|
+
cache_hits=cache.hits,
|
|
381
|
+
)
|
|
382
|
+
if storage is not None:
|
|
383
|
+
storage.record_durations(
|
|
384
|
+
workflow.id,
|
|
385
|
+
{s: r.duration_ms for s, r in results.items()
|
|
386
|
+
if r.state == StepState.SUCCEEDED and not r.cache_hit},
|
|
387
|
+
)
|
|
388
|
+
emit({"type": "run", "state": status, "makespan_ms": round(result.makespan_ms, 1),
|
|
389
|
+
"actual_critical_path": actual.path})
|
|
390
|
+
return result
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""DAG construction, topological sort, cycle detection (D7)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import heapq
|
|
6
|
+
from collections import deque
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
|
|
9
|
+
from flowforge.schema import Workflow
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CycleError(ValueError):
|
|
13
|
+
def __init__(self, cycle: list[str]):
|
|
14
|
+
# cycle is closed: ["a", "b", "c", "a"]
|
|
15
|
+
super().__init__("cycle: " + " → ".join(cycle))
|
|
16
|
+
self.cycle = cycle
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class DAG:
|
|
21
|
+
nodes: list[str]
|
|
22
|
+
parents: dict[str, list[str]] = field(default_factory=dict) # step -> its depends_on
|
|
23
|
+
children: dict[str, list[str]] = field(default_factory=dict) # step -> steps depending on it
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def build_dag(workflow: Workflow) -> DAG:
|
|
27
|
+
"""Adjacency lists from `depends_on`. Raise CycleError if the graph is not acyclic."""
|
|
28
|
+
nodes = [s.id for s in workflow.steps]
|
|
29
|
+
parents = {s.id: list(dict.fromkeys(s.depends_on)) for s in workflow.steps} # dedupe, keep order
|
|
30
|
+
children: dict[str, list[str]] = {n: [] for n in nodes}
|
|
31
|
+
for child in nodes:
|
|
32
|
+
for parent in parents[child]:
|
|
33
|
+
children[parent].append(child)
|
|
34
|
+
|
|
35
|
+
dag = DAG(nodes=nodes, parents=parents, children=children)
|
|
36
|
+
topological_order(dag) # raises CycleError
|
|
37
|
+
return dag
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def topological_order(dag: DAG) -> list[str]:
|
|
41
|
+
"""Kahn's algorithm. Ties are broken by original step order (a min-heap on the
|
|
42
|
+
step's index), so the output is deterministic: O((V + E) log V).
|
|
43
|
+
|
|
44
|
+
If fewer than V nodes come out, find and raise the actual cycle.
|
|
45
|
+
"""
|
|
46
|
+
index = {n: i for i, n in enumerate(dag.nodes)}
|
|
47
|
+
indegree = {n: len(dag.parents[n]) for n in dag.nodes}
|
|
48
|
+
ready = [index[n] for n in dag.nodes if indegree[n] == 0]
|
|
49
|
+
heapq.heapify(ready)
|
|
50
|
+
|
|
51
|
+
order: list[str] = []
|
|
52
|
+
while ready:
|
|
53
|
+
node = dag.nodes[heapq.heappop(ready)]
|
|
54
|
+
order.append(node)
|
|
55
|
+
for child in dag.children[node]:
|
|
56
|
+
indegree[child] -= 1
|
|
57
|
+
if indegree[child] == 0:
|
|
58
|
+
heapq.heappush(ready, index[child])
|
|
59
|
+
|
|
60
|
+
if len(order) < len(dag.nodes):
|
|
61
|
+
leftover = [n for n in dag.nodes if indegree[n] > 0]
|
|
62
|
+
raise CycleError(_find_cycle(dag, set(leftover), leftover[0]))
|
|
63
|
+
return order
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _find_cycle(dag: DAG, leftover: set[str], start: str) -> list[str]:
|
|
67
|
+
"""Every node Kahn's left behind still has an unprocessed parent, which is itself
|
|
68
|
+
left over. So walking parent links from any leftover node stays inside the leftovers
|
|
69
|
+
and must eventually revisit a node — that repeat closes a cycle.
|
|
70
|
+
"""
|
|
71
|
+
position: dict[str, int] = {}
|
|
72
|
+
walk: list[str] = []
|
|
73
|
+
node = start
|
|
74
|
+
while node not in position:
|
|
75
|
+
position[node] = len(walk)
|
|
76
|
+
walk.append(node)
|
|
77
|
+
node = next(p for p in dag.parents[node] if p in leftover)
|
|
78
|
+
# walk[position[node]:] follows child → parent; reverse it to read in execution order
|
|
79
|
+
cycle = walk[position[node]:][::-1]
|
|
80
|
+
return cycle + [cycle[0]]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def levels(dag: DAG) -> list[list[str]]:
|
|
84
|
+
"""BFS layers: level 0 = no parents, level i = longest parent chain of length i.
|
|
85
|
+
|
|
86
|
+
Used by benchmark strategy S2 (level-by-level with barriers).
|
|
87
|
+
Within a level, steps keep their original order.
|
|
88
|
+
"""
|
|
89
|
+
level: dict[str, int] = {}
|
|
90
|
+
for node in topological_order(dag):
|
|
91
|
+
level[node] = 1 + max((level[p] for p in dag.parents[node]), default=-1)
|
|
92
|
+
|
|
93
|
+
layers: list[list[str]] = [[] for _ in range(max(level.values(), default=-1) + 1)]
|
|
94
|
+
for node in dag.nodes:
|
|
95
|
+
layers[level[node]].append(node)
|
|
96
|
+
return layers
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def descendants(dag: DAG, step_id: str) -> set[str]:
|
|
100
|
+
"""Every step reachable from step_id — these get `skipped` when it fails."""
|
|
101
|
+
seen: set[str] = set()
|
|
102
|
+
queue = deque(dag.children[step_id])
|
|
103
|
+
while queue:
|
|
104
|
+
node = queue.popleft()
|
|
105
|
+
if node not in seen:
|
|
106
|
+
seen.add(node)
|
|
107
|
+
queue.extend(dag.children[node])
|
|
108
|
+
return seen
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Async token bucket (D5, D8)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TokenBucket:
|
|
9
|
+
"""`rate_per_min` tokens refill continuously; bursts up to `capacity`.
|
|
10
|
+
|
|
11
|
+
acquire() waits with asyncio.sleep (never busy-loops) until a token is available.
|
|
12
|
+
Waiters queue on a lock while sleeping, so tokens are handed out first-come first-served.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, rate_per_min: float, capacity: int | None = None):
|
|
16
|
+
if rate_per_min <= 0:
|
|
17
|
+
raise ValueError("rate_per_min must be positive")
|
|
18
|
+
self.rate_per_s = rate_per_min / 60
|
|
19
|
+
self.capacity = capacity if capacity is not None else max(1, int(rate_per_min / 6))
|
|
20
|
+
self.tokens = float(self.capacity)
|
|
21
|
+
self._last: float | None = None
|
|
22
|
+
self._lock = asyncio.Lock()
|
|
23
|
+
|
|
24
|
+
def _refill(self, now: float) -> None:
|
|
25
|
+
if self._last is not None:
|
|
26
|
+
self.tokens = min(self.capacity, self.tokens + (now - self._last) * self.rate_per_s)
|
|
27
|
+
self._last = now
|
|
28
|
+
|
|
29
|
+
async def acquire(self) -> None:
|
|
30
|
+
loop = asyncio.get_running_loop()
|
|
31
|
+
async with self._lock:
|
|
32
|
+
self._refill(loop.time())
|
|
33
|
+
while self.tokens < 1:
|
|
34
|
+
await asyncio.sleep((1 - self.tokens) / self.rate_per_s)
|
|
35
|
+
self._refill(loop.time())
|
|
36
|
+
self.tokens -= 1
|
flowforge/schema.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Workflow JSON models and static validation (DECISIONS.md D2, D7).
|
|
2
|
+
|
|
3
|
+
Validation here is everything that can be checked per-step or pairwise.
|
|
4
|
+
Cycle detection needs the whole graph and lives in scheduler/graph.py.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, Literal
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
12
|
+
|
|
13
|
+
from flowforge.connectors.models import CONNECTOR_ID_PATTERN
|
|
14
|
+
from flowforge.templating import referenced_steps
|
|
15
|
+
|
|
16
|
+
StepType = Literal["llm", "mcp", "http", "local", "mock", "check"] # check: a health check (D24)
|
|
17
|
+
|
|
18
|
+
DEFAULT_TIMEOUT_S: dict[str, float] = {"llm": 60, "mcp": 30, "http": 30, "local": 60, "mock": 30, "check": 60}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Step(BaseModel):
|
|
22
|
+
model_config = ConfigDict(extra="forbid") # catch typos like "depend_on"
|
|
23
|
+
|
|
24
|
+
id: str = Field(pattern=r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
25
|
+
type: StepType
|
|
26
|
+
connector: str | None = Field(default=None, pattern=CONNECTOR_ID_PATTERN) # None = type default (D10)
|
|
27
|
+
depends_on: list[str] = Field(default_factory=list)
|
|
28
|
+
params: dict[str, Any] = Field(default_factory=dict)
|
|
29
|
+
estimated_ms: float | None = Field(default=None, gt=0)
|
|
30
|
+
timeout_s: float | None = Field(default=None, gt=0)
|
|
31
|
+
retries: int = Field(default=2, ge=0, le=10)
|
|
32
|
+
cache: bool | None = None # None = use the per-type default (D3)
|
|
33
|
+
description: str = ""
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def effective_timeout_s(self) -> float:
|
|
37
|
+
return self.timeout_s if self.timeout_s is not None else DEFAULT_TIMEOUT_S[self.type]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Workflow(BaseModel):
|
|
41
|
+
model_config = ConfigDict(extra="forbid")
|
|
42
|
+
|
|
43
|
+
id: str
|
|
44
|
+
name: str = ""
|
|
45
|
+
max_concurrency: int = Field(default=4, ge=1)
|
|
46
|
+
type_concurrency: dict[StepType, int] = Field(default_factory=dict)
|
|
47
|
+
steps: list[Step] = Field(min_length=1)
|
|
48
|
+
|
|
49
|
+
@model_validator(mode="after")
|
|
50
|
+
def _check_references(self) -> Workflow:
|
|
51
|
+
errors: list[str] = []
|
|
52
|
+
ids = [s.id for s in self.steps]
|
|
53
|
+
known = set(ids)
|
|
54
|
+
|
|
55
|
+
seen: set[str] = set()
|
|
56
|
+
for sid in ids:
|
|
57
|
+
if sid in seen:
|
|
58
|
+
errors.append(f"duplicate step id '{sid}'")
|
|
59
|
+
seen.add(sid)
|
|
60
|
+
|
|
61
|
+
for step in self.steps:
|
|
62
|
+
deps = set(step.depends_on)
|
|
63
|
+
if step.id in deps:
|
|
64
|
+
errors.append(f"step '{step.id}' depends on itself")
|
|
65
|
+
for dep in deps - known:
|
|
66
|
+
errors.append(f"step '{step.id}' depends on unknown step '{dep}'")
|
|
67
|
+
for ref in referenced_steps(step.params) - deps:
|
|
68
|
+
errors.append(
|
|
69
|
+
f"step '{step.id}' references '{{{{steps.{ref}...}}}}' "
|
|
70
|
+
f"but '{ref}' is not in its depends_on"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
if errors:
|
|
74
|
+
raise ValueError("; ".join(errors))
|
|
75
|
+
return self
|
|
76
|
+
|
|
77
|
+
def step(self, step_id: str) -> Step:
|
|
78
|
+
for s in self.steps:
|
|
79
|
+
if s.id == step_id:
|
|
80
|
+
return s
|
|
81
|
+
raise KeyError(step_id)
|
|
File without changes
|