pylinxi 1.0.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.
pylinxi-1.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 pylinxi contributors
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.
pylinxi-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,316 @@
1
+ Metadata-Version: 2.4
2
+ Name: pylinxi
3
+ Version: 1.0.0
4
+ Summary: Durable, self-healing agent flows as plain Python. Zero dependencies.
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/karawash-projects/pylinxi
7
+ Project-URL: Repository, https://github.com/karawash-projects/pylinxi
8
+ Project-URL: Issues, https://github.com/karawash-projects/pylinxi/issues
9
+ Project-URL: Changelog, https://github.com/karawash-projects/pylinxi/blob/main/CHANGELOG.md
10
+ Keywords: agents,llm,workflow,durable-execution,langgraph-alternative,orchestration
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Framework :: AsyncIO
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.9
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Provides-Extra: postgres
25
+ Requires-Dist: psycopg[binary]>=3.1; extra == "postgres"
26
+ Provides-Extra: otel
27
+ Requires-Dist: opentelemetry-api>=1.20; extra == "otel"
28
+ Provides-Extra: dev
29
+ Requires-Dist: psycopg[binary]>=3.1; extra == "dev"
30
+ Requires-Dist: opentelemetry-sdk>=1.20; extra == "dev"
31
+ Requires-Dist: ruff>=0.5; extra == "dev"
32
+ Requires-Dist: mypy>=1.10; extra == "dev"
33
+ Requires-Dist: build; extra == "dev"
34
+ Dynamic: license-file
35
+
36
+ # pylinxi
37
+
38
+ [![ci](https://github.com/karawash-projects/pylinxi/actions/workflows/ci.yml/badge.svg)](https://github.com/karawash-projects/pylinxi/actions/workflows/ci.yml)
39
+
40
+ Durable, self-healing agent flows written as **plain Python**. No graph to wire. The core has zero dependencies; Postgres and OpenTelemetry are optional extras. Python 3.9+.
41
+
42
+ ```python
43
+ import asyncio
44
+ from pylinxi import flow, step, idempotency_key, Policy, Retry, SQLiteJournal
45
+
46
+ @step(policy=Policy(retry=Retry(attempts=4), validate=is_json, repair=feed_error_back))
47
+ async def classify(message):
48
+ return await llm(f"Classify: {message}")
49
+
50
+ @step
51
+ def refund(order):
52
+ return billing.refund(order, idempotency_key=idempotency_key())
53
+
54
+ @flow(journal=SQLiteJournal("runs.db", secret=KEY))
55
+ async def support(ctx, message, order):
56
+ intent = await classify(message)
57
+ if intent == "refund": # branching is just `if`
58
+ charges, history = await asyncio.gather(lookup(order), past_tickets(order))
59
+ if not await ctx.wait_for("approval", timeout=86400, default=False):
60
+ return "escalated"
61
+ return await refund(order)
62
+ return await answer_faq(message)
63
+
64
+ r = await support.run("charged twice", "A-1001", run_id="t-1001") # suspends at approval
65
+ r = await support.resume("t-1001", signals={"approval": True}) # hours later, any process
66
+ ```
67
+
68
+ ```bash
69
+ pip install pylinxi # core
70
+ pip install "pylinxi[postgres,otel]" # production extras
71
+ ```
72
+
73
+ ---
74
+
75
+ ## Why
76
+
77
+ | Gap in graph frameworks | pylinxi |
78
+ |---|---|
79
+ | **Manual graph wiring** for branching | The flow *is* the code: `if`, `match`, loops, `asyncio.gather`. `ctx.route()` replaces conditional edges in one line. Steps are plain functions outside a flow, so they unit-test without a harness. |
80
+ | **Weak real-time self-healing** | One declarative policy per step: retry → validate → **repair** (rewrite inputs between attempts) → fallback → circuit breaker. Crashes heal by **resume**: finished steps replay from the journal. A `Worker` resumes due timers and transient failures fleet-wide. |
81
+ | **Heavy state serialization** | Append-only **delta** journal: each entry stores one step's result plus only the state keys that changed. Rebuild any point in time with `state_at()`. Long loops roll over with `continue_as_new()`. |
82
+ | **Enterprise security** | HMAC-signed hash chain with key rotation, automatic redaction, never-persisted sensitive steps, per-run locks, retention deletes, idempotency keys, OTel tracing, a `verify` CLI for audits. |
83
+
84
+ ---
85
+
86
+ ## Concepts in 60 seconds
87
+
88
+ - A **flow** is an `async def f(ctx, ...)` decorated with `@flow`. Run it with `await f.run(...)`.
89
+ - A **step** is any function decorated with `@step` (sync, async, or a generator for streaming). Calling it inside a flow journals its result.
90
+ - A **journal** stores each run as a signed list of entries. On `resume(run_id)` the flow function runs again from the top; completed steps return their recorded result instantly and execution continues at the first unfinished step.
91
+ - **The one rule:** code *outside* steps must be deterministic. Put I/O, LLM calls, randomness and clocks inside steps, or use `ctx.now()` / `ctx.uuid()`. If flow code changes incompatibly between a crash and a resume, pylinxi emits a `divergence` event; use `ctx.patched()` to change code safely.
92
+
93
+ ### Replay guarantees
94
+
95
+ - **Concurrency.** Tasks created by flow code (`asyncio.gather`, `create_task`, `TaskGroup`) each get their own key scope (`t0/`, `t1/`, …) in creation order, so steps inside concurrent branches replay correctly whichever branch finishes first. When a run suspends, fails or finishes, branches still running are cancelled before it returns, so they can't act twice.
96
+ - **Handled failures stay handled.** If flow code catches a `StepFailed` and carries on, resuming re-raises the same failure (wrapping a `ReplayedError`) instead of re-running the step. A failure that ended the run is retried on resume.
97
+ - **Nested steps.** A `@step` called from inside another step is a plain call; the outer step's result is the checkpoint.
98
+ - **Suspension can't be swallowed.** `Suspended` and `ContinueAsNew` derive from `BaseException`, so `except Exception` in flow code won't catch them.
99
+ - **One executor per run.** `run`/`resume` hold a per-run lock (in-process for every journal, cross-process for file/SQLite on POSIX, database locks for Postgres). A second caller gets `RunLocked`.
100
+ - **Inputs are journaled in clear** so runs can resume. pylinxi therefore refuses flow inputs, initial state, signals or `continue_as_new` payloads containing secret-looking keys; pass secrets by reference and load them in a `@step(sensitive=True)`.
101
+
102
+ ---
103
+
104
+ ## Self-healing
105
+
106
+ ```python
107
+ from pylinxi import Policy, Retry, CircuitBreaker
108
+
109
+ crm = CircuitBreaker(threshold=5, reset_after=30) # share one per dependency
110
+
111
+ @step(policy=Policy(
112
+ retry=Retry(attempts=4, backoff=0.5, factor=2, max_backoff=10, on=(ConnectionError, TimeoutError)),
113
+ timeout=20, # per attempt
114
+ validate=lambda out: "intent" in out, # False -> ValidationError -> retried
115
+ repair=lambda err, args, kwargs, attempt: ((args[0] + f"\nFix: {err}",), kwargs),
116
+ fallback=lambda *a, **k: {"intent": "unknown"}, # after retries, or when breaker is open
117
+ breaker=crm,
118
+ ))
119
+ def classify(prompt): ...
120
+ ```
121
+
122
+ | Field | Meaning |
123
+ |---|---|
124
+ | `retry` | `Retry(attempts, backoff, factor, max_backoff, jitter, on)`. Default: 1 attempt. |
125
+ | `timeout` | Seconds per attempt. Sync steps run in a thread, so a timed-out thread can't be killed; prefer async clients for hard timeouts. |
126
+ | `validate` | `fn(result) -> bool` (may be async). Validation failures are always retried. |
127
+ | `repair` | `fn(error, args, kwargs, attempt) -> (args, kwargs) | None` (may be async). Runs between attempts. |
128
+ | `fallback` | `fn(*args, **kwargs)` used when attempts are exhausted or the breaker is open. |
129
+ | `breaker` | `CircuitBreaker(threshold, reset_after)`; closed → open → half-open. |
130
+ | `aggregate` | Streaming steps: `fn(chunks) -> result`. Default joins strings, else keeps the list. |
131
+ | `thread` | Run plain sync steps in a worker thread (default `True`). |
132
+
133
+ Each completed step's journal entry records `attempts`, `healed` (`"retry"` / `"fallback"`) and the errors seen.
134
+
135
+ ### Fleet-level healing: `Worker`
136
+
137
+ ```python
138
+ from pylinxi import Worker
139
+
140
+ worker = Worker(journal, [support, onboarding], retry_failed=True, max_failures=5, backoff=30)
141
+ await worker.run_forever(interval=5) # or: report = await worker.tick()
142
+ ```
143
+
144
+ Each tick resumes runs whose timers are due and (optionally) runs that *ended* in an unhandled failure, with exponential backoff and a give-up limit. Failures your flow caught are left alone. One run that errors while resuming is reported in `TickReport.errors` and never blocks the others. Run several workers against a `PostgresJournal`; per-run locks make them skip runs another worker owns.
145
+
146
+ ---
147
+
148
+ ## Flow API (`ctx`)
149
+
150
+ | Call | What it does |
151
+ |---|---|
152
+ | `await my_step(...)` | Journaled call of a `@step` function. |
153
+ | `await ctx.step(fn, *args, name=, policy=, sensitive=)` | Journaled call of any callable (lambdas, third-party functions). |
154
+ | `await ctx.map(fn, items, concurrency=8)` | Parallel fan-out; each item is its own checkpoint. Siblings finish before an error surfaces, so a resume re-runs only failed items. |
155
+ | `await ctx.route(label, {"a": step_a, ...}, *args, default=)` | Dispatch by label. |
156
+ | `await ctx.wait_for(signal, timeout=None, default=None)` | Wait for `run/resume(..., signals={signal: value})`. Signals are journaled on delivery, so early ones aren't lost and repeats queue in order. With `timeout`, returns `default` once it expires. |
157
+ | `await ctx.sleep(seconds, inline_max=1.0)` | Durable sleep. Short waits sleep in-process; longer ones suspend and free the worker. |
158
+ | `await ctx.now()` / `await ctx.uuid()` | Clock / random id recorded once, identical on replay. |
159
+ | `ctx.patched("change-id")` | `True` for runs that reach this point on new code, `False` for runs already past it. |
160
+ | `ctx.continue_as_new(*args, **kwargs)` | End this run; continue as `run_id.g1`, `.g2`… with the current state. |
161
+ | `ctx.state` | Dict of flow state. Changes are journaled as deltas at the next checkpoint. |
162
+ | `ctx.run_id`, `ctx.generation`, `ctx.replaying` | Run metadata. |
163
+ | `pylinxi.idempotency_key()` | Inside a step: `"<run_id>:<step_key>"`, stable across retries and resumes. |
164
+
165
+ `@step(sensitive=True)` keeps a step's result out of the journal entirely; it is recomputed on resume. Use it for tokens and credentials.
166
+
167
+ ### Running
168
+
169
+ ```python
170
+ r = await support.run(*args, run_id="t-1", state={"tier": "gold"}, signals={...}, **kwargs)
171
+ r = await support.resume("t-1", signals={"approval": True})
172
+ r = support.run_sync(...) # outside an event loop
173
+
174
+ r.status # "completed" | "suspended" | "failed"
175
+ r.output, r.state, r.waiting_for, r.wake_at, r.error, r.events
176
+ ```
177
+
178
+ ### Streaming
179
+
180
+ Generator steps stream chunks as events; the journal stores only the aggregate.
181
+
182
+ ```python
183
+ @step
184
+ async def answer(prompt):
185
+ async for tok in llm.stream(prompt):
186
+ yield tok
187
+
188
+ @flow
189
+ async def chat(ctx, prompt):
190
+ return await answer(prompt)
191
+
192
+ async for ev in chat.stream("hello", run_id="chat-1"):
193
+ if ev["event"] == "chunk":
194
+ await websocket.send(ev["chunk"])
195
+ elif ev["event"] == "stream_reset": # a retry restarted the stream
196
+ await websocket.send({"reset": True})
197
+ elif ev["event"] == "result":
198
+ final = ev["result"] # RunResult
199
+ ```
200
+
201
+ ### Events
202
+
203
+ Pass `on_event=fn` (or a list) to `@flow`, `run`, or `resume`. Every event has `event`, `key`, `run_id`, `flow`:
204
+
205
+ `run_started`, `started`, `completed`, `replayed`, `attempt_failed`, `repaired`, `fallback`, `breaker_open`, `chunk`, `stream_reset`, `step_failed`, `step_crashed`, `signal_timeout`, `suspended`, `continued_as_new`, `divergence`, `failed`, `crashed`, `finished`.
206
+
207
+ A failing observer never breaks a run.
208
+
209
+ ---
210
+
211
+ ## Journals
212
+
213
+ ```python
214
+ from pylinxi import MemoryJournal, FileJournal, SQLiteJournal, open_journal
215
+ from pylinxi.postgres import PostgresJournal
216
+
217
+ j = open_journal("sqlite:///runs.db", secret={"2026-q3": k1, "2026-q4": k2})
218
+ ```
219
+
220
+ | Backend | Use for |
221
+ |---|---|
222
+ | `MemoryJournal()` | Tests. |
223
+ | `FileJournal(dir, fsync=True)` | One JSONL file per run; tolerant of torn writes after a crash. |
224
+ | `SQLiteJournal(path)` | Single machine (WAL mode). |
225
+ | `PostgresJournal(dsn, table="pylinxi_journal")` | Many workers. Appends run under a per-run advisory lock reading the tail from the database; `run`/`resume` hold a session lock so a second worker gets `RunLocked`. Payloads are `TEXT` so signatures verify byte-for-byte. |
226
+
227
+ Common methods: `load(run_id)`, `verify(run_id, require_signed=False)`, `state_at(run_id, seq=None)`, `runs()`, `runs_where_last(kind)`, `pending_timers(due_before=None)`, `delete(run_id)`, `close()` (also a context manager).
228
+
229
+ **Signing and rotation.** `secret=b"..."` or `{key_id: key}`. New entries are signed with `active_kid` (default: the last key). Old entries verify as long as their key id is still configured. `verify(..., require_signed=True)` also rejects unkeyed entries, so an attacker can't rewrite a run as an unkeyed chain.
230
+
231
+ **Integrity limits.** The chain detects edits, reordering and deletions *inside* a run. Dropping the newest entries can only be detected against an external anchor: store `journal.head(run_id)` elsewhere (an audit log, another database) and check with `verify(run_id, anchor=...)`. `verify` never raises; undecodable data makes it return `False`, and `load` raises `JournalCorrupt`.
232
+
233
+ **Concurrent writers.** Appends read the chain tail from storage under a storage-level lock (file lock, SQLite `BEGIN IMMEDIATE`, Postgres advisory lock), so any number of `Journal` instances or processes can share one store. A crash mid-write leaves a torn final line that the next append repairs.
234
+
235
+ **Redaction.** State deltas and metadata are redacted after encoding (so tuples, sets and dataclass fields are covered too). Key names are normalized (`apiKey`, `api-key` → `api_key`) and matched by word: `password`, `secret`, `token`, `api_key`, `access_key`, `private_key`, `auth`, `authorization`, `credential(s)`, `ssn`, `cookie`, `session_id` and similar — so `access_token` matches but `max_tokens` doesn't. Override with `redact_pattern=` (or `None`). Step *results* are not redacted (they are needed for replay); mark such steps `sensitive=True`. Validation errors never include the rejected value.
236
+
237
+ **Types.** The codec is lossless: tuples, sets, bytes, non-string dict keys, and dicts that happen to contain the internal tag key all round-trip exactly. Custom classes must be registered (`default_codec.register(MyDataclass)`, also pydantic models); unregistered ones raise instead of silently coming back as dicts on replay.
238
+
239
+ ---
240
+
241
+ ## OpenTelemetry
242
+
243
+ ```python
244
+ from pylinxi.otel import OTelExporter
245
+
246
+ @flow(journal=j, on_event=OTelExporter()) # record_errors=False to keep error text out of traces
247
+ ```
248
+
249
+ ```
250
+ pylinxi.run support status, waiting_for, events: pylinxi.replayed
251
+ ├─ pylinxi.step classify attempts=3 healed=retry, events: attempt_failed, repaired
252
+ ├─ pylinxi.step lookup_charges events: breaker_open, fallback
253
+ └─ pylinxi.step send ERROR status if it finally failed
254
+ ```
255
+
256
+ Uses only the OpenTelemetry API; configure any exporter (OTLP, Jaeger, Datadog, Honeycomb) in your app. Replayed steps appear as events, not spans, so traces show what actually executed.
257
+
258
+ ---
259
+
260
+ ## CLI
261
+
262
+ ```bash
263
+ export PYLINXI_JOURNAL=postgresql://app@db/agents PYLINXI_KEYS="2026-q3=...,2026-q4=..."
264
+
265
+ pylinxi runs --status failed # completed|continued|failed|sleeping|waiting|open
266
+ pylinxi show t-1001 # every entry: kind, key, tries, healed, changed state, result
267
+ pylinxi state t-1001 --seq 3 # time travel
268
+ pylinxi verify --all --require-signed # exit 1 on any failure: use in CI or audits
269
+ pylinxi timers --due
270
+ pylinxi delete t-1001 --yes # retention / right-to-erasure
271
+ pylinxi --json runs # machine-readable
272
+ ```
273
+
274
+ ---
275
+
276
+ ## Honest comparison with LangGraph
277
+
278
+ LangGraph is mature and also offers a functional API (`@entrypoint`/`@task`), retry policies, caching and many checkpointers, plus LangSmith and a hosted platform. Choose pylinxi when you want:
279
+
280
+ - a small surface (two decorators, no graph or reducer schema), zero core dependencies, and code you can read in an afternoon;
281
+ - per-step validate → repair → retry → fallback → breaker as one policy;
282
+ - delta journals with signing, rotation, redaction and a verify CLI built in;
283
+ - durable timers, `patched()` and `continue_as_new()` for long-lived agents.
284
+
285
+ Choose LangGraph when you need its integrations ecosystem, LangSmith's tracing UI, or a managed runtime.
286
+
287
+ On certifications: SOC 2 / ISO 27001 apply to organizations and hosted services, not libraries. pylinxi makes audits easier — signed journals, redaction, no network calls, no core dependencies to vet.
288
+
289
+ ---
290
+
291
+ ## Development
292
+
293
+ ```bash
294
+ git clone https://github.com/karawash-projects/pylinxi && cd pylinxi
295
+ pip install -e ".[dev]"
296
+ python -m unittest discover -s tests -t . # Postgres tests skip without a DSN
297
+ PYLINXI_PG_DSN=postgresql://user@localhost/db python -m unittest discover -s tests -t .
298
+ ruff check pylinxi tests examples && mypy pylinxi
299
+ python examples/support_agent.py
300
+ ```
301
+
302
+ CI runs lint, type checks, and the full suite with a Postgres service on Python 3.9–3.13.
303
+
304
+ ### Releasing
305
+
306
+ Bump `version` in `pyproject.toml` and `__version__` in `pylinxi/__init__.py`, add a `## X.Y.Z` section to `CHANGELOG.md`, then:
307
+
308
+ ```bash
309
+ git tag vX.Y.Z && git push origin vX.Y.Z
310
+ ```
311
+
312
+ (or on GitHub: **Releases → Draft a new release → Choose a tag → type `vX.Y.Z` → Create new tag → Publish release**.)
313
+
314
+ The `release` workflow checks the tag matches the version, runs the tests, builds, publishes to PyPI via trusted publishing (no API token), and creates a GitHub Release with the changelog notes and built files.
315
+
316
+ MIT licensed.
@@ -0,0 +1,281 @@
1
+ # pylinxi
2
+
3
+ [![ci](https://github.com/karawash-projects/pylinxi/actions/workflows/ci.yml/badge.svg)](https://github.com/karawash-projects/pylinxi/actions/workflows/ci.yml)
4
+
5
+ Durable, self-healing agent flows written as **plain Python**. No graph to wire. The core has zero dependencies; Postgres and OpenTelemetry are optional extras. Python 3.9+.
6
+
7
+ ```python
8
+ import asyncio
9
+ from pylinxi import flow, step, idempotency_key, Policy, Retry, SQLiteJournal
10
+
11
+ @step(policy=Policy(retry=Retry(attempts=4), validate=is_json, repair=feed_error_back))
12
+ async def classify(message):
13
+ return await llm(f"Classify: {message}")
14
+
15
+ @step
16
+ def refund(order):
17
+ return billing.refund(order, idempotency_key=idempotency_key())
18
+
19
+ @flow(journal=SQLiteJournal("runs.db", secret=KEY))
20
+ async def support(ctx, message, order):
21
+ intent = await classify(message)
22
+ if intent == "refund": # branching is just `if`
23
+ charges, history = await asyncio.gather(lookup(order), past_tickets(order))
24
+ if not await ctx.wait_for("approval", timeout=86400, default=False):
25
+ return "escalated"
26
+ return await refund(order)
27
+ return await answer_faq(message)
28
+
29
+ r = await support.run("charged twice", "A-1001", run_id="t-1001") # suspends at approval
30
+ r = await support.resume("t-1001", signals={"approval": True}) # hours later, any process
31
+ ```
32
+
33
+ ```bash
34
+ pip install pylinxi # core
35
+ pip install "pylinxi[postgres,otel]" # production extras
36
+ ```
37
+
38
+ ---
39
+
40
+ ## Why
41
+
42
+ | Gap in graph frameworks | pylinxi |
43
+ |---|---|
44
+ | **Manual graph wiring** for branching | The flow *is* the code: `if`, `match`, loops, `asyncio.gather`. `ctx.route()` replaces conditional edges in one line. Steps are plain functions outside a flow, so they unit-test without a harness. |
45
+ | **Weak real-time self-healing** | One declarative policy per step: retry → validate → **repair** (rewrite inputs between attempts) → fallback → circuit breaker. Crashes heal by **resume**: finished steps replay from the journal. A `Worker` resumes due timers and transient failures fleet-wide. |
46
+ | **Heavy state serialization** | Append-only **delta** journal: each entry stores one step's result plus only the state keys that changed. Rebuild any point in time with `state_at()`. Long loops roll over with `continue_as_new()`. |
47
+ | **Enterprise security** | HMAC-signed hash chain with key rotation, automatic redaction, never-persisted sensitive steps, per-run locks, retention deletes, idempotency keys, OTel tracing, a `verify` CLI for audits. |
48
+
49
+ ---
50
+
51
+ ## Concepts in 60 seconds
52
+
53
+ - A **flow** is an `async def f(ctx, ...)` decorated with `@flow`. Run it with `await f.run(...)`.
54
+ - A **step** is any function decorated with `@step` (sync, async, or a generator for streaming). Calling it inside a flow journals its result.
55
+ - A **journal** stores each run as a signed list of entries. On `resume(run_id)` the flow function runs again from the top; completed steps return their recorded result instantly and execution continues at the first unfinished step.
56
+ - **The one rule:** code *outside* steps must be deterministic. Put I/O, LLM calls, randomness and clocks inside steps, or use `ctx.now()` / `ctx.uuid()`. If flow code changes incompatibly between a crash and a resume, pylinxi emits a `divergence` event; use `ctx.patched()` to change code safely.
57
+
58
+ ### Replay guarantees
59
+
60
+ - **Concurrency.** Tasks created by flow code (`asyncio.gather`, `create_task`, `TaskGroup`) each get their own key scope (`t0/`, `t1/`, …) in creation order, so steps inside concurrent branches replay correctly whichever branch finishes first. When a run suspends, fails or finishes, branches still running are cancelled before it returns, so they can't act twice.
61
+ - **Handled failures stay handled.** If flow code catches a `StepFailed` and carries on, resuming re-raises the same failure (wrapping a `ReplayedError`) instead of re-running the step. A failure that ended the run is retried on resume.
62
+ - **Nested steps.** A `@step` called from inside another step is a plain call; the outer step's result is the checkpoint.
63
+ - **Suspension can't be swallowed.** `Suspended` and `ContinueAsNew` derive from `BaseException`, so `except Exception` in flow code won't catch them.
64
+ - **One executor per run.** `run`/`resume` hold a per-run lock (in-process for every journal, cross-process for file/SQLite on POSIX, database locks for Postgres). A second caller gets `RunLocked`.
65
+ - **Inputs are journaled in clear** so runs can resume. pylinxi therefore refuses flow inputs, initial state, signals or `continue_as_new` payloads containing secret-looking keys; pass secrets by reference and load them in a `@step(sensitive=True)`.
66
+
67
+ ---
68
+
69
+ ## Self-healing
70
+
71
+ ```python
72
+ from pylinxi import Policy, Retry, CircuitBreaker
73
+
74
+ crm = CircuitBreaker(threshold=5, reset_after=30) # share one per dependency
75
+
76
+ @step(policy=Policy(
77
+ retry=Retry(attempts=4, backoff=0.5, factor=2, max_backoff=10, on=(ConnectionError, TimeoutError)),
78
+ timeout=20, # per attempt
79
+ validate=lambda out: "intent" in out, # False -> ValidationError -> retried
80
+ repair=lambda err, args, kwargs, attempt: ((args[0] + f"\nFix: {err}",), kwargs),
81
+ fallback=lambda *a, **k: {"intent": "unknown"}, # after retries, or when breaker is open
82
+ breaker=crm,
83
+ ))
84
+ def classify(prompt): ...
85
+ ```
86
+
87
+ | Field | Meaning |
88
+ |---|---|
89
+ | `retry` | `Retry(attempts, backoff, factor, max_backoff, jitter, on)`. Default: 1 attempt. |
90
+ | `timeout` | Seconds per attempt. Sync steps run in a thread, so a timed-out thread can't be killed; prefer async clients for hard timeouts. |
91
+ | `validate` | `fn(result) -> bool` (may be async). Validation failures are always retried. |
92
+ | `repair` | `fn(error, args, kwargs, attempt) -> (args, kwargs) | None` (may be async). Runs between attempts. |
93
+ | `fallback` | `fn(*args, **kwargs)` used when attempts are exhausted or the breaker is open. |
94
+ | `breaker` | `CircuitBreaker(threshold, reset_after)`; closed → open → half-open. |
95
+ | `aggregate` | Streaming steps: `fn(chunks) -> result`. Default joins strings, else keeps the list. |
96
+ | `thread` | Run plain sync steps in a worker thread (default `True`). |
97
+
98
+ Each completed step's journal entry records `attempts`, `healed` (`"retry"` / `"fallback"`) and the errors seen.
99
+
100
+ ### Fleet-level healing: `Worker`
101
+
102
+ ```python
103
+ from pylinxi import Worker
104
+
105
+ worker = Worker(journal, [support, onboarding], retry_failed=True, max_failures=5, backoff=30)
106
+ await worker.run_forever(interval=5) # or: report = await worker.tick()
107
+ ```
108
+
109
+ Each tick resumes runs whose timers are due and (optionally) runs that *ended* in an unhandled failure, with exponential backoff and a give-up limit. Failures your flow caught are left alone. One run that errors while resuming is reported in `TickReport.errors` and never blocks the others. Run several workers against a `PostgresJournal`; per-run locks make them skip runs another worker owns.
110
+
111
+ ---
112
+
113
+ ## Flow API (`ctx`)
114
+
115
+ | Call | What it does |
116
+ |---|---|
117
+ | `await my_step(...)` | Journaled call of a `@step` function. |
118
+ | `await ctx.step(fn, *args, name=, policy=, sensitive=)` | Journaled call of any callable (lambdas, third-party functions). |
119
+ | `await ctx.map(fn, items, concurrency=8)` | Parallel fan-out; each item is its own checkpoint. Siblings finish before an error surfaces, so a resume re-runs only failed items. |
120
+ | `await ctx.route(label, {"a": step_a, ...}, *args, default=)` | Dispatch by label. |
121
+ | `await ctx.wait_for(signal, timeout=None, default=None)` | Wait for `run/resume(..., signals={signal: value})`. Signals are journaled on delivery, so early ones aren't lost and repeats queue in order. With `timeout`, returns `default` once it expires. |
122
+ | `await ctx.sleep(seconds, inline_max=1.0)` | Durable sleep. Short waits sleep in-process; longer ones suspend and free the worker. |
123
+ | `await ctx.now()` / `await ctx.uuid()` | Clock / random id recorded once, identical on replay. |
124
+ | `ctx.patched("change-id")` | `True` for runs that reach this point on new code, `False` for runs already past it. |
125
+ | `ctx.continue_as_new(*args, **kwargs)` | End this run; continue as `run_id.g1`, `.g2`… with the current state. |
126
+ | `ctx.state` | Dict of flow state. Changes are journaled as deltas at the next checkpoint. |
127
+ | `ctx.run_id`, `ctx.generation`, `ctx.replaying` | Run metadata. |
128
+ | `pylinxi.idempotency_key()` | Inside a step: `"<run_id>:<step_key>"`, stable across retries and resumes. |
129
+
130
+ `@step(sensitive=True)` keeps a step's result out of the journal entirely; it is recomputed on resume. Use it for tokens and credentials.
131
+
132
+ ### Running
133
+
134
+ ```python
135
+ r = await support.run(*args, run_id="t-1", state={"tier": "gold"}, signals={...}, **kwargs)
136
+ r = await support.resume("t-1", signals={"approval": True})
137
+ r = support.run_sync(...) # outside an event loop
138
+
139
+ r.status # "completed" | "suspended" | "failed"
140
+ r.output, r.state, r.waiting_for, r.wake_at, r.error, r.events
141
+ ```
142
+
143
+ ### Streaming
144
+
145
+ Generator steps stream chunks as events; the journal stores only the aggregate.
146
+
147
+ ```python
148
+ @step
149
+ async def answer(prompt):
150
+ async for tok in llm.stream(prompt):
151
+ yield tok
152
+
153
+ @flow
154
+ async def chat(ctx, prompt):
155
+ return await answer(prompt)
156
+
157
+ async for ev in chat.stream("hello", run_id="chat-1"):
158
+ if ev["event"] == "chunk":
159
+ await websocket.send(ev["chunk"])
160
+ elif ev["event"] == "stream_reset": # a retry restarted the stream
161
+ await websocket.send({"reset": True})
162
+ elif ev["event"] == "result":
163
+ final = ev["result"] # RunResult
164
+ ```
165
+
166
+ ### Events
167
+
168
+ Pass `on_event=fn` (or a list) to `@flow`, `run`, or `resume`. Every event has `event`, `key`, `run_id`, `flow`:
169
+
170
+ `run_started`, `started`, `completed`, `replayed`, `attempt_failed`, `repaired`, `fallback`, `breaker_open`, `chunk`, `stream_reset`, `step_failed`, `step_crashed`, `signal_timeout`, `suspended`, `continued_as_new`, `divergence`, `failed`, `crashed`, `finished`.
171
+
172
+ A failing observer never breaks a run.
173
+
174
+ ---
175
+
176
+ ## Journals
177
+
178
+ ```python
179
+ from pylinxi import MemoryJournal, FileJournal, SQLiteJournal, open_journal
180
+ from pylinxi.postgres import PostgresJournal
181
+
182
+ j = open_journal("sqlite:///runs.db", secret={"2026-q3": k1, "2026-q4": k2})
183
+ ```
184
+
185
+ | Backend | Use for |
186
+ |---|---|
187
+ | `MemoryJournal()` | Tests. |
188
+ | `FileJournal(dir, fsync=True)` | One JSONL file per run; tolerant of torn writes after a crash. |
189
+ | `SQLiteJournal(path)` | Single machine (WAL mode). |
190
+ | `PostgresJournal(dsn, table="pylinxi_journal")` | Many workers. Appends run under a per-run advisory lock reading the tail from the database; `run`/`resume` hold a session lock so a second worker gets `RunLocked`. Payloads are `TEXT` so signatures verify byte-for-byte. |
191
+
192
+ Common methods: `load(run_id)`, `verify(run_id, require_signed=False)`, `state_at(run_id, seq=None)`, `runs()`, `runs_where_last(kind)`, `pending_timers(due_before=None)`, `delete(run_id)`, `close()` (also a context manager).
193
+
194
+ **Signing and rotation.** `secret=b"..."` or `{key_id: key}`. New entries are signed with `active_kid` (default: the last key). Old entries verify as long as their key id is still configured. `verify(..., require_signed=True)` also rejects unkeyed entries, so an attacker can't rewrite a run as an unkeyed chain.
195
+
196
+ **Integrity limits.** The chain detects edits, reordering and deletions *inside* a run. Dropping the newest entries can only be detected against an external anchor: store `journal.head(run_id)` elsewhere (an audit log, another database) and check with `verify(run_id, anchor=...)`. `verify` never raises; undecodable data makes it return `False`, and `load` raises `JournalCorrupt`.
197
+
198
+ **Concurrent writers.** Appends read the chain tail from storage under a storage-level lock (file lock, SQLite `BEGIN IMMEDIATE`, Postgres advisory lock), so any number of `Journal` instances or processes can share one store. A crash mid-write leaves a torn final line that the next append repairs.
199
+
200
+ **Redaction.** State deltas and metadata are redacted after encoding (so tuples, sets and dataclass fields are covered too). Key names are normalized (`apiKey`, `api-key` → `api_key`) and matched by word: `password`, `secret`, `token`, `api_key`, `access_key`, `private_key`, `auth`, `authorization`, `credential(s)`, `ssn`, `cookie`, `session_id` and similar — so `access_token` matches but `max_tokens` doesn't. Override with `redact_pattern=` (or `None`). Step *results* are not redacted (they are needed for replay); mark such steps `sensitive=True`. Validation errors never include the rejected value.
201
+
202
+ **Types.** The codec is lossless: tuples, sets, bytes, non-string dict keys, and dicts that happen to contain the internal tag key all round-trip exactly. Custom classes must be registered (`default_codec.register(MyDataclass)`, also pydantic models); unregistered ones raise instead of silently coming back as dicts on replay.
203
+
204
+ ---
205
+
206
+ ## OpenTelemetry
207
+
208
+ ```python
209
+ from pylinxi.otel import OTelExporter
210
+
211
+ @flow(journal=j, on_event=OTelExporter()) # record_errors=False to keep error text out of traces
212
+ ```
213
+
214
+ ```
215
+ pylinxi.run support status, waiting_for, events: pylinxi.replayed
216
+ ├─ pylinxi.step classify attempts=3 healed=retry, events: attempt_failed, repaired
217
+ ├─ pylinxi.step lookup_charges events: breaker_open, fallback
218
+ └─ pylinxi.step send ERROR status if it finally failed
219
+ ```
220
+
221
+ Uses only the OpenTelemetry API; configure any exporter (OTLP, Jaeger, Datadog, Honeycomb) in your app. Replayed steps appear as events, not spans, so traces show what actually executed.
222
+
223
+ ---
224
+
225
+ ## CLI
226
+
227
+ ```bash
228
+ export PYLINXI_JOURNAL=postgresql://app@db/agents PYLINXI_KEYS="2026-q3=...,2026-q4=..."
229
+
230
+ pylinxi runs --status failed # completed|continued|failed|sleeping|waiting|open
231
+ pylinxi show t-1001 # every entry: kind, key, tries, healed, changed state, result
232
+ pylinxi state t-1001 --seq 3 # time travel
233
+ pylinxi verify --all --require-signed # exit 1 on any failure: use in CI or audits
234
+ pylinxi timers --due
235
+ pylinxi delete t-1001 --yes # retention / right-to-erasure
236
+ pylinxi --json runs # machine-readable
237
+ ```
238
+
239
+ ---
240
+
241
+ ## Honest comparison with LangGraph
242
+
243
+ LangGraph is mature and also offers a functional API (`@entrypoint`/`@task`), retry policies, caching and many checkpointers, plus LangSmith and a hosted platform. Choose pylinxi when you want:
244
+
245
+ - a small surface (two decorators, no graph or reducer schema), zero core dependencies, and code you can read in an afternoon;
246
+ - per-step validate → repair → retry → fallback → breaker as one policy;
247
+ - delta journals with signing, rotation, redaction and a verify CLI built in;
248
+ - durable timers, `patched()` and `continue_as_new()` for long-lived agents.
249
+
250
+ Choose LangGraph when you need its integrations ecosystem, LangSmith's tracing UI, or a managed runtime.
251
+
252
+ On certifications: SOC 2 / ISO 27001 apply to organizations and hosted services, not libraries. pylinxi makes audits easier — signed journals, redaction, no network calls, no core dependencies to vet.
253
+
254
+ ---
255
+
256
+ ## Development
257
+
258
+ ```bash
259
+ git clone https://github.com/karawash-projects/pylinxi && cd pylinxi
260
+ pip install -e ".[dev]"
261
+ python -m unittest discover -s tests -t . # Postgres tests skip without a DSN
262
+ PYLINXI_PG_DSN=postgresql://user@localhost/db python -m unittest discover -s tests -t .
263
+ ruff check pylinxi tests examples && mypy pylinxi
264
+ python examples/support_agent.py
265
+ ```
266
+
267
+ CI runs lint, type checks, and the full suite with a Postgres service on Python 3.9–3.13.
268
+
269
+ ### Releasing
270
+
271
+ Bump `version` in `pyproject.toml` and `__version__` in `pylinxi/__init__.py`, add a `## X.Y.Z` section to `CHANGELOG.md`, then:
272
+
273
+ ```bash
274
+ git tag vX.Y.Z && git push origin vX.Y.Z
275
+ ```
276
+
277
+ (or on GitHub: **Releases → Draft a new release → Choose a tag → type `vX.Y.Z` → Create new tag → Publish release**.)
278
+
279
+ The `release` workflow checks the tag matches the version, runs the tests, builds, publishes to PyPI via trusted publishing (no API token), and creates a GitHub Release with the changelog notes and built files.
280
+
281
+ MIT licensed.
@@ -0,0 +1,25 @@
1
+ """pylinxi -- durable, self-healing agent flows as plain Python. Zero dependencies."""
2
+ from .flow import Context, ContinueAsNew, Flow, ReplayedError, RunResult, Suspended, flow, idempotency_key, step
3
+ from .journal import (
4
+ Codec,
5
+ Entry,
6
+ FileJournal,
7
+ Journal,
8
+ JournalCorrupt,
9
+ MemoryJournal,
10
+ RunLocked,
11
+ SQLiteJournal,
12
+ default_codec,
13
+ open_journal,
14
+ redact,
15
+ )
16
+ from .policy import CircuitBreaker, CircuitOpen, Policy, Retry, StepFailed, ValidationError
17
+ from .worker import TickReport, Worker
18
+
19
+ __version__ = "1.0.0"
20
+ __all__ = [
21
+ "flow", "step", "idempotency_key", "Flow", "Context", "RunResult", "Suspended", "ContinueAsNew", "ReplayedError",
22
+ "Policy", "Retry", "CircuitBreaker", "CircuitOpen", "StepFailed", "ValidationError",
23
+ "Journal", "MemoryJournal", "FileJournal", "SQLiteJournal", "RunLocked", "JournalCorrupt", "Entry",
24
+ "Codec", "default_codec", "open_journal", "redact", "Worker", "TickReport",
25
+ ]
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())