fancy-flow 0.1.0__tar.gz

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 (100) hide show
  1. fancy_flow-0.1.0/.gitignore +13 -0
  2. fancy_flow-0.1.0/AGENTS.md +196 -0
  3. fancy_flow-0.1.0/CHANGELOG.md +133 -0
  4. fancy_flow-0.1.0/LICENSE +21 -0
  5. fancy_flow-0.1.0/PKG-INFO +157 -0
  6. fancy_flow-0.1.0/README.md +134 -0
  7. fancy_flow-0.1.0/pyproject.toml +116 -0
  8. fancy_flow-0.1.0/src/fancy_flow/__init__.py +97 -0
  9. fancy_flow-0.1.0/src/fancy_flow/capabilities/__init__.py +212 -0
  10. fancy_flow-0.1.0/src/fancy_flow/contracts.py +62 -0
  11. fancy_flow-0.1.0/src/fancy_flow/durable/__init__.py +51 -0
  12. fancy_flow-0.1.0/src/fancy_flow/durable/coordinator.py +330 -0
  13. fancy_flow-0.1.0/src/fancy_flow/durable/frontier.py +145 -0
  14. fancy_flow-0.1.0/src/fancy_flow/durable/human.py +138 -0
  15. fancy_flow-0.1.0/src/fancy_flow/durable/replay.py +126 -0
  16. fancy_flow-0.1.0/src/fancy_flow/durable/retry.py +78 -0
  17. fancy_flow-0.1.0/src/fancy_flow/durable/state.py +192 -0
  18. fancy_flow-0.1.0/src/fancy_flow/engine/__init__.py +5 -0
  19. fancy_flow-0.1.0/src/fancy_flow/engine/runner.py +399 -0
  20. fancy_flow-0.1.0/src/fancy_flow/exceptions.py +49 -0
  21. fancy_flow-0.1.0/src/fancy_flow/executors.py +189 -0
  22. fancy_flow-0.1.0/src/fancy_flow/marketplace/__init__.py +28 -0
  23. fancy_flow-0.1.0/src/fancy_flow/marketplace/manifest.py +425 -0
  24. fancy_flow-0.1.0/src/fancy_flow/nodes/__init__.py +9 -0
  25. fancy_flow-0.1.0/src/fancy_flow/nodes/ai.py +266 -0
  26. fancy_flow-0.1.0/src/fancy_flow/nodes/data.py +97 -0
  27. fancy_flow-0.1.0/src/fancy_flow/nodes/human.py +56 -0
  28. fancy_flow-0.1.0/src/fancy_flow/nodes/io_.py +61 -0
  29. fancy_flow-0.1.0/src/fancy_flow/nodes/logic.py +130 -0
  30. fancy_flow-0.1.0/src/fancy_flow/nodes/output.py +24 -0
  31. fancy_flow-0.1.0/src/fancy_flow/nodes/structural.py +240 -0
  32. fancy_flow-0.1.0/src/fancy_flow/nodes/support/__init__.py +36 -0
  33. fancy_flow-0.1.0/src/fancy_flow/nodes/support/clients.py +182 -0
  34. fancy_flow-0.1.0/src/fancy_flow/nodes/support/deps.py +39 -0
  35. fancy_flow-0.1.0/src/fancy_flow/nodes/support/expr.py +202 -0
  36. fancy_flow-0.1.0/src/fancy_flow/nodes/support/structured.py +221 -0
  37. fancy_flow-0.1.0/src/fancy_flow/nodes/trigger.py +44 -0
  38. fancy_flow-0.1.0/src/fancy_flow/py.typed +0 -0
  39. fancy_flow-0.1.0/src/fancy_flow/registry/__init__.py +21 -0
  40. fancy_flow-0.1.0/src/fancy_flow/registry/builtin.py +1029 -0
  41. fancy_flow-0.1.0/src/fancy_flow/registry/kind_id.py +78 -0
  42. fancy_flow-0.1.0/src/fancy_flow/registry/node_kind.py +203 -0
  43. fancy_flow-0.1.0/src/fancy_flow/registry/registry.py +160 -0
  44. fancy_flow-0.1.0/src/fancy_flow/runtime/__init__.py +24 -0
  45. fancy_flow-0.1.0/src/fancy_flow/runtime/abort.py +42 -0
  46. fancy_flow-0.1.0/src/fancy_flow/runtime/context.py +90 -0
  47. fancy_flow-0.1.0/src/fancy_flow/runtime/events.py +119 -0
  48. fancy_flow-0.1.0/src/fancy_flow/runtime/identity.py +202 -0
  49. fancy_flow-0.1.0/src/fancy_flow/runtime/options.py +63 -0
  50. fancy_flow-0.1.0/src/fancy_flow/runtime/pause.py +119 -0
  51. fancy_flow-0.1.0/src/fancy_flow/runtime/ports.py +30 -0
  52. fancy_flow-0.1.0/src/fancy_flow/schema/__init__.py +16 -0
  53. fancy_flow-0.1.0/src/fancy_flow/schema/graph.py +154 -0
  54. fancy_flow-0.1.0/src/fancy_flow/schema/issues.py +65 -0
  55. fancy_flow-0.1.0/src/fancy_flow/security/__init__.py +5 -0
  56. fancy_flow-0.1.0/src/fancy_flow/security/policy.py +333 -0
  57. fancy_flow-0.1.0/src/fancy_flow/workflow.py +207 -0
  58. fancy_flow-0.1.0/tests/__init__.py +0 -0
  59. fancy_flow-0.1.0/tests/conformance/__init__.py +0 -0
  60. fancy_flow-0.1.0/tests/conformance/loader.py +199 -0
  61. fancy_flow-0.1.0/tests/conformance/test_expr_conformance.py +78 -0
  62. fancy_flow-0.1.0/tests/conformance/test_range_conformance.py +54 -0
  63. fancy_flow-0.1.0/tests/conformance/test_run_identity_conformance.py +90 -0
  64. fancy_flow-0.1.0/tests/parity/__init__.py +0 -0
  65. fancy_flow-0.1.0/tests/parity/fixtures/01-manual-transform-output.json +70 -0
  66. fancy_flow-0.1.0/tests/parity/fixtures/02-webhook-branch-true-notify.json +106 -0
  67. fancy_flow-0.1.0/tests/parity/fixtures/03-branch-false-log.json +99 -0
  68. fancy_flow-0.1.0/tests/parity/fixtures/04-switch-case.json +78 -0
  69. fancy_flow-0.1.0/tests/parity/fixtures/05-merge-after-decision.json +134 -0
  70. fancy_flow-0.1.0/tests/parity/fixtures/06-merge-concat.json +91 -0
  71. fancy_flow-0.1.0/tests/parity/fixtures/07-for-each.json +67 -0
  72. fancy_flow-0.1.0/tests/parity/fixtures/08-memory-store.json +72 -0
  73. fancy_flow-0.1.0/tests/parity/fixtures/09-data-store.json +83 -0
  74. fancy_flow-0.1.0/tests/parity/fixtures/10-variable-transform.json +54 -0
  75. fancy_flow-0.1.0/tests/parity/fixtures/11-llm-call.json +59 -0
  76. fancy_flow-0.1.0/tests/parity/fixtures/12-tool-use.json +58 -0
  77. fancy_flow-0.1.0/tests/parity/fixtures/13-embed-search.json +56 -0
  78. fancy_flow-0.1.0/tests/parity/fixtures/14-api-request.json +63 -0
  79. fancy_flow-0.1.0/tests/parity/fixtures/15-webhook-out.json +65 -0
  80. fancy_flow-0.1.0/tests/parity/fixtures/16-schedule-trigger.json +59 -0
  81. fancy_flow-0.1.0/tests/parity/fixtures/17-user-input.json +56 -0
  82. fancy_flow-0.1.0/tests/parity/fixtures/18-human-approval.json +86 -0
  83. fancy_flow-0.1.0/tests/parity/fixtures/19-wait.json +59 -0
  84. fancy_flow-0.1.0/tests/parity/fixtures/20-subgraph.json +88 -0
  85. fancy_flow-0.1.0/tests/parity/fixtures/21-cycle.json +51 -0
  86. fancy_flow-0.1.0/tests/parity/fixtures/22-unknown-kind.json +26 -0
  87. fancy_flow-0.1.0/tests/parity/fixtures/23-merge-same-handle.json +111 -0
  88. fancy_flow-0.1.0/tests/parity/fixtures/SOURCE.md +41 -0
  89. fancy_flow-0.1.0/tests/parity/test_durable_driver_parity.py +158 -0
  90. fancy_flow-0.1.0/tests/parity/test_fixture_provenance.py +90 -0
  91. fancy_flow-0.1.0/tests/parity/test_graph_fixtures.py +86 -0
  92. fancy_flow-0.1.0/tests/unit/__init__.py +0 -0
  93. fancy_flow-0.1.0/tests/unit/test_durable.py +381 -0
  94. fancy_flow-0.1.0/tests/unit/test_engine.py +408 -0
  95. fancy_flow-0.1.0/tests/unit/test_marketplace.py +156 -0
  96. fancy_flow-0.1.0/tests/unit/test_nodes.py +383 -0
  97. fancy_flow-0.1.0/tests/unit/test_registries.py +275 -0
  98. fancy_flow-0.1.0/tests/unit/test_run_identity.py +321 -0
  99. fancy_flow-0.1.0/tests/unit/test_security.py +165 -0
  100. fancy_flow-0.1.0/tests/unit/test_workflow_and_expr.py +227 -0
@@ -0,0 +1,13 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .pytest_cache/
4
+ .ruff_cache/
5
+ .mypy_cache/
6
+ .coverage
7
+ htmlcov/
8
+ dist/
9
+ build/
10
+ *.egg-info/
11
+ .venv/
12
+ venv/
13
+ .env
@@ -0,0 +1,196 @@
1
+ # AGENTS.md — fancy-flow-py
2
+
3
+ Python runtime for `fancy-flow` workflow graphs. The framework-free twin of
4
+ `@particle-academy/fancy-flow`'s TypeScript engine and of
5
+ `particle-academy/fancy-flow-php`. `CLAUDE.md` symlinks here.
6
+
7
+ This file describes **this package's code**. Process rules — publishing, kit
8
+ versioning, backports, the issue protocol — live in the envelope's `AGENTS.md`
9
+ and are deliberately not repeated.
10
+
11
+ ## What this package is
12
+
13
+ A faithful **port**, not a redesign. Behaviour questions are settled against the
14
+ peers, in this order: `@particle-academy/fancy-flow`'s `src/runtime/run-flow.ts`
15
+ and `src/registry/*` for the contract, `particle-academy/fancy-flow-php` for how
16
+ a *server* twin realises it.
17
+
18
+ The guarantee: **same `WorkflowSchema` JSON in, same `RunResult.outputs` out**
19
+ on Node, PHP and Python. Don't break it.
20
+
21
+ Where this port deliberately differs from a peer, the difference is in a
22
+ docstring at the point of divergence and in the envelope plan. There are three
23
+ as of 0.1.0, and all three are listed under "Deliberate divergences" below.
24
+
25
+ ## Architecture
26
+
27
+ Pure core, `src/` layout, **zero runtime dependencies**.
28
+
29
+ - `workflow.py` — import / export / validate WorkflowSchema v1.
30
+ - `engine/runner.py` — `FlowRunner`, the `runFlow` port. Kahn topo, ports,
31
+ branching, cycles, timeout, resume. **A node runs when ≥1 incoming edge is
32
+ active** (merge-after-decision, `#1`) and `_collect_inputs` reads only active
33
+ edges. Don't regress either.
34
+ - `registry/` — `NodeKindRegistry`, `NodeKind`, `ConfigField`, `kind_id`, and
35
+ `builtin` (the 24 authorable kinds, plus structural `note` and `subgraph`,
36
+ and a default executor for each one that executes -- `note` never does).
37
+ - `executors.py` — `ExecutorRegistry`; resolves node id → kind → `*`.
38
+ - `runtime/` — `RunEvent`, `RunOptions`, `RunResult`, `ExecutionContext`,
39
+ `Port`, `Pause`, `AbortSignal`, `RunIdentity`.
40
+ **`RunIdentity` is what a WRITING node keys an idempotent call on.**
41
+ `ctx.run.step_key(ctx.node.id)` is the same on every retry of one step and
42
+ different for every other execution of the same node — which is why `attempt`
43
+ is carried on it and is NOT part of the key. `(run, node)` alone is
44
+ insufficient: a node runs once per subflow invocation and once per loop
45
+ iteration, so the key composes the invocation `path` and an optional
46
+ `occurrence`. `is_replay_safe()` says whether a retry is still inside the
47
+ provider's dedup window; past it the caller must REFUSE, because resending the
48
+ key and minting a fresh one both write twice. Pinned by
49
+ `shared/flow-run-identity`; the other two runtimes implement the same table.
50
+ - `nodes/` — the default executors by domain, plus `nodes/support/` (injectable
51
+ client protocols, offline fakes, the `{{ }}` resolver, structured output).
52
+ - `capabilities/` — the HOST seam: `LlmClient` (`choose_route`, used by
53
+ `llm_router`) and `WorkflowResolver` (used by `subflow`).
54
+ - `durable/` — resumable runs with **no queue library**: the claim contract, the
55
+ frontier, per-node replay, retry policy, human gates, and the coordinator.
56
+ - `security/policy.py` — `GraphPolicy`, for a graph that arrived over the wire.
57
+ - `marketplace/manifest.py` — node-manifest validation and `satisfies_range`.
58
+
59
+ ### The engine is one walk, driven two ways
60
+
61
+ TypeScript executors may be `async`; PHP's are synchronous. Python has both, and
62
+ writing the loop twice would put two copies of the routing rules in the file
63
+ that exists to have exactly one.
64
+
65
+ So `FlowRunner._walk` is a **generator**: it yields a node to execute and is sent
66
+ the outcome back. `run()` drives it synchronously; `arun()` drives the same
67
+ generator while awaiting awaitable results. Add behaviour to `_walk`, never to a
68
+ driver.
69
+
70
+ `run()` refuses an awaitable rather than storing it. A coroutine object sitting
71
+ in `outputs[node]` looks like success and reaches every downstream node as a
72
+ value nothing can read.
73
+
74
+ ### The durable layer, and why it is not Temporal
75
+
76
+ Durability is **checkpoint-per-node, keyed by node id**, in the host's own
77
+ store — the same shape as `fancy-flow-php`'s `node_outputs`.
78
+
79
+ Event-sourced replay (Temporal, Restate) exists to police *arbitrary user code*
80
+ for non-determinism, and charges a sandbox, history-size limits and permanent
81
+ versioning for it. An interpreter over a declarative JSON graph is deterministic
82
+ by construction, so none of that is bought. Worse, checkpoint identity in those
83
+ systems is positional (DBOS keys on a call ordinal); ours is the node id, which
84
+ is what lets a graph be edited while a run is parked on an approval.
85
+
86
+ Three rules hold the per-node driver together, and each exists because the
87
+ alternative fails silently:
88
+
89
+ 1. **The engine is not reimplemented.** `durable/replay.py` executes a node by
90
+ replaying the graph *through* `FlowRunner` with completed nodes fed back as
91
+ `resume_outputs` and every other node fenced off with `bind_node`. Inputs,
92
+ branching and skips are therefore the engine's, not a driver's copy.
93
+ 2. **Activated ports come from the engine's own `node-output` events**, stored on
94
+ the claim row. `Frontier` reads them; it must never compute them. A second
95
+ copy of `_activated_ports` would agree for a year and then disagree on one
96
+ branch.
97
+ 3. **The claim is a unique constraint, not a check.** A lost race is a NO-OP.
98
+ `NodeClaimStore.claim()` must be atomic, and must let an owner re-enter its
99
+ OWN claim — that is what lets a retry resume instead of deadlocking against
100
+ the row it wrote itself, and it is also what makes the retry's idempotency
101
+ key identical to the first attempt's, since both read the same row.
102
+ 4. **`NodeState.first_attempt_at` is the retry clock and must never move.** It
103
+ is stamped on the first claim only. A store that refreshed it per attempt
104
+ would report a retry 25 hours late as seconds old, and a connector would
105
+ reuse a key the provider forgot yesterday.
106
+
107
+ A failed node whose attempts are not exhausted is left **CLAIMED**, never
108
+ recorded FAILED: a FAILED node settles, and settling one mid-retry skips
109
+ everything downstream while the run reports a tidy finish. `NodeOutcome.retryable`
110
+ is how a queue adapter learns to re-dispatch — with the SAME owner token.
111
+
112
+ `Coordinator.run_to_completion()` drives both operations in-process. It is a real
113
+ durable runner over a persistent store, not a stand-in for one.
114
+
115
+ ### Kind ids
116
+
117
+ Canonical ids are `@particle-academy/<name>`; old spellings live on as aliases.
118
+ **Anything keyed by kind name must key on EVERY id a kind answers to** —
119
+ registry lookups, executor bindings, retry overrides and `GraphPolicy` all do.
120
+ Getting this wrong once cost a human gate: a durable override bound under the
121
+ bare name only never matched a node saved as `@particle-academy/user_input`, so
122
+ the run went straight past the person it was meant to stop for, and nothing
123
+ errored.
124
+
125
+ ## Deliberate divergences
126
+
127
+ Three, all tested and all recorded in `.ai/plans/fancy-flow-py.md`:
128
+
129
+ 1. **`GraphPolicy.untrusted()` fails closed.** The PHP twin returns a policy
130
+ whose allowlist is *absent*, and an absent allowlist permits every kind — so
131
+ a caller who forgets `allowKinds()` gets size caps and byte hygiene from a
132
+ method named `untrusted`. Here the allowlist starts empty. No correctly
133
+ configured policy changes verdict.
134
+ 2. **No LLM auto-detection.** PHP probes for Prism / laravel-ai with
135
+ `class_exists()`, which is free. The Python equivalent is importing a
136
+ candidate to find out whether it is there: side effects, start-up cost, and a
137
+ provider the author never named. A missing client aborts with
138
+ `llm_unavailable_message()` instead.
139
+ 3. **`{{ x.length }}` on a list is `None`.** JavaScript resolves it because
140
+ arrays carry a `length` property; PHP does not, and neither does this. The
141
+ shared table has no row for it — promoting one is in the plan.
142
+
143
+ ## Parity is a test result, not a claim
144
+
145
+ - `tests/conformance/` runs `shared/expr` **and** `shared/satisfies-range` from
146
+ `particle-academy/fancy-conformance` through `tests/conformance/loader.py`.
147
+ A missing conformance checkout is a **failure**, never a skip.
148
+ - `tests/parity/test_graph_fixtures.py` runs the 23 golden `WorkflowSchema`
149
+ fixtures. Read `tests/parity/fixtures/SOURCE.md` before touching them: they
150
+ are a COPY of the PHP twin's, because those goldens are not in the shared
151
+ package and nothing on the Node side runs them at all.
152
+ - `tests/parity/test_fixture_provenance.py` fails if that copy has drifted from
153
+ the twin's, whenever the twin is on disk.
154
+ - `tests/parity/test_durable_driver_parity.py` runs **every fixture through the
155
+ per-node durable driver too** and requires the same answer. That is what pins
156
+ "what is unblocked?" against "what is next?".
157
+
158
+ `tests/conformance/loader.py` is a **third loader for a package that ships two**.
159
+ It belongs in `fancy-conformance`; until it lands there, treat this file as a
160
+ bridge and keep it behaviourally identical to the Node and PHP loaders.
161
+
162
+ ## Conventions
163
+
164
+ - **Python 3.11 floor.** Frozen dataclasses, `Protocol` over base classes,
165
+ `X | None`.
166
+ - **No runtime dependencies in the core.** Injectable protocols with offline
167
+ defaults, never a hard dep. An adapter takes its dependency behind an extra.
168
+ - **Faithfulness first.** If in doubt, match `run-flow.ts` semantics and add a
169
+ fixture. Divergence from the TS *code* (never the TS *contract*) gets a
170
+ docstring at the point of divergence.
171
+ - **Regenerate fixtures deliberately.** They are golden files; only regenerate
172
+ when behaviour legitimately changes, and eyeball the diff.
173
+
174
+ ## Commands
175
+
176
+ ```bash
177
+ python -m pip install -e . --group dev
178
+ pytest # unit + parity + conformance
179
+ pytest tests/unit/test_engine.py # one file
180
+ ruff check . && ruff format --check .
181
+ mypy
182
+ ```
183
+
184
+ `FANCY_CONFORMANCE_ROOT` and `FANCY_FLOW_PHP_FIXTURES` override the sibling-repo
185
+ discovery when the checkouts are somewhere unusual.
186
+
187
+ ## Status
188
+
189
+ **0.1.0 — core parity, unreleased.** The engine, the registries, the 24 built-in
190
+ kinds plus structural `note` / `subgraph` and their executors, `{{ }}`,
191
+ capabilities, the node manifest, `GraphPolicy`, and the durable core (claims /
192
+ frontier / replay / retries / human gates / coordinator) are built and tested.
193
+
194
+ **Not built, and staged in the plan:** queue adapters (Celery, Dramatiq, Taskiq,
195
+ Procrastinate), a persistent `NodeClaimStore`, a web-framework integration, the
196
+ `agent` executor, and the Human+ / MCP surface.
@@ -0,0 +1,133 @@
1
+ # Changelog
2
+
3
+ All notable changes to `fancy-flow` (Python) are documented here, in
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format.
5
+
6
+ This package is pre-1.0, so **breaking changes land in MINOR releases**. The
7
+ version number is not a promise it can yet keep; the entries are.
8
+
9
+ ## [Unreleased]
10
+
11
+ ### Added
12
+
13
+ - **`RunIdentity` on the execution context, so a node that WRITES can send an
14
+ idempotency key.** `ctx.run` is new, and `ctx.run.step_key(ctx.node.id)` is
15
+ the key.
16
+
17
+ Nothing in `{node, inputs, emit, depth}` could produce a key that is the same
18
+ on a retry and different on a different execution, so a writing connector had
19
+ to declare `unsafe-to-replay` and send none — meaning a timed-out payment
20
+ could never be retried, because retrying it would charge the card twice.
21
+
22
+ What identifies a step is deliberately **not** `(run, node)`: a node
23
+ legitimately runs many times in one run, once per subflow invocation and once
24
+ per loop iteration. The key is the run key plus the *path of invocations* that
25
+ led here plus an optional occurrence — `run_9f2c:billing/pay#3` — and
26
+ `attempt` is carried on the identity but **deliberately absent from the key**.
27
+
28
+ `is_replay_safe(window_seconds, now)` answers the other half: providers forget
29
+ keys (Stripe after 24h), and past that window resending the key and minting a
30
+ fresh one BOTH write twice, so the caller must refuse. Attempt 1 is always
31
+ safe, which is what lets a run park on a human gate for a week and then write.
32
+
33
+ Pinned across Python, TypeScript and PHP by `shared/flow-run-identity` in
34
+ `fancy-conformance` (25 rows).
35
+
36
+ ### Fixed
37
+
38
+ - **`Coordinator.retry` was wired to nothing.** `RetryPolicy` was a declared
39
+ field with **no read site anywhere in the package** — so a host constructing
40
+ `Coordinator(retry=RetryPolicy(tries=3))` got exactly one attempt per node and
41
+ no error to say otherwise. `unsafe-to-replay` appeared to be honoured only
42
+ because nothing retried at all.
43
+
44
+ `run_to_completion()` now consults the policy: a failed node whose attempts
45
+ are not exhausted is retried under the **same owner token**, so it re-enters
46
+ its own claim and derives the **same** step key — which is what makes the
47
+ retry idempotent rather than duplicative. A node declaring `unsafe-to-replay`
48
+ is still pinned to one attempt whatever `tries` says.
49
+
50
+ `NodeOutcome` gains `attempt` and `retryable`. A failed-but-retryable node is
51
+ deliberately left CLAIMED rather than recorded FAILED: a FAILED node settles,
52
+ and settling one mid-retry skips everything downstream while the run reports a
53
+ tidy finish.
54
+
55
+ ### Changed
56
+
57
+ - **BREAKING (unreleased): `Coordinator.run_key` is now `Coordinator.run`**, and
58
+ takes a `RunIdentity` or a bare run-key string. *What to do:* rename the
59
+ keyword — `Coordinator(..., run="run_9f2c")`. `coordinator.run_key` still
60
+ reads the key.
61
+ - `NodeState` gains `first_attempt_at`, the retry clock. It is stamped on the
62
+ first claim and **must never move**: a store that refreshed it per attempt
63
+ would report a retry 25 hours late as seconds old, and a connector would reuse
64
+ a key the provider forgot yesterday. Adapters constructing `NodeState` get a
65
+ sane default and need no change.
66
+
67
+ ## [0.1.0] - unreleased
68
+
69
+ The first cut: the pure engine, the node contracts, the registries, and a
70
+ durable layer that needs no queue library.
71
+
72
+ ### Added
73
+
74
+ - **`FlowRunner`** — the port of `runFlow`. Kahn topological order, the three
75
+ port-activation conventions (`__port`, `branch`, declared outputs), cycle
76
+ detection, timeout, host abort, and `resume_outputs`.
77
+ - One graph walk, driven two ways: `run()` is synchronous, `arun()` awaits
78
+ awaitable executors. Both drive the same generator, so the routing rules
79
+ exist once.
80
+ - `run()` **refuses** an awaitable rather than storing a coroutine object that
81
+ would look like success and reach every downstream node.
82
+ - **WorkflowSchema v1** — `import_workflow` / `export_workflow` / `to_json`,
83
+ with `ImportIssue` diagnostics for unknown kinds, missing required config and
84
+ dangling edges.
85
+ - **Registries** — `NodeKindRegistry` (kinds, aliases, config defaults and
86
+ validation) and `ExecutorRegistry` (node id → kind → `*`, alias-aware in both
87
+ directions, callables / objects / classes through a `Resolver`).
88
+ - **24 built-in kinds** across trigger / logic / data / ai / io / human / output,
89
+ plus structural `note` and `subgraph`, each with a framework-free default
90
+ executor and an offline client.
91
+ - **`{{ }}` expressions** — dot-paths only, no evaluation. Scanned rather than
92
+ matched with a regex, so the linear-time property is structural rather than
93
+ pattern-dependent.
94
+ - **Capabilities** — `LlmClient` and `WorkflowResolver` as host seams, so core
95
+ ships an opinionated `llm_router` and `subflow` without any consumer
96
+ inheriting a provider SDK.
97
+ - **`fancy_flow.durable`** — resumable runs with no queue dependency:
98
+ `NodeClaimStore` (+ an in-memory reference), `Frontier`, per-node `replay`,
99
+ `RetryPolicy`, fail-closed human gates, and a `Coordinator` exposing the two
100
+ operations a queue adapter dispatches.
101
+ - **`GraphPolicy`** — kind allow/deny lists, size caps, byte hygiene and host
102
+ rules for a graph that arrived over the wire.
103
+ - **Node manifests** — `validate`, `check_runtime_support`,
104
+ `check_capabilities`, `satisfies_range`. The `runtimes` map is open, so a node
105
+ can declare a `py` backend with no change to any runtime's validator.
106
+ - **Parity suites** — `shared/expr` and `shared/satisfies-range` from
107
+ `particle-academy/fancy-conformance`; the 23 golden `WorkflowSchema` fixtures;
108
+ a provenance check against the PHP twin's copies; and every fixture run a
109
+ second time through the durable driver.
110
+
111
+ ### Changed
112
+
113
+ Three deliberate divergences from the PHP twin, each tested and each explained
114
+ at the point of divergence:
115
+
116
+ - **`GraphPolicy.untrusted()` fails closed.** The PHP twin's leaves the
117
+ allowlist *absent*, which permits every kind — so a caller who forgets
118
+ `allowKinds()` gets size caps and byte hygiene from a method named
119
+ `untrusted`. Here nothing is permitted until something is named.
120
+ **What you must do:** pass the kinds you permit, either as
121
+ `GraphPolicy.untrusted(allow=[...])` or with `.allow_kinds([...])`. Any policy
122
+ that already named its kinds is unaffected.
123
+ - **No LLM auto-detection.** PHP probes for Prism / laravel-ai with
124
+ `class_exists()`. The Python equivalent is importing a candidate to find out
125
+ whether it is installed. **What you must do:** call
126
+ `fancy_flow.capabilities.set_llm_client(...)`. A missing client aborts the
127
+ node with a message naming that call.
128
+ - **`{{ list.length }}` resolves to `None`,** matching PHP rather than
129
+ JavaScript. **What you must do:** nothing, unless a graph authored against the
130
+ Node runtime relied on it — in which case it was already broken on PHP.
131
+
132
+ [Unreleased]: https://github.com/Particle-Academy/fancy-flow-py/compare/v0.1.0...HEAD
133
+ [0.1.0]: https://github.com/Particle-Academy/fancy-flow-py/releases/tag/v0.1.0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) Particle Academy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,157 @@
1
+ Metadata-Version: 2.5
2
+ Name: fancy-flow
3
+ Version: 0.1.0
4
+ Summary: Python runtime for fancy-flow workflow graphs - the third twin of @particle-academy/fancy-flow's engine. Same WorkflowSchema JSON in, same outputs out.
5
+ Project-URL: Homepage, https://github.com/Particle-Academy/fancy-flow-py
6
+ Project-URL: Issues, https://github.com/Particle-Academy/fancy-flow-py/issues
7
+ Project-URL: Source, https://github.com/Particle-Academy/fancy-flow-py
8
+ Author: Particle Academy
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: agentic,ai,automation,dag,durable-execution,fancy-flow,flow,orchestration,particle-academy,workflow
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: Software Development :: Libraries
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.11
22
+ Description-Content-Type: text/markdown
23
+
24
+ # fancy-flow (Python)
25
+
26
+ The Python runtime for [`fancy-flow`](https://github.com/Particle-Academy/fancy-flow)
27
+ workflow graphs — the third twin of its headless TypeScript engine, alongside
28
+ [`fancy-flow-php`](https://github.com/Particle-Academy/fancy-flow-php).
29
+
30
+ > A graph an agent or human authors in `<FlowEditor>` runs **unchanged** on
31
+ > Python. Same JSON in, same outputs out. The editor stays the one authoring
32
+ > surface; Python becomes a peer runtime alongside Node and PHP.
33
+
34
+ **Zero runtime dependencies.** Everything the built-in nodes reach for — HTTP,
35
+ an LLM, a vector store, a queue — is an injected protocol with a deterministic
36
+ offline default, so a workflow app that never calls a model does not inherit a
37
+ provider SDK, and every test runs without a network.
38
+
39
+ ```python
40
+ from fancy_flow import FlowRunner, RunOptions, builtin, import_workflow
41
+
42
+ builtin.register() # install the built-in kinds
43
+ result = import_workflow(schema_json) # WorkflowSchema v1
44
+
45
+ run = FlowRunner().run(
46
+ result.graph,
47
+ builtin.executors(), # or your own bindings
48
+ options=RunOptions(initial_inputs={"trigger-1": {"payload": body}}),
49
+ )
50
+
51
+ run.ok # bool
52
+ run.outputs # {node_id: value}
53
+ run.error # str | None
54
+ ```
55
+
56
+ ## Async, without two engines
57
+
58
+ Executors may be synchronous or `async`. The graph walk is written once and
59
+ driven two ways, so branching, skipping and port routing cannot drift between
60
+ them.
61
+
62
+ ```python
63
+ run = await FlowRunner().arun(graph, executors) # awaits awaitable executors
64
+ ```
65
+
66
+ The synchronous runner **refuses** an awaitable rather than storing it: a
67
+ coroutine object in `outputs` looks like success and reaches every downstream
68
+ node as a value nothing can read.
69
+
70
+ ## Custom nodes
71
+
72
+ Two halves, kept in sync — exactly the path every built-in takes.
73
+
74
+ ```python
75
+ from fancy_flow import ConfigField, NodeKind, ExecutorRegistry, default_registry
76
+
77
+ default_registry().register(
78
+ NodeKind(
79
+ name="@acme/send_invoice",
80
+ category="io",
81
+ label="Send invoice",
82
+ aliases=("send_invoice",),
83
+ config_schema=(ConfigField(type="text", key="to", label="To", required=True),),
84
+ side_effects="unsafe-to-replay", # a durable run gives this ONE attempt
85
+ )
86
+ )
87
+
88
+
89
+ def send_invoice(ctx):
90
+ return {"sent": ctx.option("to")}
91
+
92
+
93
+ executors = builtin.executors().bind("@acme/send_invoice", send_invoice)
94
+ ```
95
+
96
+ An executor may be a callable, an object with `.execute(ctx)`, or a class
97
+ resolved through your container.
98
+
99
+ ## Durable runs, with no queue library
100
+
101
+ Durability is **checkpoint-per-node, keyed by node id**. The core owns the hard
102
+ part — which node may run, and with what inputs — and a queue supplies transport
103
+ and nothing else.
104
+
105
+ ```python
106
+ from fancy_flow.durable import Coordinator
107
+
108
+ flow = Coordinator(graph=graph, executors=executors, run=run_id, store=store)
109
+
110
+ ready = flow.advance() # what is unblocked right now -> dispatch these
111
+ flow.run_node(node_id) # claim, run through the real engine, checkpoint
112
+ flow.run_to_completion() # or drive both, here, in this process
113
+ ```
114
+
115
+ `advance()` and `run_node()` are the two operations a Celery / Dramatiq / Taskiq
116
+ job wraps. `Coordinator.run_to_completion()` over a persistent `NodeClaimStore`
117
+ is already a real durable runner: a crash resumes from the same place a crashed
118
+ worker would, because the resume behaviour lives in the checkpoints rather than
119
+ in the loop.
120
+
121
+ Human gates **fail closed**: `user_input` and `human_approval` pause because
122
+ they *are* human nodes, not because their input port happens to be empty. Only a
123
+ recorded answer for that node resumes the run.
124
+
125
+ ## Accepting a graph you did not write
126
+
127
+ `import_workflow` answers *is this graph coherent?* `GraphPolicy` answers *is it
128
+ safe to accept?* — kind allowlists (resolved across every id a kind answers to),
129
+ size caps, byte hygiene, structure, and host rules.
130
+
131
+ ```python
132
+ from fancy_flow.security import GraphPolicy
133
+
134
+ GraphPolicy.untrusted(allow=["manual_trigger", "transform", "output"]).assert_safe(schema)
135
+ ```
136
+
137
+ ## Parity
138
+
139
+ The guarantee is asserted, not asserted-to. The suite runs the shared
140
+ `shared/expr` and `shared/satisfies-range` tables from
141
+ [`fancy-conformance`](https://github.com/Particle-Academy/fancy-conformance),
142
+ the 23 golden `WorkflowSchema` fixtures, and — because a queued run derives
143
+ readiness from the opposite end — every one of those fixtures a second time
144
+ through the per-node durable driver.
145
+
146
+ ```bash
147
+ python -m pip install -e . --group dev
148
+ pytest
149
+ ```
150
+
151
+ ## Status
152
+
153
+ Pre-1.0: breaking changes land in minor releases. See
154
+ [`CHANGELOG.md`](CHANGELOG.md) and, for how the package is built and what is
155
+ staged next, `AGENTS.md`.
156
+
157
+ MIT.
@@ -0,0 +1,134 @@
1
+ # fancy-flow (Python)
2
+
3
+ The Python runtime for [`fancy-flow`](https://github.com/Particle-Academy/fancy-flow)
4
+ workflow graphs — the third twin of its headless TypeScript engine, alongside
5
+ [`fancy-flow-php`](https://github.com/Particle-Academy/fancy-flow-php).
6
+
7
+ > A graph an agent or human authors in `<FlowEditor>` runs **unchanged** on
8
+ > Python. Same JSON in, same outputs out. The editor stays the one authoring
9
+ > surface; Python becomes a peer runtime alongside Node and PHP.
10
+
11
+ **Zero runtime dependencies.** Everything the built-in nodes reach for — HTTP,
12
+ an LLM, a vector store, a queue — is an injected protocol with a deterministic
13
+ offline default, so a workflow app that never calls a model does not inherit a
14
+ provider SDK, and every test runs without a network.
15
+
16
+ ```python
17
+ from fancy_flow import FlowRunner, RunOptions, builtin, import_workflow
18
+
19
+ builtin.register() # install the built-in kinds
20
+ result = import_workflow(schema_json) # WorkflowSchema v1
21
+
22
+ run = FlowRunner().run(
23
+ result.graph,
24
+ builtin.executors(), # or your own bindings
25
+ options=RunOptions(initial_inputs={"trigger-1": {"payload": body}}),
26
+ )
27
+
28
+ run.ok # bool
29
+ run.outputs # {node_id: value}
30
+ run.error # str | None
31
+ ```
32
+
33
+ ## Async, without two engines
34
+
35
+ Executors may be synchronous or `async`. The graph walk is written once and
36
+ driven two ways, so branching, skipping and port routing cannot drift between
37
+ them.
38
+
39
+ ```python
40
+ run = await FlowRunner().arun(graph, executors) # awaits awaitable executors
41
+ ```
42
+
43
+ The synchronous runner **refuses** an awaitable rather than storing it: a
44
+ coroutine object in `outputs` looks like success and reaches every downstream
45
+ node as a value nothing can read.
46
+
47
+ ## Custom nodes
48
+
49
+ Two halves, kept in sync — exactly the path every built-in takes.
50
+
51
+ ```python
52
+ from fancy_flow import ConfigField, NodeKind, ExecutorRegistry, default_registry
53
+
54
+ default_registry().register(
55
+ NodeKind(
56
+ name="@acme/send_invoice",
57
+ category="io",
58
+ label="Send invoice",
59
+ aliases=("send_invoice",),
60
+ config_schema=(ConfigField(type="text", key="to", label="To", required=True),),
61
+ side_effects="unsafe-to-replay", # a durable run gives this ONE attempt
62
+ )
63
+ )
64
+
65
+
66
+ def send_invoice(ctx):
67
+ return {"sent": ctx.option("to")}
68
+
69
+
70
+ executors = builtin.executors().bind("@acme/send_invoice", send_invoice)
71
+ ```
72
+
73
+ An executor may be a callable, an object with `.execute(ctx)`, or a class
74
+ resolved through your container.
75
+
76
+ ## Durable runs, with no queue library
77
+
78
+ Durability is **checkpoint-per-node, keyed by node id**. The core owns the hard
79
+ part — which node may run, and with what inputs — and a queue supplies transport
80
+ and nothing else.
81
+
82
+ ```python
83
+ from fancy_flow.durable import Coordinator
84
+
85
+ flow = Coordinator(graph=graph, executors=executors, run=run_id, store=store)
86
+
87
+ ready = flow.advance() # what is unblocked right now -> dispatch these
88
+ flow.run_node(node_id) # claim, run through the real engine, checkpoint
89
+ flow.run_to_completion() # or drive both, here, in this process
90
+ ```
91
+
92
+ `advance()` and `run_node()` are the two operations a Celery / Dramatiq / Taskiq
93
+ job wraps. `Coordinator.run_to_completion()` over a persistent `NodeClaimStore`
94
+ is already a real durable runner: a crash resumes from the same place a crashed
95
+ worker would, because the resume behaviour lives in the checkpoints rather than
96
+ in the loop.
97
+
98
+ Human gates **fail closed**: `user_input` and `human_approval` pause because
99
+ they *are* human nodes, not because their input port happens to be empty. Only a
100
+ recorded answer for that node resumes the run.
101
+
102
+ ## Accepting a graph you did not write
103
+
104
+ `import_workflow` answers *is this graph coherent?* `GraphPolicy` answers *is it
105
+ safe to accept?* — kind allowlists (resolved across every id a kind answers to),
106
+ size caps, byte hygiene, structure, and host rules.
107
+
108
+ ```python
109
+ from fancy_flow.security import GraphPolicy
110
+
111
+ GraphPolicy.untrusted(allow=["manual_trigger", "transform", "output"]).assert_safe(schema)
112
+ ```
113
+
114
+ ## Parity
115
+
116
+ The guarantee is asserted, not asserted-to. The suite runs the shared
117
+ `shared/expr` and `shared/satisfies-range` tables from
118
+ [`fancy-conformance`](https://github.com/Particle-Academy/fancy-conformance),
119
+ the 23 golden `WorkflowSchema` fixtures, and — because a queued run derives
120
+ readiness from the opposite end — every one of those fixtures a second time
121
+ through the per-node durable driver.
122
+
123
+ ```bash
124
+ python -m pip install -e . --group dev
125
+ pytest
126
+ ```
127
+
128
+ ## Status
129
+
130
+ Pre-1.0: breaking changes land in minor releases. See
131
+ [`CHANGELOG.md`](CHANGELOG.md) and, for how the package is built and what is
132
+ staged next, `AGENTS.md`.
133
+
134
+ MIT.