pylinxi 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pylinxi/__init__.py +25 -0
- pylinxi/__main__.py +5 -0
- pylinxi/cli.py +202 -0
- pylinxi/flow.py +653 -0
- pylinxi/journal.py +607 -0
- pylinxi/otel.py +97 -0
- pylinxi/policy.py +240 -0
- pylinxi/postgres.py +125 -0
- pylinxi/py.typed +0 -0
- pylinxi/worker.py +112 -0
- pylinxi-1.0.0.dist-info/METADATA +316 -0
- pylinxi-1.0.0.dist-info/RECORD +16 -0
- pylinxi-1.0.0.dist-info/WHEEL +5 -0
- pylinxi-1.0.0.dist-info/entry_points.txt +2 -0
- pylinxi-1.0.0.dist-info/licenses/LICENSE +21 -0
- pylinxi-1.0.0.dist-info/top_level.txt +1 -0
pylinxi/__init__.py
ADDED
|
@@ -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
|
+
]
|
pylinxi/__main__.py
ADDED
pylinxi/cli.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""pylinxi command line: inspect, verify and manage run journals.
|
|
2
|
+
|
|
3
|
+
pylinxi --journal sqlite:///runs.db runs
|
|
4
|
+
pylinxi runs --status failed # journal from $PYLINXI_JOURNAL
|
|
5
|
+
pylinxi show ticket-1001
|
|
6
|
+
pylinxi state ticket-1001 --seq 3 # time travel
|
|
7
|
+
pylinxi verify --all --require-signed # exit code 1 on any failure (CI / audits)
|
|
8
|
+
pylinxi timers --due
|
|
9
|
+
pylinxi delete ticket-1001 --yes
|
|
10
|
+
|
|
11
|
+
Signing keys come from --key kid=secret (repeatable) or $PYLINXI_KEYS="k1=...,k2=...".
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import datetime as _dt
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
22
|
+
|
|
23
|
+
from .journal import Entry, Journal, JournalCorrupt, open_journal
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _parse_keys(pairs: Sequence[str]) -> Dict[str, bytes]:
|
|
27
|
+
keys: Dict[str, bytes] = {}
|
|
28
|
+
for p in pairs:
|
|
29
|
+
if "=" not in p:
|
|
30
|
+
raise SystemExit(f"bad key {p!r}: expected kid=secret")
|
|
31
|
+
kid, secret = p.split("=", 1)
|
|
32
|
+
keys[kid.strip()] = secret.encode()
|
|
33
|
+
return keys
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def run_status(entries: List[Entry]) -> str:
|
|
37
|
+
"""completed | continued | failed | sleeping (timer) | waiting (signal) | open (running or crashed)"""
|
|
38
|
+
if not entries:
|
|
39
|
+
return "missing"
|
|
40
|
+
last = entries[-1]
|
|
41
|
+
if last.kind == "done":
|
|
42
|
+
return "continued" if last.meta.get("continued_as") else "completed"
|
|
43
|
+
if last.kind == "failed" and last.key == "flow":
|
|
44
|
+
return "failed"
|
|
45
|
+
if last.kind == "suspend":
|
|
46
|
+
return "sleeping" if (last.result or {}).get("wake_at") is not None else "waiting"
|
|
47
|
+
return "open"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _fmt_ts(ts: float) -> str:
|
|
51
|
+
return _dt.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _preview(v: Any, width: int = 48) -> str:
|
|
55
|
+
s = json.dumps(v, default=str) if not isinstance(v, str) else v
|
|
56
|
+
s = s.replace("\n", " ")
|
|
57
|
+
return s if len(s) <= width else s[: width - 1] + "…"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _table(rows: List[List[str]], headers: List[str]) -> str:
|
|
61
|
+
widths = [max(len(str(x)) for x in col) for col in zip(headers, *rows)]
|
|
62
|
+
line = lambda r: " ".join(str(c).ljust(w) for c, w in zip(r, widths)).rstrip() # noqa: E731
|
|
63
|
+
return "\n".join([line(headers), line(["-" * w for w in widths])] + [line(r) for r in rows])
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _out(args: argparse.Namespace, data: Any, text: str) -> None:
|
|
67
|
+
print(json.dumps(data, indent=2, default=str) if args.json else text)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def cmd_runs(j: Journal, args: argparse.Namespace) -> int:
|
|
71
|
+
rows: List[Dict[str, Any]] = []
|
|
72
|
+
for rid in sorted(j.runs()):
|
|
73
|
+
try:
|
|
74
|
+
es = j.load(rid)
|
|
75
|
+
st = run_status(es)
|
|
76
|
+
except JournalCorrupt:
|
|
77
|
+
es, st = [], "corrupt"
|
|
78
|
+
if args.status and st != args.status:
|
|
79
|
+
continue
|
|
80
|
+
rows.append({"run_id": rid, "status": st, "entries": len(es),
|
|
81
|
+
"updated": es[-1].ts if es else None})
|
|
82
|
+
rows.sort(key=lambda r: r["updated"] or 0, reverse=True)
|
|
83
|
+
rows = rows[: args.limit]
|
|
84
|
+
_out(args, rows, _table([[r["run_id"], r["status"], str(r["entries"]),
|
|
85
|
+
_fmt_ts(r["updated"]) if r["updated"] else ""] for r in rows],
|
|
86
|
+
["RUN", "STATUS", "ENTRIES", "UPDATED"]) if rows else "no runs")
|
|
87
|
+
return 0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def cmd_show(j: Journal, args: argparse.Namespace) -> int:
|
|
91
|
+
try:
|
|
92
|
+
es = j.load(args.run_id)
|
|
93
|
+
except JournalCorrupt as e:
|
|
94
|
+
print(f"run {args.run_id!r} is corrupt: {e}", file=sys.stderr)
|
|
95
|
+
return 1
|
|
96
|
+
if not es:
|
|
97
|
+
print(f"run {args.run_id!r} not found", file=sys.stderr)
|
|
98
|
+
return 1
|
|
99
|
+
data: List[Dict[str, Any]] = [{"seq": e.seq, "kind": e.kind, "key": e.key, "ts": e.ts, "kid": e.kid,
|
|
100
|
+
"attempts": e.meta.get("attempts"), "healed": e.meta.get("healed"),
|
|
101
|
+
"error": e.meta.get("error"), "result": e.result if e.persisted else "[sensitive]",
|
|
102
|
+
"changed": sorted(e.delta) + [f"-{k}" for k in e.removed]} for e in es]
|
|
103
|
+
rows = [[str(d["seq"]), d["kind"], d["key"], _fmt_ts(d["ts"]),
|
|
104
|
+
"" if d["attempts"] in (None, 1) else str(d["attempts"]), d["healed"] or "",
|
|
105
|
+
",".join(d["changed"]), _preview(d["error"] or d["result"])] for d in data]
|
|
106
|
+
head = f"run {args.run_id} status={run_status(es)} entries={len(es)}\n\n"
|
|
107
|
+
_out(args, data, head + _table(rows, ["SEQ", "KIND", "KEY", "TIME", "TRIES", "HEALED", "STATE", "RESULT"]))
|
|
108
|
+
return 0
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def cmd_state(j: Journal, args: argparse.Namespace) -> int:
|
|
112
|
+
if not j.load(args.run_id):
|
|
113
|
+
print(f"run {args.run_id!r} not found", file=sys.stderr)
|
|
114
|
+
return 1
|
|
115
|
+
print(json.dumps(j.state_at(args.run_id, args.seq), indent=2, default=str))
|
|
116
|
+
return 0
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def cmd_verify(j: Journal, args: argparse.Namespace) -> int:
|
|
120
|
+
ids = sorted(j.runs()) if args.all else args.run_ids
|
|
121
|
+
if not ids:
|
|
122
|
+
print("nothing to verify (give run ids or --all)", file=sys.stderr)
|
|
123
|
+
return 2
|
|
124
|
+
results = {rid: j.verify(rid, require_signed=args.require_signed) for rid in ids}
|
|
125
|
+
bad = [r for r, ok in results.items() if not ok]
|
|
126
|
+
_out(args, results, "\n".join(f"{'OK ' if ok else 'FAIL'} {rid}" for rid, ok in results.items())
|
|
127
|
+
+ f"\n\n{len(ids) - len(bad)}/{len(ids)} verified")
|
|
128
|
+
return 1 if bad else 0
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def cmd_timers(j: Journal, args: argparse.Namespace) -> int:
|
|
132
|
+
now = time.time()
|
|
133
|
+
timers = j.pending_timers(due_before=now if args.due else None)
|
|
134
|
+
data: List[Dict[str, Any]] = [{"run_id": r, "wake_at": w, "due": w <= now} for r, w in timers]
|
|
135
|
+
_out(args, data, _table([[d["run_id"], _fmt_ts(d["wake_at"]), "yes" if d["due"] else ""] for d in data],
|
|
136
|
+
["RUN", "WAKE AT", "DUE"]) if data else "no pending timers")
|
|
137
|
+
return 0
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def cmd_delete(j: Journal, args: argparse.Namespace) -> int:
|
|
141
|
+
if not args.yes:
|
|
142
|
+
print("refusing to delete without --yes (this is permanent)", file=sys.stderr)
|
|
143
|
+
return 2
|
|
144
|
+
for rid in args.run_ids:
|
|
145
|
+
j.delete(rid)
|
|
146
|
+
print(f"deleted {rid}")
|
|
147
|
+
return 0
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
151
|
+
p = argparse.ArgumentParser(prog="pylinxi", description="Inspect and manage pylinxi run journals.")
|
|
152
|
+
p.add_argument("--journal", default=os.environ.get("PYLINXI_JOURNAL"),
|
|
153
|
+
help="journal URL (memory://, file://DIR, sqlite:///FILE, postgresql://...); "
|
|
154
|
+
"default $PYLINXI_JOURNAL")
|
|
155
|
+
p.add_argument("--key", action="append", default=[], metavar="KID=SECRET",
|
|
156
|
+
help="signing key for verify (repeatable); default $PYLINXI_KEYS")
|
|
157
|
+
p.add_argument("--json", action="store_true", help="machine-readable output")
|
|
158
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
159
|
+
|
|
160
|
+
s = sub.add_parser("runs", help="list runs")
|
|
161
|
+
s.add_argument("--status", choices=["completed", "continued", "failed", "sleeping", "waiting", "open"])
|
|
162
|
+
s.add_argument("--limit", type=int, default=100)
|
|
163
|
+
s.set_defaults(fn=cmd_runs)
|
|
164
|
+
|
|
165
|
+
s = sub.add_parser("show", help="show a run's journal")
|
|
166
|
+
s.add_argument("run_id")
|
|
167
|
+
s.set_defaults(fn=cmd_show)
|
|
168
|
+
|
|
169
|
+
s = sub.add_parser("state", help="rebuild state, optionally as of an entry")
|
|
170
|
+
s.add_argument("run_id")
|
|
171
|
+
s.add_argument("--seq", type=int)
|
|
172
|
+
s.set_defaults(fn=cmd_state)
|
|
173
|
+
|
|
174
|
+
s = sub.add_parser("verify", help="verify hash chain and signatures")
|
|
175
|
+
s.add_argument("run_ids", nargs="*")
|
|
176
|
+
s.add_argument("--all", action="store_true")
|
|
177
|
+
s.add_argument("--require-signed", action="store_true")
|
|
178
|
+
s.set_defaults(fn=cmd_verify)
|
|
179
|
+
|
|
180
|
+
s = sub.add_parser("timers", help="list runs waiting on timers")
|
|
181
|
+
s.add_argument("--due", action="store_true", help="only timers that are due now")
|
|
182
|
+
s.set_defaults(fn=cmd_timers)
|
|
183
|
+
|
|
184
|
+
s = sub.add_parser("delete", help="permanently delete runs")
|
|
185
|
+
s.add_argument("run_ids", nargs="+")
|
|
186
|
+
s.add_argument("--yes", action="store_true")
|
|
187
|
+
s.set_defaults(fn=cmd_delete)
|
|
188
|
+
return p
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
192
|
+
args = build_parser().parse_args(argv)
|
|
193
|
+
if not args.journal:
|
|
194
|
+
print("no journal: pass --journal URL or set PYLINXI_JOURNAL", file=sys.stderr)
|
|
195
|
+
return 2
|
|
196
|
+
keys = _parse_keys(args.key or [k for k in os.environ.get("PYLINXI_KEYS", "").split(",") if k])
|
|
197
|
+
with open_journal(args.journal, secret=keys or None) as j:
|
|
198
|
+
return int(args.fn(j, args))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
if __name__ == "__main__": # pragma: no cover
|
|
202
|
+
sys.exit(main())
|