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.
@@ -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,,