ctrlrun 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ctrlrun/cli/demo.py ADDED
@@ -0,0 +1,300 @@
1
+ """The four-scenario demo with an in-process fake Stripe. Build-list item 8; SPEC §7 T11.
2
+
3
+ Four ways an agent action goes wrong at the boundary between intention and effect, and what
4
+ CTRLRun does about each. Everything runs in this process: no network, no clock skew, no
5
+ sleeping. The fake remote is the only thing pretending — and it pretends in the one way that
6
+ matters, by committing before its response goes missing.
7
+
8
+ The demo keeps its evidence in `.ctrlrun/demo/`, never in the store a real agent is using:
9
+ it reserves effect keys and would otherwise collide with — or block — live work.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import shlex
15
+ from collections.abc import Callable
16
+ from pathlib import Path
17
+ from typing import Any, Final, TypeVar
18
+
19
+ import click
20
+
21
+ from ..approval import ScriptedApprovalProvider
22
+ from ..control import Control, context, protect, with_approval
23
+ from ..errors import (
24
+ AmbiguousEffect,
25
+ ApprovalMismatch,
26
+ ApprovalRequired,
27
+ DuplicateEffect,
28
+ )
29
+ from ..policy import Policy
30
+ from ..receipt import EVENTS_FILENAME, RECEIPTS_FILENAME
31
+ from ..state import SQLiteStateStore
32
+
33
+ #: Where the demo keeps its own store and evidence, under `.ctrlrun/`.
34
+ DEMO_DIRNAME: Final = "demo"
35
+
36
+ #: The headings `ctrlrun demo` prints, in order (README, "What `ctrlrun demo` shows").
37
+ SCENARIO_HEADINGS: Final = (
38
+ "Duplicate effect after a lost response",
39
+ "Approval mutation",
40
+ "Concurrent agents, same effect",
41
+ "Approval replay",
42
+ )
43
+
44
+ #: What each scenario refunds, in integer minor units (SPEC-v0.1 §2.3). The README's
45
+ #: "What `ctrlrun demo` shows" quotes the first three, and
46
+ #: `test_the_readme_demo_section_shows_the_amounts_the_demo_uses` keeps the two in step:
47
+ #: the demo is the truth, and the README follows it.
48
+ LOST_RESPONSE_AMOUNT: Final = 50000 # €500 — autonomous
49
+ PROPOSED_AMOUNT: Final = 200000 # €2,000 — the refund a human approves
50
+ MUTATED_AMOUNT: Final = 500000 # €5,000 — what the agent tried to run instead
51
+ CONCURRENT_AMOUNT: Final = 50000 # €500 — autonomous, raced for by two agents
52
+ REPLAY_AMOUNT: Final = 200000 # €2,000 — approved once, presented twice
53
+
54
+ #: The amounts the README prints, in the order it prints them.
55
+ README_AMOUNTS: Final = (LOST_RESPONSE_AMOUNT, PROPOSED_AMOUNT, MUTATED_AMOUNT)
56
+
57
+ #: Amounts are integer minor units (SPEC-v0.1 §2.3): €1,000 autonomous, €10,000 with a human.
58
+ DEMO_POLICY: Final = """
59
+ schema: ctrlrun.policy/v1
60
+ actions:
61
+ customer.read:
62
+ decision: allow
63
+ stripe.refund:
64
+ rules:
65
+ - when: { amount_lte: 100000 }
66
+ decision: allow
67
+ - when: { amount_lte: 1000000 }
68
+ decision: approve
69
+ - decision: deny
70
+ iam.grant_admin:
71
+ decision: deny
72
+ """
73
+
74
+ _BLOCKED: Final = " ✗ BLOCKED — "
75
+
76
+
77
+ class FakeStripe:
78
+ """An in-process payment API. It commits first, then decides how the reply goes wrong.
79
+
80
+ That order is the whole point: a remote that fails *before* doing anything is easy, and
81
+ the executor can say so by raising `NotExecuted`. The dangerous remote is this one.
82
+ """
83
+
84
+ def __init__(self) -> None:
85
+ self.calls: list[str] = []
86
+ self.lose_response: set[str] = set()
87
+ self.on_call: dict[str, Callable[[], None]] = {}
88
+
89
+ def refund(self, payment_id: str, amount: int) -> dict[str, Any]:
90
+ self.calls.append(payment_id) # the money has moved by the time anything else runs
91
+ hook = self.on_call.pop(payment_id, None)
92
+ if hook is not None:
93
+ hook()
94
+ if payment_id in self.lose_response:
95
+ raise TimeoutError("no response from api.stripe.com after 30s")
96
+ return {"id": f"re_{payment_id}", "amount": amount, "status": "succeeded"}
97
+
98
+
99
+ _Refusal = TypeVar("_Refusal", bound=BaseException)
100
+
101
+
102
+ def _euros(minor_units: int) -> str:
103
+ return f"€{minor_units // 100:,}"
104
+
105
+
106
+ def _heading(number: int, text: str) -> None:
107
+ click.echo("")
108
+ click.echo(f"{number}. {text}")
109
+ click.echo("")
110
+
111
+
112
+ def _refused(call: Callable[[], Any], expected: type[_Refusal]) -> _Refusal:
113
+ """Run something the kernel must refuse, and return the refusal.
114
+
115
+ A scenario that is not blocked has demonstrated nothing, and would print nothing to say
116
+ so. Raising here makes `ctrlrun demo` fail loudly instead of quietly passing.
117
+ """
118
+ try:
119
+ call()
120
+ except expected as blocked:
121
+ return blocked
122
+ raise AssertionError(f"the demo expected {expected.__name__} and the action went through")
123
+
124
+
125
+ def run_demo(root: Path) -> None:
126
+ """Run the four scenarios under `root`, printing what each one blocks (SPEC §7 T11)."""
127
+ evidence = _fresh_evidence_dir(root)
128
+ store = SQLiteStateStore(evidence / "state.db")
129
+ remote = FakeStripe()
130
+ # Two grants: scenario 2's human, and scenario 4's. A scripted approver never grants
131
+ # more than it was told to, so a scenario that asked twice would fail loudly.
132
+ approvals = ScriptedApprovalProvider(store, ["grant", "grant"], approver="human:demo")
133
+ control = Control(Policy.from_yaml(DEMO_POLICY, source="<demo>"), store, approvals)
134
+
135
+ @protect("stripe.refund", effect="refund:{payment_id}", control=control)
136
+ def refund(payment_id: str, amount: int, currency: str = "EUR") -> dict[str, Any]:
137
+ return remote.refund(payment_id, amount)
138
+
139
+ click.echo("CTRLRun demo — four ways an agent action goes wrong, and what stops it.")
140
+ click.echo(
141
+ "Policy: refunds up to €1,000 are autonomous, up to €10,000 need a human, "
142
+ "above that are denied."
143
+ )
144
+
145
+ try:
146
+ _lost_response(refund, remote)
147
+ _approval_mutation(refund, approvals)
148
+ _concurrent_agents(refund, remote)
149
+ _approval_replay(refund, approvals)
150
+ finally:
151
+ written = len(store.receipts())
152
+ store.close()
153
+
154
+ click.echo("")
155
+ click.echo(f"Receipts ({written}): {written_path(root, evidence / RECEIPTS_FILENAME)}")
156
+ click.echo(f"Events: {written_path(root, evidence / EVENTS_FILENAME)}")
157
+ click.echo("")
158
+ click.echo(f"Read them: {read_them_command(evidence)}")
159
+
160
+
161
+ # --- 1. the signature scenario (SPEC §7 T1) -------------------------------------------
162
+
163
+
164
+ def _lost_response(refund: Callable[..., Any], remote: FakeStripe) -> None:
165
+ amount = LOST_RESPONSE_AMOUNT
166
+ remote.lose_response.add("txn_1")
167
+ _heading(1, SCENARIO_HEADINGS[0])
168
+ click.echo(
169
+ f" refund {_euros(amount)} → remote commits → response lost → effect: AMBIGUOUS"
170
+ )
171
+ with context(agent="refund-agent"):
172
+ _refused(lambda: refund(payment_id="txn_1", amount=amount), TimeoutError)
173
+ click.echo(" agent retries the same refund")
174
+ _refused(lambda: refund(payment_id="txn_1", amount=amount), AmbiguousEffect)
175
+ click.echo(f"{_BLOCKED}effect may already have committed; blind retry refused")
176
+ click.echo(f" remote refund calls: {remote.calls.count('txn_1')}")
177
+ click.echo(" only a human moves it on: ctrlrun resolve refund:txn_1 --committed|--failed")
178
+
179
+
180
+ # --- 2. the approval is for one exact action (SPEC §7 T2) ------------------------------
181
+
182
+
183
+ def _approval_mutation(refund: Callable[..., Any], approvals: ScriptedApprovalProvider) -> None:
184
+ proposed, mutated = PROPOSED_AMOUNT, MUTATED_AMOUNT
185
+ _heading(2, SCENARIO_HEADINGS[1])
186
+ with context(agent="refund-agent"):
187
+ request_id = _propose(refund, payment_id="txn_2", amount=proposed)
188
+ approvals.wait(request_id, None) # the scripted human says yes
189
+ click.echo(
190
+ f" agent proposes refund {_euros(proposed)} → human approves {request_id} "
191
+ "(bound to the action hash)"
192
+ )
193
+ click.echo(f" agent executes refund {_euros(mutated)} →")
194
+
195
+ def execute_the_mutation() -> None:
196
+ with with_approval(request_id):
197
+ refund(payment_id="txn_2", amount=mutated)
198
+
199
+ blocked = _refused(execute_the_mutation, ApprovalMismatch)
200
+ click.echo(f"{_BLOCKED}approved action ≠ requested action ({blocked.reason})")
201
+
202
+
203
+ # --- 3. two agents, one effect (SPEC §7 T3, in one process) ----------------------------
204
+
205
+
206
+ def _concurrent_agents(refund: Callable[..., Any], remote: FakeStripe) -> None:
207
+ amount, key = CONCURRENT_AMOUNT, "refund:txn_123"
208
+ _heading(3, SCENARIO_HEADINGS[2])
209
+ refused: list[DuplicateEffect] = []
210
+
211
+ def agent_b() -> None:
212
+ """Agent B arrives while A holds the key, which is what makes this a race."""
213
+ with context(agent="refund-agent-b"):
214
+ refused.append(
215
+ _refused(lambda: refund(payment_id="txn_123", amount=amount), DuplicateEffect)
216
+ )
217
+
218
+ remote.on_call["txn_123"] = agent_b
219
+ with context(agent="refund-agent-a"):
220
+ refund(payment_id="txn_123", amount=amount)
221
+ click.echo(f" Agent A reserve {key} → ACQUIRED → executes")
222
+ click.echo(f" Agent B reserve {key} →")
223
+ click.echo(f"{_BLOCKED}already reserved ({refused[0].state})")
224
+
225
+
226
+ # --- 4. an approval is worth exactly one execution (SPEC §7 T4) ------------------------
227
+
228
+
229
+ def _approval_replay(refund: Callable[..., Any], approvals: ScriptedApprovalProvider) -> None:
230
+ amount = REPLAY_AMOUNT
231
+ _heading(4, SCENARIO_HEADINGS[3])
232
+ with context(agent="refund-agent"):
233
+ request_id = _propose(refund, payment_id="txn_4", amount=amount)
234
+ approvals.wait(request_id, None)
235
+ with with_approval(request_id):
236
+ refund(payment_id="txn_4", amount=amount)
237
+ used = f"approval {request_id} used once"
238
+ click.echo(f" {used} → consumed")
239
+ # The two arrows line up, and the id's width is not a constant to hardcode: it grew
240
+ # once already (48 → 128 bits) and quietly knocked this column out of true.
241
+ click.echo(f" {'same approval presented again':<{len(used)}} →")
242
+
243
+ def present_it_again() -> None:
244
+ with with_approval(request_id):
245
+ refund(payment_id="txn_4", amount=amount)
246
+
247
+ blocked = _refused(present_it_again, ApprovalMismatch)
248
+ click.echo(f"{_BLOCKED}single-use approval already {blocked.reason}")
249
+
250
+
251
+ def _propose(refund: Callable[..., Any], payment_id: str, amount: int) -> str:
252
+ """Make the proposal that asks a human, and return the request it raised (SPEC §4.3)."""
253
+ pending = _refused(lambda: refund(payment_id=payment_id, amount=amount), ApprovalRequired)
254
+ return pending.request_id
255
+
256
+
257
+ def written_path(root: Path, path: Path) -> str:
258
+ """`path` as the reader can retype it: relative to the directory the demo ran in.
259
+
260
+ The demo's transcript is meant to be pasted — into an issue, a post, the README — and an
261
+ absolute path carries the operator's username and directory layout along with it. A path
262
+ outside `root` has no relative form and is printed whole; the demo never writes one.
263
+ """
264
+ try:
265
+ return str(path.relative_to(root))
266
+ except ValueError:
267
+ return str(path)
268
+
269
+
270
+ def read_them_command(evidence: Path) -> str:
271
+ """The command that reads the demo's receipts, ready to paste (SPEC-v0.1 §8).
272
+
273
+ The demo keeps its own store, so `ctrlrun receipts` on its own would read the operator's.
274
+ `$CTRLRUN_STATE` is the documented way to point a command at another store, and this is
275
+ the one place a new user needs it.
276
+
277
+ The path is relative to where the demo ran, which is also where the reader is standing
278
+ when they paste this, so `evidence.parents[1]` is that directory: `<root>/.ctrlrun/demo`.
279
+ """
280
+ state = written_path(evidence.parents[1], evidence / "state.db")
281
+ return f"CTRLRUN_STATE={shlex.quote(state)} ctrlrun receipts"
282
+
283
+
284
+ def _fresh_evidence_dir(root: Path) -> Path:
285
+ """`.ctrlrun/demo/`, emptied of a previous run's evidence so the demo repeats.
286
+
287
+ Only the files this demo writes are removed, by name: a demo that deleted a directory
288
+ would eventually delete somebody's real evidence.
289
+ """
290
+ evidence = root / ".ctrlrun" / DEMO_DIRNAME
291
+ evidence.mkdir(parents=True, exist_ok=True)
292
+ for name in (
293
+ "state.db",
294
+ "state.db-wal",
295
+ "state.db-shm",
296
+ RECEIPTS_FILENAME,
297
+ EVENTS_FILENAME,
298
+ ):
299
+ (evidence / name).unlink(missing_ok=True)
300
+ return evidence
ctrlrun/cli/main.py ADDED
@@ -0,0 +1,260 @@
1
+ """click command group for the ctrlrun CLI. Build-list item 8; SPEC-v0.1 §8.
2
+
3
+ The CLI is the human end of the kernel: it answers approval requests, shows the evidence,
4
+ and resolves the one state no machine may resolve for itself (§5.2). It is also the only
5
+ place in CTRLRun that prints.
6
+
7
+ Every command works on the store an agent is already using — `.ctrlrun/state.db` beside the
8
+ policy, or wherever `$CTRLRUN_STATE` says (§8) — so approving here answers the request an
9
+ agent is waiting on in another shell.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from datetime import UTC, datetime
15
+ from pathlib import Path
16
+ from typing import Final
17
+
18
+ import click
19
+
20
+ from ..control import DEFAULT_STATE_DIR, state_path
21
+ from ..effect import EffectRecord, EffectState
22
+ from ..errors import CTRLRunError
23
+ from ..policy import DEFAULT_POLICY_FILENAME
24
+ from ..receipt import Event, EventType, Receipt, iso_timestamp
25
+ from ..state import RESOLUTIONS, SQLiteStateStore
26
+ from .demo import run_demo
27
+
28
+ #: Who the CLI records as the answer's author. Free text in v0.1 (SPEC-v0.1 §4.1).
29
+ CLI_APPROVER: Final = "cli:local"
30
+
31
+ #: `ctrlrun init` writes this. It is `ctrlrun.example.yaml` in the repository, and
32
+ #: `test_the_shipped_example_policy_is_the_one_in_the_repository` keeps the two identical.
33
+ EXAMPLE_POLICY: Final = """# ctrlrun.yaml — action-level autonomy policy (v0.1)
34
+ # Unknown actions are DENIED. There is no default-allow. List what is safe.
35
+ schema: ctrlrun.policy/v1
36
+
37
+ actions:
38
+ # Reads: autonomous. Declare no effect key on these in code.
39
+ customer.read:
40
+ decision: allow
41
+ invoice.read:
42
+ decision: allow
43
+
44
+ # External communication: autonomous in v0.1 (allow_with_log arrives later).
45
+ email.send:
46
+ decision: allow
47
+
48
+ # Money: autonomy depends on the amount. First matching rule wins.
49
+ # Amounts are integer minor units (cents). Floats are rejected.
50
+ # Bound both ends: `amount_lte` alone lets a negative amount through, and a refund of
51
+ # a negative amount is a charge. An upper bound is not a range.
52
+ stripe.refund:
53
+ rules:
54
+ - when: { amount_gte: 0, amount_lte: 50000 } # €0.00 to €500.00
55
+ decision: allow
56
+ - when: { amount_gte: 0, amount_lte: 500000 } # €0.00 to €5,000.00
57
+ decision: approve
58
+ - decision: deny
59
+
60
+ # Privilege changes: never autonomous.
61
+ iam.grant_admin:
62
+ decision: deny
63
+
64
+ # Destructive infrastructure: human every time.
65
+ k8s.delete_namespace:
66
+ decision: approve
67
+ """
68
+
69
+
70
+ def _store() -> SQLiteStateStore:
71
+ """Open the store this working tree's agents use (SPEC-v0.1 §8)."""
72
+ return SQLiteStateStore(state_path())
73
+
74
+
75
+ def _fail(exc: CTRLRunError) -> click.ClickException:
76
+ """Turn a kernel refusal into a non-zero exit with the reason, not a traceback."""
77
+ return click.ClickException(str(exc))
78
+
79
+
80
+ def _event(
81
+ type_: EventType,
82
+ action_id: str,
83
+ *,
84
+ effect_key: str | None = None,
85
+ approval_id: str | None = None,
86
+ **data: object,
87
+ ) -> Event:
88
+ """One event for something a human did at the terminal (SPEC-v0.1 §6.2)."""
89
+ return Event(
90
+ type=type_,
91
+ action_id=action_id,
92
+ ts=datetime.now(UTC),
93
+ data=data,
94
+ effect_key=effect_key,
95
+ approval_id=approval_id,
96
+ )
97
+
98
+
99
+ @click.group()
100
+ @click.version_option(package_name="ctrlrun")
101
+ def main() -> None:
102
+ """CTRLRun — transaction safety for AI-agent actions."""
103
+
104
+
105
+ @main.command()
106
+ def init() -> None:
107
+ """Write a starter ctrlrun.yaml and create .ctrlrun/."""
108
+ policy = Path.cwd() / DEFAULT_POLICY_FILENAME
109
+ if policy.exists():
110
+ # A policy is the thing that decides what an agent may do; overwriting one is not a
111
+ # convenience. Refuse, and let the human choose.
112
+ raise click.ClickException(f"{policy} already exists; delete it first to start over")
113
+ policy.write_text(EXAMPLE_POLICY, encoding="utf-8")
114
+ state = Path.cwd() / DEFAULT_STATE_DIR
115
+ state.mkdir(parents=True, exist_ok=True)
116
+ click.echo(f"wrote {policy}")
117
+ click.echo(f"created {state}/")
118
+ click.echo("edit the policy, then protect an action with @ctrlrun.protect(...)")
119
+
120
+
121
+ @main.command()
122
+ def demo() -> None:
123
+ """Run the four failure scenarios, in process, with no network."""
124
+ run_demo(Path.cwd())
125
+
126
+
127
+ @main.command()
128
+ @click.argument("request_id")
129
+ def approve(request_id: str) -> None:
130
+ """Grant a pending approval request."""
131
+ store = _store()
132
+ try:
133
+ record = store.get_approval(request_id)
134
+ approval = store.grant_approval(request_id, CLI_APPROVER)
135
+ except CTRLRunError as exc:
136
+ raise _fail(exc) from exc
137
+ if record is not None:
138
+ store.append_event(
139
+ _event(
140
+ EventType.APPROVAL_GRANTED,
141
+ record.request.action.action_id,
142
+ approval_id=request_id,
143
+ approver=approval.approver,
144
+ action_hash=approval.action_hash,
145
+ )
146
+ )
147
+ click.echo(f"granted {request_id} for {approval.action_hash}")
148
+ click.echo(f"expires {iso_timestamp(approval.expires_at)}")
149
+
150
+
151
+ @main.command()
152
+ @click.argument("request_id")
153
+ def deny(request_id: str) -> None:
154
+ """Refuse a pending approval request."""
155
+ store = _store()
156
+ try:
157
+ record = store.get_approval(request_id)
158
+ store.deny_approval(request_id, CLI_APPROVER)
159
+ except CTRLRunError as exc:
160
+ raise _fail(exc) from exc
161
+ if record is not None:
162
+ store.append_event(
163
+ _event(
164
+ EventType.APPROVAL_DENIED,
165
+ record.request.action.action_id,
166
+ approval_id=request_id,
167
+ approver=CLI_APPROVER,
168
+ )
169
+ )
170
+ click.echo(f"denied {request_id}")
171
+
172
+
173
+ @main.command()
174
+ @click.option("--last", type=click.IntRange(min=1), default=None, help="Show only the last N.")
175
+ @click.option("--json", "as_json", is_flag=True, help="Print the portable receipt JSON.")
176
+ def receipts(last: int | None, as_json: bool) -> None:
177
+ """Show the receipts this store holds."""
178
+ try:
179
+ found = _store().receipts()
180
+ except CTRLRunError as exc:
181
+ raise _fail(exc) from exc
182
+ if last is not None:
183
+ found = found[-last:]
184
+ if not found:
185
+ click.echo("no receipts yet")
186
+ return
187
+ for receipt in found:
188
+ click.echo(receipt.to_json() if as_json else _receipt_line(receipt))
189
+
190
+
191
+ @main.command()
192
+ @click.option(
193
+ "--state",
194
+ type=click.Choice([str(member) for member in EffectState]),
195
+ default=None,
196
+ help="Show only effects in this state.",
197
+ )
198
+ def effects(state: str | None) -> None:
199
+ """Show the logical effects this store knows about."""
200
+ try:
201
+ found = _store().list_effects(None if state is None else EffectState(state))
202
+ except CTRLRunError as exc:
203
+ raise _fail(exc) from exc
204
+ if not found:
205
+ click.echo("no effects yet" if state is None else f"no effects are {state}")
206
+ return
207
+ for record in found:
208
+ click.echo(_effect_line(record))
209
+
210
+
211
+ @main.command()
212
+ @click.argument("effect_key")
213
+ @click.option("--committed", is_flag=True, help="The effect did happen at the remote.")
214
+ @click.option("--failed", is_flag=True, help="The effect provably did not happen.")
215
+ def resolve(effect_key: str, committed: bool, failed: bool) -> None:
216
+ """Say what actually happened to an effect with an unknown outcome."""
217
+ if committed == failed:
218
+ # SPEC §5.2 — a resolution is a human's claim about the real world, and the two
219
+ # claims are opposites. Neither flag says nothing; both say nothing twice.
220
+ raise click.UsageError(
221
+ f"say which it was: exactly one of --committed or --failed "
222
+ f"({'|'.join(sorted(RESOLUTIONS))} are the only answers)"
223
+ )
224
+ outcome = EffectState.COMMITTED if committed else EffectState.FAILED
225
+ store = _store()
226
+ try:
227
+ record = store.resolve_effect(effect_key, outcome, CLI_APPROVER)
228
+ store.append_event(
229
+ _event(
230
+ EventType.EFFECT_RESOLVED,
231
+ record.action_id,
232
+ effect_key=effect_key,
233
+ state=str(record.state),
234
+ resolver=CLI_APPROVER,
235
+ )
236
+ )
237
+ except CTRLRunError as exc:
238
+ raise _fail(exc) from exc
239
+ click.echo(f"{effect_key} resolved {record.state} by {CLI_APPROVER}")
240
+ if record.state is EffectState.FAILED:
241
+ click.echo("a retry of this effect is now permitted")
242
+
243
+
244
+ def _receipt_line(receipt: Receipt) -> str:
245
+ return (
246
+ f"{iso_timestamp(receipt.finished_at)} {receipt.receipt_id} {receipt.action} "
247
+ f"{receipt.decision}/{receipt.result} {receipt.effect_key or '-'} "
248
+ f"{receipt.principal.agent}"
249
+ )
250
+
251
+
252
+ def _effect_line(record: EffectRecord) -> str:
253
+ return (
254
+ f"{record.effect_key} {record.state} attempt {record.attempt} "
255
+ f"{record.action_id} {iso_timestamp(record.updated_at)}"
256
+ )
257
+
258
+
259
+ if __name__ == "__main__": # pragma: no cover
260
+ main()