apsimo-hostworker 0.2.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.
- apsimo_hostworker/__init__.py +134 -0
- apsimo_hostworker/_private_io.py +267 -0
- apsimo_hostworker/admission.py +297 -0
- apsimo_hostworker/catalog.py +754 -0
- apsimo_hostworker/client.py +292 -0
- apsimo_hostworker/compat.py +53 -0
- apsimo_hostworker/conformance/__init__.py +52 -0
- apsimo_hostworker/conformance/__main__.py +29 -0
- apsimo_hostworker/conformance/harness.py +278 -0
- apsimo_hostworker/conformance/suite.py +1109 -0
- apsimo_hostworker/contract.py +402 -0
- apsimo_hostworker/gate.py +446 -0
- apsimo_hostworker/intent.py +221 -0
- apsimo_hostworker/sqlite_store.py +1815 -0
- apsimo_hostworker/store.py +446 -0
- apsimo_hostworker/worker.py +643 -0
- apsimo_hostworker-0.2.0.dist-info/METADATA +24 -0
- apsimo_hostworker-0.2.0.dist-info/RECORD +22 -0
- apsimo_hostworker-0.2.0.dist-info/WHEEL +5 -0
- apsimo_hostworker-0.2.0.dist-info/licenses/LICENSE +21 -0
- apsimo_hostworker-0.2.0.dist-info/top_level.txt +2 -0
- colony_hostworker/__init__.py +4 -0
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
"""One-mutation-at-most governed action worker.
|
|
2
|
+
|
|
3
|
+
The worker is deliberately transport neutral: it drives any
|
|
4
|
+
:class:`~apsimo_hostworker.store.ActionStore` and any dispatcher exposing
|
|
5
|
+
``execute`` (one PUT) and ``observe`` (side-effect-free GET), such as
|
|
6
|
+
:class:`~apsimo_hostworker.client.GovernedActionClient`.
|
|
7
|
+
|
|
8
|
+
The state machine, unchanged from the deployed reference implementation:
|
|
9
|
+
|
|
10
|
+
lease -> parse immutable intent -> validate owner gate -> check
|
|
11
|
+
admission -> ATOMICALLY consume the gate (store re-validates it inside
|
|
12
|
+
the dispatch transaction) -> exactly one PUT -> bounded GET-only
|
|
13
|
+
reconciliation -> read-only verification -> complete.
|
|
14
|
+
|
|
15
|
+
Invariants the worker upholds (its half of the one-mutation guarantee; the
|
|
16
|
+
store's half is specified in :mod:`apsimo_hostworker.store`):
|
|
17
|
+
|
|
18
|
+
* a mutation is NEVER retried after dispatch — any doubt after the PUT is
|
|
19
|
+
resolved exclusively through the bounded GET-only observation contract;
|
|
20
|
+
* a post-effect retry is read-only verification only;
|
|
21
|
+
* exactly one owner gate receipt is required, validated both before and —
|
|
22
|
+
via the ``gate_validator`` parameter — inside the dispatch transaction;
|
|
23
|
+
* an authorization expired at point of use never dispatches;
|
|
24
|
+
* the dispatch admission is consulted immediately before consuming the
|
|
25
|
+
gate, and refusal defers without consuming anything.
|
|
26
|
+
|
|
27
|
+
What a host still writes: action ingress (proposing envelopes and writing
|
|
28
|
+
gate receipts from its own approval capture — deliberately out of scope
|
|
29
|
+
here), credential/admission provisioning, and the scheduling loop that
|
|
30
|
+
calls :meth:`GovernedActionWorker.process_one`.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import hmac
|
|
36
|
+
import math
|
|
37
|
+
import os
|
|
38
|
+
import re
|
|
39
|
+
import uuid
|
|
40
|
+
from typing import Any, Iterable, Mapping
|
|
41
|
+
|
|
42
|
+
from .catalog import ACTION_TOOL_NAMES, TOOL_CATALOG
|
|
43
|
+
from .contract import (
|
|
44
|
+
APPROVAL_BINDING_SCHEMA,
|
|
45
|
+
EFFECT_FIELDS,
|
|
46
|
+
EFFECT_SCHEMA,
|
|
47
|
+
EXECUTION_REQUEST_SCHEMA,
|
|
48
|
+
EXECUTION_RESULT_FIELDS,
|
|
49
|
+
EXECUTION_RESULT_MAX_BYTES,
|
|
50
|
+
EXECUTION_RESULT_SCHEMA,
|
|
51
|
+
GATE_CLOCK_SKEW_SECONDS,
|
|
52
|
+
INTENT_ENVELOPE_SCHEMA,
|
|
53
|
+
SAFE_ID_RE,
|
|
54
|
+
SHA256_RE,
|
|
55
|
+
canonical_json_utf8,
|
|
56
|
+
sha256_json_utf8,
|
|
57
|
+
)
|
|
58
|
+
from .gate import (
|
|
59
|
+
DEFAULT_REGISTRY,
|
|
60
|
+
GateAuthorization,
|
|
61
|
+
ProvenanceShapeRegistry,
|
|
62
|
+
assert_dispatchable,
|
|
63
|
+
validate_owner_gate,
|
|
64
|
+
)
|
|
65
|
+
from .intent import HermesActionIntentError, HermesToolActionIntentV1
|
|
66
|
+
from .store import ActionStoreError
|
|
67
|
+
|
|
68
|
+
# Durable-store conventions shared with the host's ingress (the component
|
|
69
|
+
# that proposes actions). Configurable per host, but the defaults are the
|
|
70
|
+
# published convention and should not be changed casually.
|
|
71
|
+
DEFAULT_SOURCE_PREFIX = "hermes-action-intent:"
|
|
72
|
+
DEFAULT_ACTION_TYPE = "hermes_tool_action"
|
|
73
|
+
|
|
74
|
+
OBSERVATION_MAX_ATTEMPTS = 8
|
|
75
|
+
OBSERVATION_WINDOW_SECONDS = 5 * 60
|
|
76
|
+
OBSERVATION_RETRY_SECONDS = 1.0
|
|
77
|
+
ADMISSION_DEFER_SECONDS = 1.0
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class GovernedActionWorkerError(RuntimeError):
|
|
81
|
+
"""The worker refused a configuration or an exchange."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _safe_id(value: Any, name: str) -> str:
|
|
85
|
+
if not isinstance(value, str) or not SAFE_ID_RE.fullmatch(value):
|
|
86
|
+
raise GovernedActionWorkerError("%s is not a safe identifier" % name)
|
|
87
|
+
return value
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _sha(value: Any, name: str) -> str:
|
|
91
|
+
if not isinstance(value, str) or not SHA256_RE.fullmatch(value):
|
|
92
|
+
raise GovernedActionWorkerError("%s is not a SHA-256 digest" % name)
|
|
93
|
+
return value
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _bounded_verification(value: Any, *, depth=0, counter=None) -> Any:
|
|
97
|
+
if counter is None:
|
|
98
|
+
counter = [0]
|
|
99
|
+
counter[0] += 1
|
|
100
|
+
if counter[0] > 256 or depth > 6:
|
|
101
|
+
raise GovernedActionWorkerError("effect verification is too complex")
|
|
102
|
+
if value is None or isinstance(value, bool):
|
|
103
|
+
return value
|
|
104
|
+
if isinstance(value, int):
|
|
105
|
+
if abs(value) > (1 << 63) - 1:
|
|
106
|
+
raise GovernedActionWorkerError("effect verification integer is unsafe")
|
|
107
|
+
return value
|
|
108
|
+
if isinstance(value, float):
|
|
109
|
+
if not math.isfinite(value):
|
|
110
|
+
raise GovernedActionWorkerError("effect verification number is unsafe")
|
|
111
|
+
return value
|
|
112
|
+
if isinstance(value, str):
|
|
113
|
+
if len(value) > 2048 or any(
|
|
114
|
+
ord(character) < 0x20 or ord(character) == 0x7F
|
|
115
|
+
for character in value
|
|
116
|
+
):
|
|
117
|
+
raise GovernedActionWorkerError("effect verification text is unsafe")
|
|
118
|
+
return value
|
|
119
|
+
if isinstance(value, list):
|
|
120
|
+
return [
|
|
121
|
+
_bounded_verification(item, depth=depth + 1, counter=counter)
|
|
122
|
+
for item in value
|
|
123
|
+
]
|
|
124
|
+
if isinstance(value, Mapping):
|
|
125
|
+
result = {}
|
|
126
|
+
for key, item in value.items():
|
|
127
|
+
key = _safe_id(key, "verification key")
|
|
128
|
+
result[key] = _bounded_verification(
|
|
129
|
+
item, depth=depth + 1, counter=counter
|
|
130
|
+
)
|
|
131
|
+
return result
|
|
132
|
+
raise GovernedActionWorkerError("effect verification is not JSON")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def build_execution_request(
|
|
136
|
+
action: Mapping[str, Any],
|
|
137
|
+
intent: HermesToolActionIntentV1,
|
|
138
|
+
authorization: GateAuthorization,
|
|
139
|
+
) -> dict[str, Any]:
|
|
140
|
+
"""Build the immutable ``ColonyGovernedActionExecutionV1`` document.
|
|
141
|
+
|
|
142
|
+
The ``execution_digest`` (UTF-8 canonical-JSON convention) pins the
|
|
143
|
+
whole request; the store's recovery contract and the endpoint's durable
|
|
144
|
+
ledger are both keyed by it.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
approval = {
|
|
148
|
+
"schema": APPROVAL_BINDING_SCHEMA,
|
|
149
|
+
"version": 1,
|
|
150
|
+
"approval_id": authorization.approval_id,
|
|
151
|
+
"decision_id": authorization.decision_id,
|
|
152
|
+
"revision": authorization.revision,
|
|
153
|
+
"authorization_receipt_sha256": authorization.evidence_sha256,
|
|
154
|
+
"decided_at": authorization.decided_at,
|
|
155
|
+
"expires_at": authorization.expires_at,
|
|
156
|
+
}
|
|
157
|
+
unsigned = {
|
|
158
|
+
"schema": EXECUTION_REQUEST_SCHEMA,
|
|
159
|
+
"version": 1,
|
|
160
|
+
"action_id": action["action_id"],
|
|
161
|
+
"action_digest": action["payload_sha256"],
|
|
162
|
+
"intent_id": intent.intent_id,
|
|
163
|
+
"intent_digest": intent.intent_digest,
|
|
164
|
+
"tool_name": intent.tool_name,
|
|
165
|
+
"args": intent.args,
|
|
166
|
+
"args_sha256": intent.args_sha256,
|
|
167
|
+
"approval": approval,
|
|
168
|
+
}
|
|
169
|
+
return {**unsigned, "execution_digest": sha256_json_utf8(unsigned)}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def validate_execution_result(
|
|
173
|
+
value: Any,
|
|
174
|
+
request: Mapping[str, Any],
|
|
175
|
+
*,
|
|
176
|
+
clock,
|
|
177
|
+
) -> dict[str, Any]:
|
|
178
|
+
"""Validate one endpoint projection as a COMPLETED, digest-bound result.
|
|
179
|
+
|
|
180
|
+
Anything that is not a completed, performed, fully digest-bound
|
|
181
|
+
projection of exactly this request raises — including transient
|
|
182
|
+
``prepared`` / ``executing`` projections, which the caller treats as
|
|
183
|
+
unresolved observation, never as failure and NEVER as license to retry
|
|
184
|
+
the mutation.
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
if not isinstance(value, Mapping) or set(value) != EXECUTION_RESULT_FIELDS:
|
|
188
|
+
raise GovernedActionWorkerError("execution response fields are invalid")
|
|
189
|
+
if (
|
|
190
|
+
value.get("schema") != EXECUTION_RESULT_SCHEMA
|
|
191
|
+
or isinstance(value.get("version"), bool)
|
|
192
|
+
or value.get("version") != 1
|
|
193
|
+
or value.get("status") != "completed"
|
|
194
|
+
or value.get("effect_state") != "performed"
|
|
195
|
+
):
|
|
196
|
+
raise GovernedActionWorkerError("execution response state is invalid")
|
|
197
|
+
for field_name in ("execution_digest", "action_digest", "intent_digest"):
|
|
198
|
+
_sha(value.get(field_name), field_name)
|
|
199
|
+
if not hmac.compare_digest(
|
|
200
|
+
value[field_name], str(request.get(field_name) or "")
|
|
201
|
+
):
|
|
202
|
+
raise GovernedActionWorkerError("execution response is not bound")
|
|
203
|
+
for field_name in ("action_id", "intent_id", "tool_name"):
|
|
204
|
+
if value.get(field_name) != request.get(field_name):
|
|
205
|
+
raise GovernedActionWorkerError("execution response is not bound")
|
|
206
|
+
effect = value.get("effect")
|
|
207
|
+
if not isinstance(effect, Mapping) or set(effect) != EFFECT_FIELDS:
|
|
208
|
+
raise GovernedActionWorkerError("effect projection is invalid")
|
|
209
|
+
if (
|
|
210
|
+
effect.get("schema") != EFFECT_SCHEMA
|
|
211
|
+
or isinstance(effect.get("version"), bool)
|
|
212
|
+
or effect.get("version") != 1
|
|
213
|
+
):
|
|
214
|
+
raise GovernedActionWorkerError("effect schema is invalid")
|
|
215
|
+
_safe_id(effect.get("effect_id"), "effect_id")
|
|
216
|
+
_safe_id(effect.get("outcome"), "outcome")
|
|
217
|
+
verification = _bounded_verification(effect.get("verification"))
|
|
218
|
+
normalized_effect = {
|
|
219
|
+
"schema": EFFECT_SCHEMA,
|
|
220
|
+
"version": 1,
|
|
221
|
+
"effect_id": effect["effect_id"],
|
|
222
|
+
"outcome": effect["outcome"],
|
|
223
|
+
"verification": verification,
|
|
224
|
+
}
|
|
225
|
+
effect_digest = _sha(value.get("effect_digest"), "effect_digest")
|
|
226
|
+
if not hmac.compare_digest(effect_digest, sha256_json_utf8(normalized_effect)):
|
|
227
|
+
raise GovernedActionWorkerError("effect digest does not match")
|
|
228
|
+
observed_at = value.get("observed_at")
|
|
229
|
+
if isinstance(observed_at, bool) or not isinstance(observed_at, (int, float)):
|
|
230
|
+
raise GovernedActionWorkerError("observation time is invalid")
|
|
231
|
+
observed_at = float(observed_at)
|
|
232
|
+
now = float(clock())
|
|
233
|
+
if (
|
|
234
|
+
not math.isfinite(observed_at)
|
|
235
|
+
or observed_at <= 0
|
|
236
|
+
or observed_at > now + GATE_CLOCK_SKEW_SECONDS
|
|
237
|
+
):
|
|
238
|
+
raise GovernedActionWorkerError("observation time is invalid")
|
|
239
|
+
normalized = dict(value)
|
|
240
|
+
normalized["effect"] = normalized_effect
|
|
241
|
+
normalized["observed_at"] = observed_at
|
|
242
|
+
if len(canonical_json_utf8(normalized).encode("utf-8")) > EXECUTION_RESULT_MAX_BYTES:
|
|
243
|
+
raise GovernedActionWorkerError("execution response is too large")
|
|
244
|
+
return normalized
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class GovernedActionWorker:
|
|
248
|
+
"""One-mutation-at-most worker with read-only verification recovery."""
|
|
249
|
+
|
|
250
|
+
def __init__(
|
|
251
|
+
self,
|
|
252
|
+
store,
|
|
253
|
+
dispatcher,
|
|
254
|
+
admission,
|
|
255
|
+
*,
|
|
256
|
+
enabled_tools: Iterable[str],
|
|
257
|
+
admission_principals: Iterable[str],
|
|
258
|
+
clock,
|
|
259
|
+
lease_seconds: float = 45.0,
|
|
260
|
+
owner: str = "",
|
|
261
|
+
registry: ProvenanceShapeRegistry = DEFAULT_REGISTRY,
|
|
262
|
+
catalog: Mapping[str, Any] = TOOL_CATALOG,
|
|
263
|
+
source_prefix: str = DEFAULT_SOURCE_PREFIX,
|
|
264
|
+
action_type: str = DEFAULT_ACTION_TYPE,
|
|
265
|
+
) -> None:
|
|
266
|
+
enabled = frozenset(str(item or "").strip() for item in enabled_tools)
|
|
267
|
+
principals = frozenset(
|
|
268
|
+
str(item or "").strip() for item in admission_principals
|
|
269
|
+
)
|
|
270
|
+
if not enabled or enabled - ACTION_TOOL_NAMES:
|
|
271
|
+
raise GovernedActionWorkerError("worker tool allowlist is invalid")
|
|
272
|
+
if not principals or any(
|
|
273
|
+
not SAFE_ID_RE.fullmatch(item) for item in principals
|
|
274
|
+
):
|
|
275
|
+
raise GovernedActionWorkerError("worker principal allowlist is invalid")
|
|
276
|
+
try:
|
|
277
|
+
lease = float(lease_seconds)
|
|
278
|
+
except (TypeError, ValueError) as error:
|
|
279
|
+
raise GovernedActionWorkerError("worker lease is invalid") from error
|
|
280
|
+
if not math.isfinite(lease) or not 5 <= lease <= 300:
|
|
281
|
+
raise GovernedActionWorkerError("worker lease is invalid")
|
|
282
|
+
if not hasattr(dispatcher, "execute") or not hasattr(dispatcher, "observe"):
|
|
283
|
+
raise GovernedActionWorkerError("worker dispatcher is invalid")
|
|
284
|
+
if not callable(clock):
|
|
285
|
+
raise GovernedActionWorkerError("worker clock is invalid")
|
|
286
|
+
if not isinstance(registry, ProvenanceShapeRegistry):
|
|
287
|
+
raise GovernedActionWorkerError("worker shape registry is invalid")
|
|
288
|
+
if not isinstance(source_prefix, str) or not source_prefix.strip():
|
|
289
|
+
raise GovernedActionWorkerError("worker source prefix is invalid")
|
|
290
|
+
if not isinstance(action_type, str) or not action_type.strip():
|
|
291
|
+
raise GovernedActionWorkerError("worker action type is invalid")
|
|
292
|
+
# The admission object stands between every leased gate and the one
|
|
293
|
+
# mutation; a missing or method-less admission must fail loudly at
|
|
294
|
+
# construction, never quietly at dispatch time. Hosts MUST pass a
|
|
295
|
+
# real DispatchAdmission (e.g. FileDispatchAdmission) — a stub that
|
|
296
|
+
# always returns removes the operator kill-switch.
|
|
297
|
+
assert_live = getattr(admission, "assert_live", None)
|
|
298
|
+
if admission is None or not callable(assert_live) or not hasattr(
|
|
299
|
+
assert_live, "__self__"
|
|
300
|
+
):
|
|
301
|
+
raise GovernedActionWorkerError("worker dispatch admission is invalid")
|
|
302
|
+
self.store = store
|
|
303
|
+
self.dispatcher = dispatcher
|
|
304
|
+
self.admission = admission
|
|
305
|
+
self.enabled_tools = enabled
|
|
306
|
+
self.admission_principals = principals
|
|
307
|
+
self.clock = clock
|
|
308
|
+
self.lease_seconds = lease
|
|
309
|
+
self.registry = registry
|
|
310
|
+
self.catalog = catalog
|
|
311
|
+
self.source_prefix = source_prefix
|
|
312
|
+
self.action_type = action_type
|
|
313
|
+
self.owner = owner or "governed-action-worker:%d:%s" % (
|
|
314
|
+
os.getpid(),
|
|
315
|
+
uuid.uuid4().hex[:12],
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
# ------------------------------------------------------------- leasing
|
|
319
|
+
|
|
320
|
+
def _sources(self) -> tuple[str, ...]:
|
|
321
|
+
"""Exact source values this worker owns: one per admitted principal.
|
|
322
|
+
|
|
323
|
+
Ownership is exact-match (``prefix + principal``), never bare
|
|
324
|
+
prefix-match, so a look-alike principal cannot smuggle work into an
|
|
325
|
+
admitted worker.
|
|
326
|
+
"""
|
|
327
|
+
|
|
328
|
+
return tuple(
|
|
329
|
+
sorted(self.source_prefix + principal
|
|
330
|
+
for principal in self.admission_principals)
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
def _lease(self):
|
|
334
|
+
for source in self._sources():
|
|
335
|
+
self.store.recover_expired_leases(
|
|
336
|
+
source=source, action_type=self.action_type
|
|
337
|
+
)
|
|
338
|
+
for state in ("verified", "accepted"):
|
|
339
|
+
for source in self._sources():
|
|
340
|
+
leased = self.store.lease_next(
|
|
341
|
+
self.owner,
|
|
342
|
+
lease_seconds=self.lease_seconds,
|
|
343
|
+
states=(state,),
|
|
344
|
+
source=source,
|
|
345
|
+
action_type=self.action_type,
|
|
346
|
+
)
|
|
347
|
+
if leased is not None:
|
|
348
|
+
return leased
|
|
349
|
+
for source in self._sources():
|
|
350
|
+
leased = self.store.lease_dispatched_observation(
|
|
351
|
+
self.owner,
|
|
352
|
+
lease_seconds=self.lease_seconds,
|
|
353
|
+
source=source,
|
|
354
|
+
action_type=self.action_type,
|
|
355
|
+
)
|
|
356
|
+
if leased is not None:
|
|
357
|
+
return leased
|
|
358
|
+
for source in self._sources():
|
|
359
|
+
leased = self.store.lease_next(
|
|
360
|
+
self.owner,
|
|
361
|
+
lease_seconds=self.lease_seconds,
|
|
362
|
+
states=("gated",),
|
|
363
|
+
source=source,
|
|
364
|
+
action_type=self.action_type,
|
|
365
|
+
)
|
|
366
|
+
if leased is not None:
|
|
367
|
+
return leased
|
|
368
|
+
return None
|
|
369
|
+
|
|
370
|
+
# ------------------------------------------------------------- parsing
|
|
371
|
+
|
|
372
|
+
def _parse(self, action: Mapping[str, Any]) -> HermesToolActionIntentV1:
|
|
373
|
+
payload = action.get("payload")
|
|
374
|
+
if (
|
|
375
|
+
not isinstance(payload, Mapping)
|
|
376
|
+
or set(payload) != {"schema", "version", "intent"}
|
|
377
|
+
or payload.get("schema") != INTENT_ENVELOPE_SCHEMA
|
|
378
|
+
or isinstance(payload.get("version"), bool)
|
|
379
|
+
or payload.get("version") != 1
|
|
380
|
+
or action.get("max_attempts") != 1
|
|
381
|
+
):
|
|
382
|
+
raise GovernedActionWorkerError("action envelope is invalid")
|
|
383
|
+
try:
|
|
384
|
+
intent = HermesToolActionIntentV1.from_mapping(payload["intent"])
|
|
385
|
+
except HermesActionIntentError as error:
|
|
386
|
+
raise GovernedActionWorkerError("action intent is invalid") from error
|
|
387
|
+
if (
|
|
388
|
+
intent.tool_name not in self.enabled_tools
|
|
389
|
+
or action.get("source") not in self._sources()
|
|
390
|
+
or action.get("action_type") != self.action_type
|
|
391
|
+
or action.get("source_ref") != intent.intent_id
|
|
392
|
+
or action.get("payload_sha256") != sha256_json_utf8(dict(payload))
|
|
393
|
+
):
|
|
394
|
+
raise GovernedActionWorkerError("action binding is invalid")
|
|
395
|
+
return intent
|
|
396
|
+
|
|
397
|
+
def _validate_gate(
|
|
398
|
+
self,
|
|
399
|
+
action: Mapping[str, Any],
|
|
400
|
+
receipts,
|
|
401
|
+
*,
|
|
402
|
+
tool_name: str,
|
|
403
|
+
now: float,
|
|
404
|
+
) -> GateAuthorization:
|
|
405
|
+
return validate_owner_gate(
|
|
406
|
+
action,
|
|
407
|
+
receipts,
|
|
408
|
+
tool_name=tool_name,
|
|
409
|
+
now=now,
|
|
410
|
+
registry=self.registry,
|
|
411
|
+
catalog=self.catalog,
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
def _gate_validator(self, tool_name: str):
|
|
415
|
+
"""The in-transaction re-validation the store runs (invariant I4).
|
|
416
|
+
|
|
417
|
+
Same validator as the pre-check, but executed against the durable
|
|
418
|
+
projections re-read inside the dispatch transaction and the store's
|
|
419
|
+
clock, with point-of-use expiry enforced by
|
|
420
|
+
:func:`~apsimo_hostworker.gate.assert_dispatchable`.
|
|
421
|
+
"""
|
|
422
|
+
|
|
423
|
+
def revalidate(action, receipts, now):
|
|
424
|
+
return assert_dispatchable(
|
|
425
|
+
self._validate_gate(
|
|
426
|
+
action, receipts, tool_name=tool_name, now=now
|
|
427
|
+
)
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
return revalidate
|
|
431
|
+
|
|
432
|
+
# ------------------------------------------------------------ recovery
|
|
433
|
+
|
|
434
|
+
def _fail(self, action_id: str, code: str):
|
|
435
|
+
safe = re.sub(r"[^a-z0-9_]+", "_", str(code).lower()).strip("_")
|
|
436
|
+
try:
|
|
437
|
+
return self.store.fail_attempt(
|
|
438
|
+
action_id,
|
|
439
|
+
self.owner,
|
|
440
|
+
safe[:160] or "governed_action_failed",
|
|
441
|
+
retryable=False,
|
|
442
|
+
)
|
|
443
|
+
except ActionStoreError:
|
|
444
|
+
return self._current(action_id)
|
|
445
|
+
|
|
446
|
+
def _current(self, action_id: str):
|
|
447
|
+
try:
|
|
448
|
+
return self.store.get_action(action_id)
|
|
449
|
+
except Exception:
|
|
450
|
+
# A lifecycle race must never cause a second mutation attempt.
|
|
451
|
+
return None
|
|
452
|
+
|
|
453
|
+
def _accept_execution(self, action, request, response):
|
|
454
|
+
acceptance_key = "governed-action-acceptance:" + request["execution_digest"]
|
|
455
|
+
try:
|
|
456
|
+
accepted, _ = self.store.accept(
|
|
457
|
+
action["action_id"],
|
|
458
|
+
self.owner,
|
|
459
|
+
acceptance_key,
|
|
460
|
+
"governed_action_acceptance",
|
|
461
|
+
response,
|
|
462
|
+
external_id=response["action_id"],
|
|
463
|
+
result={
|
|
464
|
+
"status": "accepted",
|
|
465
|
+
"execution_digest": request["execution_digest"],
|
|
466
|
+
"effect_digest": response["effect_digest"],
|
|
467
|
+
},
|
|
468
|
+
)
|
|
469
|
+
return accepted
|
|
470
|
+
except ActionStoreError:
|
|
471
|
+
return self._current(action["action_id"])
|
|
472
|
+
|
|
473
|
+
def _observe_dispatched(self, action, request):
|
|
474
|
+
"""Perform one durable, bounded GET attempt and never issue a PUT."""
|
|
475
|
+
|
|
476
|
+
try:
|
|
477
|
+
action, observation_receipt = self.store.begin_dispatched_observation(
|
|
478
|
+
action["action_id"],
|
|
479
|
+
self.owner,
|
|
480
|
+
request["execution_digest"],
|
|
481
|
+
)
|
|
482
|
+
except ActionStoreError:
|
|
483
|
+
return self._current(action["action_id"])
|
|
484
|
+
if action["state"] != "dispatched" or observation_receipt is None:
|
|
485
|
+
return action
|
|
486
|
+
try:
|
|
487
|
+
observed = validate_execution_result(
|
|
488
|
+
self.dispatcher.observe(request), request, clock=self.clock
|
|
489
|
+
)
|
|
490
|
+
except Exception:
|
|
491
|
+
try:
|
|
492
|
+
return self.store.defer_dispatched_observation(
|
|
493
|
+
action["action_id"],
|
|
494
|
+
self.owner,
|
|
495
|
+
request["execution_digest"],
|
|
496
|
+
observation_receipt["receipt_key"],
|
|
497
|
+
"endpoint_observation_unavailable_or_noncompleted",
|
|
498
|
+
OBSERVATION_RETRY_SECONDS,
|
|
499
|
+
)
|
|
500
|
+
except ActionStoreError:
|
|
501
|
+
return self._current(action["action_id"])
|
|
502
|
+
return self._accept_execution(action, request, observed)
|
|
503
|
+
|
|
504
|
+
# --------------------------------------------------------- the machine
|
|
505
|
+
|
|
506
|
+
def process_one(self):
|
|
507
|
+
"""Lease and advance at most one governed action; return its row.
|
|
508
|
+
|
|
509
|
+
Safe to call from a simple host loop; every step is individually
|
|
510
|
+
durable and crash-recoverable per the store contract.
|
|
511
|
+
"""
|
|
512
|
+
|
|
513
|
+
action = self._lease()
|
|
514
|
+
if action is None:
|
|
515
|
+
return None
|
|
516
|
+
try:
|
|
517
|
+
intent = self._parse(action)
|
|
518
|
+
gate = self._validate_gate(
|
|
519
|
+
action,
|
|
520
|
+
self.store.list_receipts(action["action_id"]),
|
|
521
|
+
tool_name=intent.tool_name,
|
|
522
|
+
now=float(self.clock()),
|
|
523
|
+
)
|
|
524
|
+
request = build_execution_request(action, intent, gate)
|
|
525
|
+
except Exception:
|
|
526
|
+
return self._fail(action["action_id"], "invalid_governed_action")
|
|
527
|
+
|
|
528
|
+
if action["state"] == "gated":
|
|
529
|
+
if gate.expired:
|
|
530
|
+
return self._fail(action["action_id"], "owner_approval_expired")
|
|
531
|
+
try:
|
|
532
|
+
self.admission.assert_live()
|
|
533
|
+
except Exception:
|
|
534
|
+
try:
|
|
535
|
+
return self.store.defer_leased(
|
|
536
|
+
action["action_id"],
|
|
537
|
+
self.owner,
|
|
538
|
+
"worker_dispatch_admission_closed",
|
|
539
|
+
ADMISSION_DEFER_SECONDS,
|
|
540
|
+
event_type="dispatch_admission_deferred",
|
|
541
|
+
)
|
|
542
|
+
except ActionStoreError:
|
|
543
|
+
return self._current(action["action_id"])
|
|
544
|
+
try:
|
|
545
|
+
action = self.store.begin_owner_authorized_dispatch(
|
|
546
|
+
action["action_id"],
|
|
547
|
+
self.owner,
|
|
548
|
+
gate_receipt_key=gate.receipt_key,
|
|
549
|
+
expected_source=action["source"],
|
|
550
|
+
expected_source_ref=intent.intent_id,
|
|
551
|
+
expected_action_type=self.action_type,
|
|
552
|
+
expected_payload=action["payload"],
|
|
553
|
+
expected_approval_id=gate.approval_id,
|
|
554
|
+
expected_decision_id=gate.decision_id,
|
|
555
|
+
expected_execution_digest=request["execution_digest"],
|
|
556
|
+
observation_window_seconds=OBSERVATION_WINDOW_SECONDS,
|
|
557
|
+
max_observations=OBSERVATION_MAX_ATTEMPTS,
|
|
558
|
+
gate_validator=self._gate_validator(intent.tool_name),
|
|
559
|
+
)
|
|
560
|
+
except Exception:
|
|
561
|
+
return self._fail(action["action_id"], "owner_gate_rejected")
|
|
562
|
+
try:
|
|
563
|
+
response = validate_execution_result(
|
|
564
|
+
self.dispatcher.execute(request), request, clock=self.clock
|
|
565
|
+
)
|
|
566
|
+
except Exception:
|
|
567
|
+
# The one PUT's outcome is unknown; from here on the only
|
|
568
|
+
# legal traffic for this action is GET.
|
|
569
|
+
action = self._observe_dispatched(action, request)
|
|
570
|
+
else:
|
|
571
|
+
action = self._accept_execution(action, request, response)
|
|
572
|
+
elif action["state"] == "dispatched":
|
|
573
|
+
action = self._observe_dispatched(action, request)
|
|
574
|
+
|
|
575
|
+
if action is None:
|
|
576
|
+
return None
|
|
577
|
+
|
|
578
|
+
acceptance_key = "governed-action-acceptance:" + request["execution_digest"]
|
|
579
|
+
if action["state"] == "accepted":
|
|
580
|
+
try:
|
|
581
|
+
observed = validate_execution_result(
|
|
582
|
+
self.dispatcher.observe(request), request, clock=self.clock
|
|
583
|
+
)
|
|
584
|
+
acceptance = next(
|
|
585
|
+
receipt
|
|
586
|
+
for receipt in self.store.list_receipts(action["action_id"])
|
|
587
|
+
if receipt["receipt_key"] == acceptance_key
|
|
588
|
+
)
|
|
589
|
+
if acceptance.get("evidence") != observed:
|
|
590
|
+
raise GovernedActionWorkerError(
|
|
591
|
+
"durable endpoint observation changed"
|
|
592
|
+
)
|
|
593
|
+
except Exception:
|
|
594
|
+
try:
|
|
595
|
+
return self.store.defer_leased(
|
|
596
|
+
action["action_id"],
|
|
597
|
+
self.owner,
|
|
598
|
+
"endpoint_verification_unavailable",
|
|
599
|
+
OBSERVATION_RETRY_SECONDS,
|
|
600
|
+
event_type="verification_deferred",
|
|
601
|
+
)
|
|
602
|
+
except ActionStoreError:
|
|
603
|
+
return self._current(action["action_id"])
|
|
604
|
+
try:
|
|
605
|
+
action, _ = self.store.verify(
|
|
606
|
+
action["action_id"],
|
|
607
|
+
self.owner,
|
|
608
|
+
"governed-action-verification:" + request["execution_digest"],
|
|
609
|
+
observed,
|
|
610
|
+
qualifying_receipt_keys=(acceptance_key,),
|
|
611
|
+
)
|
|
612
|
+
except ActionStoreError:
|
|
613
|
+
return self._current(action["action_id"])
|
|
614
|
+
|
|
615
|
+
if action["state"] == "verified":
|
|
616
|
+
try:
|
|
617
|
+
return self.store.complete(
|
|
618
|
+
action["action_id"],
|
|
619
|
+
self.owner,
|
|
620
|
+
{
|
|
621
|
+
"status": "completed",
|
|
622
|
+
"execution_digest": request["execution_digest"],
|
|
623
|
+
"intent_id": intent.intent_id,
|
|
624
|
+
"tool_name": intent.tool_name,
|
|
625
|
+
},
|
|
626
|
+
)
|
|
627
|
+
except ActionStoreError:
|
|
628
|
+
return self._current(action["action_id"])
|
|
629
|
+
return action
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
__all__ = (
|
|
633
|
+
"ADMISSION_DEFER_SECONDS",
|
|
634
|
+
"DEFAULT_ACTION_TYPE",
|
|
635
|
+
"DEFAULT_SOURCE_PREFIX",
|
|
636
|
+
"GovernedActionWorker",
|
|
637
|
+
"GovernedActionWorkerError",
|
|
638
|
+
"OBSERVATION_MAX_ATTEMPTS",
|
|
639
|
+
"OBSERVATION_RETRY_SECONDS",
|
|
640
|
+
"OBSERVATION_WINDOW_SECONDS",
|
|
641
|
+
"build_execution_request",
|
|
642
|
+
"validate_execution_result",
|
|
643
|
+
)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: apsimo-hostworker
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Stateless, stdlib-only core for governed Apsimo host workers
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
11
|
+
Dynamic: license-file
|
|
12
|
+
|
|
13
|
+
# Apsimo host worker
|
|
14
|
+
|
|
15
|
+
The stdlib-only action contract, capability catalog, execution ledger and
|
|
16
|
+
conformance checks used by Apsimo host adapters. This package has no sidecar or
|
|
17
|
+
web-server dependency.
|
|
18
|
+
|
|
19
|
+
Install `apsimo-hostworker==0.2.0` in a fresh environment and import
|
|
20
|
+
`apsimo_hostworker`. Legacy `colony_hostworker` imports resolve to the same
|
|
21
|
+
module objects. Existing action names and serialized contract identifiers are
|
|
22
|
+
preserved so pending actions and their digests remain valid.
|
|
23
|
+
|
|
24
|
+
Run `python -m apsimo_hostworker.conformance` to check a host implementation.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
apsimo_hostworker/__init__.py,sha256=8QOShFDEiQ5VsO5LR7ueX1o-0dbtRNcJWWVWwgtazBE,3772
|
|
2
|
+
apsimo_hostworker/_private_io.py,sha256=sMhSQCq3wC7FF25xPlGggEw3pmzQ5uF4fKdllQm8kC4,9324
|
|
3
|
+
apsimo_hostworker/admission.py,sha256=AlahyKjEXu1mmSyIEJyvSFYxKoijTfGU21hDhEG6aao,11743
|
|
4
|
+
apsimo_hostworker/catalog.py,sha256=kYnttcbuWgbwXlDo52F0oEJLzPA9_g-9CraH_VtZgU4,25625
|
|
5
|
+
apsimo_hostworker/client.py,sha256=INl_tppRfg7vi4HNUPXpHlqjJ9XeX_gU0LIWmD-ckto,10940
|
|
6
|
+
apsimo_hostworker/compat.py,sha256=LyIftL76P_3m-6iH61n4xklM0_9ahAUJC054zebspBs,2091
|
|
7
|
+
apsimo_hostworker/contract.py,sha256=TEzLT4mTzTX2PgIhEERZ986B9UsSav4-hBgcGbVH_YQ,13667
|
|
8
|
+
apsimo_hostworker/gate.py,sha256=UAT2tgHyUNub4UWhCNeE7g12n2pQQJkO-R-hMBvnMsc,16227
|
|
9
|
+
apsimo_hostworker/intent.py,sha256=T5jPxJQ28FgJvNmqYJKQk-XsyH4mKpX_V_f0SwmzTvk,8045
|
|
10
|
+
apsimo_hostworker/sqlite_store.py,sha256=fgRrxB3ErC56iRhjzerzoKNoMy7FdgeU4DLpmbbxyok,68663
|
|
11
|
+
apsimo_hostworker/store.py,sha256=zdptiUaDrB1zkcLF3ztWaQIxl-562Hbcec9DMe0Mibs,18531
|
|
12
|
+
apsimo_hostworker/worker.py,sha256=kRSx2_hkpvOz5YRATN_GDfwqUXD9KhEKobvzPzDIbKc,25371
|
|
13
|
+
apsimo_hostworker/conformance/__init__.py,sha256=yMIMCiuE-VUyh3NNM7j-sSSL0q5YVyU8rZGYHgfqrmI,1183
|
|
14
|
+
apsimo_hostworker/conformance/__main__.py,sha256=0oxyqDiZylOSYeVYfy76GqLt-RqTmU9ppe_lxBcCK4Y,746
|
|
15
|
+
apsimo_hostworker/conformance/harness.py,sha256=T8BVOpMkT-6KdxAk3NOFlzrwau1Bs7J9opXRquy4_Es,8294
|
|
16
|
+
apsimo_hostworker/conformance/suite.py,sha256=B3Ldf7JOegtuhQjEjUy7fqeh0L-eYU0rkb7gFlo18Qg,39287
|
|
17
|
+
apsimo_hostworker-0.2.0.dist-info/licenses/LICENSE,sha256=LyIKx_pTy2LEq09fpHaD-xNX6Rc7a8NDy5o5atSH2M0,1064
|
|
18
|
+
colony_hostworker/__init__.py,sha256=s4YI6NEyF8fe8yIXkEs0Q2y_6-XogDK7xK0qI3-DEUA,188
|
|
19
|
+
apsimo_hostworker-0.2.0.dist-info/METADATA,sha256=9rLWW5iRxD-wkMknejFo0AUpGqqQW4guA5LFzC7jd_Q,890
|
|
20
|
+
apsimo_hostworker-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
21
|
+
apsimo_hostworker-0.2.0.dist-info/top_level.txt,sha256=ETchVR1drESloBc2KFdwQEGmQFT19xWH47g4z6g2dvU,36
|
|
22
|
+
apsimo_hostworker-0.2.0.dist-info/RECORD,,
|