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.
- fancy_flow-0.1.0/.gitignore +13 -0
- fancy_flow-0.1.0/AGENTS.md +196 -0
- fancy_flow-0.1.0/CHANGELOG.md +133 -0
- fancy_flow-0.1.0/LICENSE +21 -0
- fancy_flow-0.1.0/PKG-INFO +157 -0
- fancy_flow-0.1.0/README.md +134 -0
- fancy_flow-0.1.0/pyproject.toml +116 -0
- fancy_flow-0.1.0/src/fancy_flow/__init__.py +97 -0
- fancy_flow-0.1.0/src/fancy_flow/capabilities/__init__.py +212 -0
- fancy_flow-0.1.0/src/fancy_flow/contracts.py +62 -0
- fancy_flow-0.1.0/src/fancy_flow/durable/__init__.py +51 -0
- fancy_flow-0.1.0/src/fancy_flow/durable/coordinator.py +330 -0
- fancy_flow-0.1.0/src/fancy_flow/durable/frontier.py +145 -0
- fancy_flow-0.1.0/src/fancy_flow/durable/human.py +138 -0
- fancy_flow-0.1.0/src/fancy_flow/durable/replay.py +126 -0
- fancy_flow-0.1.0/src/fancy_flow/durable/retry.py +78 -0
- fancy_flow-0.1.0/src/fancy_flow/durable/state.py +192 -0
- fancy_flow-0.1.0/src/fancy_flow/engine/__init__.py +5 -0
- fancy_flow-0.1.0/src/fancy_flow/engine/runner.py +399 -0
- fancy_flow-0.1.0/src/fancy_flow/exceptions.py +49 -0
- fancy_flow-0.1.0/src/fancy_flow/executors.py +189 -0
- fancy_flow-0.1.0/src/fancy_flow/marketplace/__init__.py +28 -0
- fancy_flow-0.1.0/src/fancy_flow/marketplace/manifest.py +425 -0
- fancy_flow-0.1.0/src/fancy_flow/nodes/__init__.py +9 -0
- fancy_flow-0.1.0/src/fancy_flow/nodes/ai.py +266 -0
- fancy_flow-0.1.0/src/fancy_flow/nodes/data.py +97 -0
- fancy_flow-0.1.0/src/fancy_flow/nodes/human.py +56 -0
- fancy_flow-0.1.0/src/fancy_flow/nodes/io_.py +61 -0
- fancy_flow-0.1.0/src/fancy_flow/nodes/logic.py +130 -0
- fancy_flow-0.1.0/src/fancy_flow/nodes/output.py +24 -0
- fancy_flow-0.1.0/src/fancy_flow/nodes/structural.py +240 -0
- fancy_flow-0.1.0/src/fancy_flow/nodes/support/__init__.py +36 -0
- fancy_flow-0.1.0/src/fancy_flow/nodes/support/clients.py +182 -0
- fancy_flow-0.1.0/src/fancy_flow/nodes/support/deps.py +39 -0
- fancy_flow-0.1.0/src/fancy_flow/nodes/support/expr.py +202 -0
- fancy_flow-0.1.0/src/fancy_flow/nodes/support/structured.py +221 -0
- fancy_flow-0.1.0/src/fancy_flow/nodes/trigger.py +44 -0
- fancy_flow-0.1.0/src/fancy_flow/py.typed +0 -0
- fancy_flow-0.1.0/src/fancy_flow/registry/__init__.py +21 -0
- fancy_flow-0.1.0/src/fancy_flow/registry/builtin.py +1029 -0
- fancy_flow-0.1.0/src/fancy_flow/registry/kind_id.py +78 -0
- fancy_flow-0.1.0/src/fancy_flow/registry/node_kind.py +203 -0
- fancy_flow-0.1.0/src/fancy_flow/registry/registry.py +160 -0
- fancy_flow-0.1.0/src/fancy_flow/runtime/__init__.py +24 -0
- fancy_flow-0.1.0/src/fancy_flow/runtime/abort.py +42 -0
- fancy_flow-0.1.0/src/fancy_flow/runtime/context.py +90 -0
- fancy_flow-0.1.0/src/fancy_flow/runtime/events.py +119 -0
- fancy_flow-0.1.0/src/fancy_flow/runtime/identity.py +202 -0
- fancy_flow-0.1.0/src/fancy_flow/runtime/options.py +63 -0
- fancy_flow-0.1.0/src/fancy_flow/runtime/pause.py +119 -0
- fancy_flow-0.1.0/src/fancy_flow/runtime/ports.py +30 -0
- fancy_flow-0.1.0/src/fancy_flow/schema/__init__.py +16 -0
- fancy_flow-0.1.0/src/fancy_flow/schema/graph.py +154 -0
- fancy_flow-0.1.0/src/fancy_flow/schema/issues.py +65 -0
- fancy_flow-0.1.0/src/fancy_flow/security/__init__.py +5 -0
- fancy_flow-0.1.0/src/fancy_flow/security/policy.py +333 -0
- fancy_flow-0.1.0/src/fancy_flow/workflow.py +207 -0
- fancy_flow-0.1.0/tests/__init__.py +0 -0
- fancy_flow-0.1.0/tests/conformance/__init__.py +0 -0
- fancy_flow-0.1.0/tests/conformance/loader.py +199 -0
- fancy_flow-0.1.0/tests/conformance/test_expr_conformance.py +78 -0
- fancy_flow-0.1.0/tests/conformance/test_range_conformance.py +54 -0
- fancy_flow-0.1.0/tests/conformance/test_run_identity_conformance.py +90 -0
- fancy_flow-0.1.0/tests/parity/__init__.py +0 -0
- fancy_flow-0.1.0/tests/parity/fixtures/01-manual-transform-output.json +70 -0
- fancy_flow-0.1.0/tests/parity/fixtures/02-webhook-branch-true-notify.json +106 -0
- fancy_flow-0.1.0/tests/parity/fixtures/03-branch-false-log.json +99 -0
- fancy_flow-0.1.0/tests/parity/fixtures/04-switch-case.json +78 -0
- fancy_flow-0.1.0/tests/parity/fixtures/05-merge-after-decision.json +134 -0
- fancy_flow-0.1.0/tests/parity/fixtures/06-merge-concat.json +91 -0
- fancy_flow-0.1.0/tests/parity/fixtures/07-for-each.json +67 -0
- fancy_flow-0.1.0/tests/parity/fixtures/08-memory-store.json +72 -0
- fancy_flow-0.1.0/tests/parity/fixtures/09-data-store.json +83 -0
- fancy_flow-0.1.0/tests/parity/fixtures/10-variable-transform.json +54 -0
- fancy_flow-0.1.0/tests/parity/fixtures/11-llm-call.json +59 -0
- fancy_flow-0.1.0/tests/parity/fixtures/12-tool-use.json +58 -0
- fancy_flow-0.1.0/tests/parity/fixtures/13-embed-search.json +56 -0
- fancy_flow-0.1.0/tests/parity/fixtures/14-api-request.json +63 -0
- fancy_flow-0.1.0/tests/parity/fixtures/15-webhook-out.json +65 -0
- fancy_flow-0.1.0/tests/parity/fixtures/16-schedule-trigger.json +59 -0
- fancy_flow-0.1.0/tests/parity/fixtures/17-user-input.json +56 -0
- fancy_flow-0.1.0/tests/parity/fixtures/18-human-approval.json +86 -0
- fancy_flow-0.1.0/tests/parity/fixtures/19-wait.json +59 -0
- fancy_flow-0.1.0/tests/parity/fixtures/20-subgraph.json +88 -0
- fancy_flow-0.1.0/tests/parity/fixtures/21-cycle.json +51 -0
- fancy_flow-0.1.0/tests/parity/fixtures/22-unknown-kind.json +26 -0
- fancy_flow-0.1.0/tests/parity/fixtures/23-merge-same-handle.json +111 -0
- fancy_flow-0.1.0/tests/parity/fixtures/SOURCE.md +41 -0
- fancy_flow-0.1.0/tests/parity/test_durable_driver_parity.py +158 -0
- fancy_flow-0.1.0/tests/parity/test_fixture_provenance.py +90 -0
- fancy_flow-0.1.0/tests/parity/test_graph_fixtures.py +86 -0
- fancy_flow-0.1.0/tests/unit/__init__.py +0 -0
- fancy_flow-0.1.0/tests/unit/test_durable.py +381 -0
- fancy_flow-0.1.0/tests/unit/test_engine.py +408 -0
- fancy_flow-0.1.0/tests/unit/test_marketplace.py +156 -0
- fancy_flow-0.1.0/tests/unit/test_nodes.py +383 -0
- fancy_flow-0.1.0/tests/unit/test_registries.py +275 -0
- fancy_flow-0.1.0/tests/unit/test_run_identity.py +321 -0
- fancy_flow-0.1.0/tests/unit/test_security.py +165 -0
- fancy_flow-0.1.0/tests/unit/test_workflow_and_expr.py +227 -0
|
@@ -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
|
fancy_flow-0.1.0/LICENSE
ADDED
|
@@ -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.
|