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/effect.py ADDED
@@ -0,0 +1,296 @@
1
+ """Effect keys and effect records. Build-list items 5 and 6; SPEC-v0.1 §5.
2
+
3
+ The effect key is what duplicate protection is built on (§5.3): two attempts that resolve to
4
+ the same key are the same real-world effect, whatever their `action_id`s are. Resolution is
5
+ therefore strict — an unresolvable key is never a silent `None`, and a placeholder that
6
+ resolves to nothing identifiable is refused rather than rendered.
7
+
8
+ `plan_reservation` is the retry table of §5.4 as one pure function. Both StateStores decide
9
+ with it and then only write, so the rule that refuses a duplicate lives in exactly one place
10
+ and the in-memory store cannot drift into permitting what SQLite refuses.
11
+
12
+ The transitions of §5.2 are in `state.py`, where the records live. `ctrlrun resolve` — the
13
+ only way out of `AMBIGUOUS`, because it is the only one a human drives — arrives with item 8.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ from collections.abc import Mapping
20
+ from dataclasses import dataclass, replace
21
+ from datetime import datetime, timedelta
22
+ from enum import StrEnum
23
+ from typing import Any, Final
24
+
25
+ from .action import Action
26
+ from .errors import (
27
+ AmbiguousEffect,
28
+ CTRLRunError,
29
+ DuplicateEffect,
30
+ EffectKeyError,
31
+ InvalidArgument,
32
+ )
33
+
34
+ #: The decision reason recorded when an effect template cannot be resolved (SPEC §5.1, §6.1).
35
+ UNRESOLVED_EFFECT: Final = "effect_key_error"
36
+
37
+ #: SPEC-v0.1 §5.3 E3 — a reservation is held for five minutes unless told otherwise.
38
+ DEFAULT_LEASE: Final = timedelta(minutes=5)
39
+
40
+ #: `DuplicateEffect.state` values (SPEC-v0.1 §5.4).
41
+ COMMITTED_EFFECT: Final = "committed"
42
+ IN_PROGRESS_EFFECT: Final = "in_progress"
43
+
44
+ #: Recorded on a record whose holder disappeared with its lease still held (§5.3 E3).
45
+ LEASE_EXPIRED: Final = "lease expired: the worker holding this effect never finished"
46
+
47
+ #: In an effect template, `{resource}` names the action's `resource` field (SPEC §5.1).
48
+ RESOURCE_PLACEHOLDER: Final = "resource"
49
+
50
+ #: A template is literal text and `{name}` placeholders. Anything else is a typo. `name` is
51
+ #: an identifier — a letter or underscore, then letters, digits or underscores — because it
52
+ #: names an argument, and an argument name is a Python parameter name.
53
+ _TOKEN: Final = re.compile(r"\{(?P<name>[^\W\d]\w*)\}|(?P<text>[^{}]+)|(?P<bad>.)")
54
+
55
+
56
+ class EffectState(StrEnum):
57
+ """Where a logical effect stands (SPEC-v0.1 §5.2).
58
+
59
+ `NEW` is the state of a key nobody has reserved: it is never written to a store, which
60
+ reports it as no record at all. `AMBIGUOUS` never collapses to `FAILED`; only a human
61
+ moves a record out of it.
62
+ """
63
+
64
+ NEW = "new"
65
+ RESERVED = "reserved"
66
+ EXECUTING = "executing"
67
+ COMMITTED = "committed"
68
+ FAILED = "failed"
69
+ AMBIGUOUS = "ambiguous"
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class EffectRecord:
74
+ """What a StateStore holds for one effect key (ARCHITECTURE §5)."""
75
+
76
+ effect_key: str
77
+ state: EffectState
78
+ action_id: str
79
+ attempt: int
80
+ created_at: datetime
81
+ updated_at: datetime
82
+ lease_expires_at: datetime | None = None
83
+ result: Any = None
84
+ error: str | None = None
85
+
86
+ def lease_is_live(self, now: datetime) -> bool:
87
+ """Whether an attempt is still holding this effect (SPEC-v0.1 §5.3 E3).
88
+
89
+ A `RESERVED` or `EXECUTING` record with no lease at all counts as expired: the
90
+ conservative reading, since an expired lease is refused more firmly than a live one.
91
+ """
92
+ if self.state not in (EffectState.RESERVED, EffectState.EXECUTING):
93
+ return False
94
+ return self.lease_expires_at is not None and now <= self.lease_expires_at
95
+
96
+
97
+ @dataclass(frozen=True)
98
+ class Reservation:
99
+ """The right to execute one effect once, until `lease_expires_at` (SPEC-v0.1 §5.3)."""
100
+
101
+ effect_key: str
102
+ action_id: str
103
+ attempt: int
104
+ lease_expires_at: datetime
105
+
106
+
107
+ @dataclass(frozen=True)
108
+ class ReservationPlan:
109
+ """What a store must do about one reservation attempt (SPEC-v0.1 §5.4).
110
+
111
+ Exactly one of `reservation` and `refusal` is set. `renews` means an existing `FAILED`
112
+ record is being retried, so the store updates rather than inserts — an insert that hits
113
+ the `UNIQUE(effect_key)` constraint is then a real violation, not an expected one.
114
+ `ambiguate` means the existing record must first be moved to `AMBIGUOUS`, and that write
115
+ kept even though the attempt is refused: an expired lease is evidence (§5.3 E3).
116
+ """
117
+
118
+ reservation: Reservation | None = None
119
+ refusal: CTRLRunError | None = None
120
+ renews: bool = False
121
+ ambiguate: bool = False
122
+
123
+
124
+ def plan_reservation(
125
+ record: EffectRecord | None,
126
+ effect_key: str,
127
+ action_id: str,
128
+ lease: timedelta,
129
+ now: datetime,
130
+ ) -> ReservationPlan:
131
+ """Apply the retry table of SPEC-v0.1 §5.4 to one reservation attempt.
132
+
133
+ Pure: it reads a record and returns what to do. Every store decides here, so `FAILED` is
134
+ the only state that lets a second attempt through, in one place rather than in each.
135
+ """
136
+ if not effect_key:
137
+ raise InvalidArgument("effect_key must be a non-empty string")
138
+ if not action_id:
139
+ raise InvalidArgument("action_id must be a non-empty string")
140
+ if lease <= timedelta(0):
141
+ raise InvalidArgument(f"a reservation lease must be positive, got {lease!r}")
142
+
143
+ granted = Reservation(
144
+ effect_key=effect_key,
145
+ action_id=action_id,
146
+ attempt=1,
147
+ lease_expires_at=now + lease,
148
+ )
149
+ if record is None or record.state is EffectState.NEW:
150
+ return ReservationPlan(reservation=granted)
151
+ if record.state is EffectState.COMMITTED:
152
+ return ReservationPlan(
153
+ refusal=DuplicateEffect(
154
+ f"effect {effect_key!r} was already committed by {record.action_id}",
155
+ state=COMMITTED_EFFECT,
156
+ effect_key=effect_key,
157
+ )
158
+ )
159
+ if record.state is EffectState.AMBIGUOUS:
160
+ return ReservationPlan(
161
+ refusal=AmbiguousEffect(
162
+ f"effect {effect_key!r} has an unknown outcome from {record.action_id}; "
163
+ f"resolve it with 'ctrlrun resolve {effect_key}' before retrying",
164
+ effect_key=effect_key,
165
+ action_id=record.action_id,
166
+ )
167
+ )
168
+ if record.state is EffectState.FAILED:
169
+ # SPEC §5.4 — the only automatic retry: the executor proved nothing happened (§5.5).
170
+ return ReservationPlan(
171
+ reservation=replace(granted, attempt=record.attempt + 1), renews=True
172
+ )
173
+ if record.lease_is_live(now):
174
+ return ReservationPlan(
175
+ refusal=DuplicateEffect(
176
+ f"effect {effect_key!r} is {record.state} under {record.action_id}",
177
+ state=IN_PROGRESS_EFFECT,
178
+ effect_key=effect_key,
179
+ )
180
+ )
181
+ # SPEC §5.3 E3 — the lease expired mid-flight. The remote may have committed, so the
182
+ # record becomes AMBIGUOUS; it is never silently released to the next caller.
183
+ return ReservationPlan(
184
+ refusal=AmbiguousEffect(
185
+ f"effect {effect_key!r} was left {record.state} by {record.action_id} and its "
186
+ f"lease expired; resolve it with 'ctrlrun resolve {effect_key}'",
187
+ effect_key=effect_key,
188
+ action_id=record.action_id,
189
+ ),
190
+ ambiguate=True,
191
+ )
192
+
193
+
194
+ def template_placeholders(template: str) -> tuple[str, ...]:
195
+ """Return the placeholder names in `template`, in order. Malformed → `InvalidArgument`.
196
+
197
+ The grammar is deliberately smaller than `str.format`: no `{{` escapes, no format specs,
198
+ no attribute or index access. An effect key is an identity, not a formatted string, so a
199
+ brace that is not part of a `{name}` placeholder is a typo — and a typo must not become
200
+ part of an effect identity.
201
+ """
202
+ if not template:
203
+ raise InvalidArgument("a template must be a non-empty string")
204
+ names: list[str] = []
205
+ for token in _TOKEN.finditer(template):
206
+ if token.group("bad") is not None:
207
+ raise InvalidArgument(
208
+ f"{template!r} is not a valid template: expected literal text and "
209
+ f"'{{name}}' placeholders, found {token.group('bad')!r} at position "
210
+ f"{token.start()}"
211
+ )
212
+ name = token.group("name")
213
+ if name is not None:
214
+ names.append(name)
215
+ return tuple(names)
216
+
217
+
218
+ def resolve_effect_key(template: str, action: Action) -> str:
219
+ """Resolve an effect template against a constructed action (SPEC-v0.1 §5.1).
220
+
221
+ Placeholders name the action's arguments; `{resource}` names its `resource` field. A
222
+ placeholder with no value raises `EffectKeyError`, and the action is refused: an action
223
+ whose logical effect cannot be identified cannot be protected against duplication.
224
+ """
225
+ names = template_placeholders(template)
226
+ values: dict[str, Any] = action.canonical_arguments
227
+ if RESOURCE_PLACEHOLDER in names:
228
+ if RESOURCE_PLACEHOLDER in values:
229
+ # SPEC: §5.1 — the spec gives `{resource}` to the resource field but does not say
230
+ # what an argument of the same name does. Two candidate values for one key is the
231
+ # fail-closed case: refuse, rather than silently pick the one the author did not
232
+ # mean, because duplicate protection depends on which one it is.
233
+ raise EffectKeyError(
234
+ f"{template!r}: '{{resource}}' is ambiguous — {action.name} has both a "
235
+ "resource field and an argument named 'resource'; rename the argument"
236
+ )
237
+ if action.resource is None:
238
+ raise EffectKeyError(
239
+ f"{template!r}: '{{resource}}' needs a resource on the action, and "
240
+ f"{action.name} has none"
241
+ )
242
+ values[RESOURCE_PLACEHOLDER] = action.resource
243
+ return _render(template, values, EffectKeyError)
244
+
245
+
246
+ def resolve_resource(template: str, arguments: Mapping[str, Any]) -> str:
247
+ """Resolve a `resource=` template against the bound call arguments (SPEC-v0.1 §5.1).
248
+
249
+ Same syntax and same resolver as an effect template, but resolved *before* the Action
250
+ exists: `resource` is part of the canonical form and therefore of the action hash (§2.2),
251
+ so a missing placeholder is an `InvalidArgument` at construction time, as in §2.
252
+ """
253
+ return _render(template, arguments, InvalidArgument)
254
+
255
+
256
+ def _render(template: str, values: Mapping[str, Any], error: type[CTRLRunError]) -> str:
257
+ """Substitute `values` into `template`, raising `error` for anything unresolvable."""
258
+ parts: list[str] = []
259
+ for token in _TOKEN.finditer(template):
260
+ text = token.group("text")
261
+ if text is not None:
262
+ parts.append(text)
263
+ continue
264
+ name = token.group("name")
265
+ if name is None:
266
+ raise InvalidArgument(
267
+ f"{template!r} is not a valid template: found {token.group('bad')!r} at "
268
+ f"position {token.start()}"
269
+ )
270
+ if name not in values:
271
+ raise error(
272
+ f"{template!r}: no value for '{{{name}}}' (have: "
273
+ f"{', '.join(sorted(values)) or 'nothing'})"
274
+ )
275
+ parts.append(_rendered(name, values[name], template, error))
276
+ return "".join(parts)
277
+
278
+
279
+ def _rendered(name: str, value: object, template: str, error: type[CTRLRunError]) -> str:
280
+ """Render one placeholder value, or raise: only a non-empty `str` or an `int` will do.
281
+
282
+ SPEC: §5.1 — the spec does not restrict placeholder types. This is the fail-closed
283
+ reading: `None` and `""` identify nothing and would collide across unrelated actions,
284
+ `bool` identifies nothing either, and a container has no stable rendering. An effect key
285
+ must be an identity a human can read and two attempts can agree on.
286
+ """
287
+ if isinstance(value, str):
288
+ if not value:
289
+ raise error(f"{template!r}: '{{{name}}}' is empty; an effect key must identify")
290
+ return value
291
+ if isinstance(value, int) and not isinstance(value, bool):
292
+ return str(value)
293
+ raise error(
294
+ f"{template!r}: '{{{name}}}' is {type(value).__name__}; a placeholder must resolve "
295
+ "to a non-empty string or an int"
296
+ )
ctrlrun/errors.py ADDED
@@ -0,0 +1,114 @@
1
+ """Exception hierarchy for the public API. SPEC-v0.1 §8."""
2
+
3
+
4
+ class CTRLRunError(Exception):
5
+ """Base class for every error raised by CTRLRun."""
6
+
7
+
8
+ class InvalidArgument(CTRLRunError):
9
+ """An argument cannot be accepted as given.
10
+
11
+ An Action field or argument that cannot be canonicalized (SPEC-v0.1 §2.3), and — the
12
+ same kind of wiring bug — a StateStore transition no record can make, such as committing
13
+ an effect nobody reserved.
14
+ """
15
+
16
+
17
+ class PolicyError(CTRLRunError):
18
+ """The policy is missing, unreadable, or malformed. Raised at load time (SPEC-v0.1 §3.4)."""
19
+
20
+
21
+ class EffectKeyError(CTRLRunError):
22
+ """An effect template cannot be resolved to a key (SPEC-v0.1 §5.1).
23
+
24
+ The action is refused rather than executed without an effect key: an action whose
25
+ logical effect cannot be identified cannot be protected against duplication.
26
+ """
27
+
28
+
29
+ class ActionDenied(CTRLRunError):
30
+ """The action may not run. `reason` says why, e.g. `unknown_action` (SPEC-v0.1 §3.4)."""
31
+
32
+ def __init__(
33
+ self, message: str | None = None, *, reason: str, action_id: str | None = None
34
+ ) -> None:
35
+ super().__init__(message if message is not None else reason)
36
+ self.reason = reason
37
+ self.action_id = action_id
38
+
39
+
40
+ class ApprovalRequired(CTRLRunError):
41
+ """The action needs a human. `request_id` is what `ctrlrun approve` takes (SPEC §4.3).
42
+
43
+ Raised instead of blocking, so an agent loop can surface the request and come back with
44
+ `ctrlrun.with_approval(request_id)` in context.
45
+ """
46
+
47
+ def __init__(
48
+ self, message: str | None = None, *, request_id: str, action_id: str | None = None
49
+ ) -> None:
50
+ super().__init__(message if message is not None else request_id)
51
+ self.request_id = request_id
52
+ self.action_id = action_id
53
+
54
+
55
+ class ApprovalTimeout(CTRLRunError):
56
+ """Nobody answered the approval request in time (SPEC-v0.1 §4.3)."""
57
+
58
+ def __init__(self, message: str | None = None, *, request_id: str) -> None:
59
+ super().__init__(message if message is not None else request_id)
60
+ self.request_id = request_id
61
+
62
+
63
+ class ApprovalMismatch(CTRLRunError):
64
+ """The presented approval does not authorize this action (SPEC-v0.1 §4.2).
65
+
66
+ `reason` is one of `unknown`, `mismatch`, or the status the record was in — `consumed`,
67
+ `expired`, `pending`, `denied`.
68
+ """
69
+
70
+ def __init__(
71
+ self, message: str | None = None, *, reason: str, approval_id: str | None = None
72
+ ) -> None:
73
+ super().__init__(message if message is not None else reason)
74
+ self.reason = reason
75
+ self.approval_id = approval_id
76
+
77
+
78
+ class DuplicateEffect(CTRLRunError):
79
+ """This logical effect already happened, or is happening now (SPEC-v0.1 §5.4).
80
+
81
+ `state` is `committed` — the effect is done — or `in_progress`, meaning another attempt
82
+ holds a live reservation on the key. Neither permits a second execution.
83
+ """
84
+
85
+ def __init__(
86
+ self, message: str | None = None, *, state: str, effect_key: str | None = None
87
+ ) -> None:
88
+ super().__init__(message if message is not None else state)
89
+ self.state = state
90
+ self.effect_key = effect_key
91
+
92
+
93
+ class AmbiguousEffect(CTRLRunError):
94
+ """The outcome of this effect is unknown; only a human may resolve it (SPEC-v0.1 §5.4).
95
+
96
+ Raised for a record already in `AMBIGUOUS`, and for one whose lease expired mid-flight:
97
+ the worker may have died after the remote committed. A retry is refused either way,
98
+ until `ctrlrun resolve` says which it was.
99
+ """
100
+
101
+ def __init__(
102
+ self, message: str | None = None, *, effect_key: str, action_id: str | None = None
103
+ ) -> None:
104
+ super().__init__(message if message is not None else effect_key)
105
+ self.effect_key = effect_key
106
+ self.action_id = action_id
107
+
108
+
109
+ class NotExecuted(CTRLRunError):
110
+ """Raised by an executor to assert the remote side did nothing (SPEC-v0.1 §5.5).
111
+
112
+ This is the *only* exception that maps to `FAILED` and therefore permits a retry.
113
+ Every other exception is an `AMBIGUOUS` outcome.
114
+ """