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.
Files changed (113) hide show
  1. flowforge/__init__.py +0 -0
  2. flowforge/_classic.html +289 -0
  3. flowforge/_dashboard/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  4. flowforge/_dashboard/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  5. flowforge/_dashboard/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  6. flowforge/_dashboard/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  7. flowforge/_dashboard/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
  8. flowforge/_dashboard/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
  9. flowforge/_dashboard/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
  10. flowforge/_dashboard/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
  11. flowforge/_dashboard/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
  12. flowforge/_dashboard/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
  13. flowforge/_dashboard/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  14. flowforge/_dashboard/assets/index-BGT5seIN.css +1 -0
  15. flowforge/_dashboard/assets/index-BoGikST6.js +52 -0
  16. flowforge/_dashboard/favicon.svg +1 -0
  17. flowforge/_dashboard/index.html +16 -0
  18. flowforge/_example/trip-planner/.env.example +3 -0
  19. flowforge/_example/trip-planner/.gitignore +2 -0
  20. flowforge/_example/trip-planner/.mcp.json +20 -0
  21. flowforge/_example/trip-planner/README.md +48 -0
  22. flowforge/_example/trip-planner/config.py +27 -0
  23. flowforge/_example/trip-planner/mcp_servers/currency.py +10 -0
  24. flowforge/_example/trip-planner/planner.py +113 -0
  25. flowforge/_example/trip-planner/requirements.txt +3 -0
  26. flowforge/_example/trip-planner/services/__init__.py +0 -0
  27. flowforge/_example/trip-planner/services/geo.py +49 -0
  28. flowforge/_example/trip-planner/services/llm.py +18 -0
  29. flowforge/_example/trip-planner/services/mcp_tools.py +26 -0
  30. flowforge/_example/trip-planner/services/weather.py +25 -0
  31. flowforge/_example/trip-planner/tools/packing_list.py +24 -0
  32. flowforge/_example/trip-planner/trips/.gitkeep +0 -0
  33. flowforge/_workflows/diamond_mock.json +71 -0
  34. flowforge/_workflows/stripe_to_razorpay.json +38 -0
  35. flowforge/agent/__init__.py +5 -0
  36. flowforge/agent/server.py +333 -0
  37. flowforge/api/__init__.py +0 -0
  38. flowforge/api/agent.py +79 -0
  39. flowforge/api/checks.py +255 -0
  40. flowforge/api/connectors.py +289 -0
  41. flowforge/api/flowmap.py +174 -0
  42. flowforge/api/runs.py +284 -0
  43. flowforge/api/scan.py +117 -0
  44. flowforge/api/security.py +31 -0
  45. flowforge/api/stats.py +80 -0
  46. flowforge/checks/__init__.py +13 -0
  47. flowforge/checks/models.py +41 -0
  48. flowforge/checks/probes.py +359 -0
  49. flowforge/cli.py +108 -0
  50. flowforge/connectors/__init__.py +0 -0
  51. flowforge/connectors/catalog.py +84 -0
  52. flowforge/connectors/models.py +149 -0
  53. flowforge/connectors/presets.py +192 -0
  54. flowforge/connectors/registry.py +85 -0
  55. flowforge/connectors/runtime.py +47 -0
  56. flowforge/connectors/secrets.py +53 -0
  57. flowforge/example_data.py +247 -0
  58. flowforge/flow/__init__.py +328 -0
  59. flowforge/flow/advice.py +132 -0
  60. flowforge/flow/curly.py +884 -0
  61. flowforge/flow/index.py +116 -0
  62. flowforge/flow/order.py +181 -0
  63. flowforge/flow/reach.py +145 -0
  64. flowforge/flow/rewrite.py +146 -0
  65. flowforge/flowmap/__init__.py +1 -0
  66. flowforge/flowmap/diff.py +145 -0
  67. flowforge/flowmap/fixture.py +283 -0
  68. flowforge/flowmap/models.py +258 -0
  69. flowforge/flowmap/planner.py +131 -0
  70. flowforge/flowmap/service.py +379 -0
  71. flowforge/flowmap/validate.py +228 -0
  72. flowforge/home.py +58 -0
  73. flowforge/main.py +137 -0
  74. flowforge/nodes/__init__.py +3 -0
  75. flowforge/nodes/base.py +74 -0
  76. flowforge/nodes/check_node.py +50 -0
  77. flowforge/nodes/http_node.py +161 -0
  78. flowforge/nodes/llm_node.py +158 -0
  79. flowforge/nodes/local_node.py +164 -0
  80. flowforge/nodes/mcp_node.py +243 -0
  81. flowforge/nodes/mock_node.py +40 -0
  82. flowforge/scan/__init__.py +6 -0
  83. flowforge/scan/facts.py +47 -0
  84. flowforge/scan/files.py +112 -0
  85. flowforge/scan/manifests.py +65 -0
  86. flowforge/scan/mcp_config.py +75 -0
  87. flowforge/scan/models.py +73 -0
  88. flowforge/scan/patterns.py +86 -0
  89. flowforge/scan/python_ast.py +246 -0
  90. flowforge/scan/scanner.py +344 -0
  91. flowforge/scan/services.py +58 -0
  92. flowforge/scan/urls.py +60 -0
  93. flowforge/scheduler/__init__.py +0 -0
  94. flowforge/scheduler/cache.py +76 -0
  95. flowforge/scheduler/critical_path.py +65 -0
  96. flowforge/scheduler/durations.py +35 -0
  97. flowforge/scheduler/executor.py +390 -0
  98. flowforge/scheduler/graph.py +108 -0
  99. flowforge/scheduler/rate_limit.py +36 -0
  100. flowforge/schema.py +81 -0
  101. flowforge/security/__init__.py +0 -0
  102. flowforge/security/finders.py +127 -0
  103. flowforge/security/findings.py +169 -0
  104. flowforge/security/logfilter.py +129 -0
  105. flowforge/spa.py +52 -0
  106. flowforge/state.py +73 -0
  107. flowforge/storage.py +481 -0
  108. flowforge/templating.py +60 -0
  109. flowforge_map-0.1.0.dist-info/METADATA +335 -0
  110. flowforge_map-0.1.0.dist-info/RECORD +113 -0
  111. flowforge_map-0.1.0.dist-info/WHEEL +4 -0
  112. flowforge_map-0.1.0.dist-info/entry_points.txt +3 -0
  113. 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