@heyanon-arp/cli 0.0.7 → 0.0.8
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.
- package/README.md +4 -4
- package/dist/cli.js +806 -784
- package/dist/cli.js.map +1 -1
- package/package.json +3 -4
- package/scripts/postinstall.mjs +7 -6
- package/examples/README.md +0 -157
- package/examples/worker-template.py +0 -834
|
@@ -1,834 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
ARP Worker Template
|
|
4
|
-
===================
|
|
5
|
-
Plug-and-play template for building autonomous ARP (Agent Relationship
|
|
6
|
-
Protocol) workers. Drop your business logic into `handle_work_request()`
|
|
7
|
-
— everything else (handshake → delegation → work → receipt →
|
|
8
|
-
settlement) is auto-mediated.
|
|
9
|
-
|
|
10
|
-
What this template does:
|
|
11
|
-
handshake → auto-accept
|
|
12
|
-
delegation offer → auto-accept (terms inline, idempotent, escrow-aware)
|
|
13
|
-
work_request → YOUR CUSTOM HANDLER → work_response
|
|
14
|
-
→ auto receipt propose (for escrow, the SAME command
|
|
15
|
-
also signs + delivers the payee settlement signature)
|
|
16
|
-
|
|
17
|
-
Quick start:
|
|
18
|
-
1. Register your agent: heyarp register --name "MyWorker" --tag my-service ...
|
|
19
|
-
2. Find your settlement key: heyarp whoami --local
|
|
20
|
-
3. Edit the CONFIGURATION section below — at minimum set
|
|
21
|
-
`MY_SETTLEMENT_PUBKEY` (or `ARP_SETTLEMENT_PUBKEY` env var) to YOUR key.
|
|
22
|
-
4. Edit `handle_work_request()` with your business logic.
|
|
23
|
-
5. Run: python3 my-worker.py
|
|
24
|
-
|
|
25
|
-
Requirements:
|
|
26
|
-
- Node ≥22, @heyanon-arp/cli installed (`npm i -g @heyanon-arp/cli`)
|
|
27
|
-
- Python 3.9+
|
|
28
|
-
- Registered ARP agent identity
|
|
29
|
-
|
|
30
|
-
Scope and limitations (V1 alpha):
|
|
31
|
-
- **FLAT pricing only — by THIS template's choice.** `auto_accept_delegation`
|
|
32
|
-
below refuses to accept non-flat offers (the delegation carries its
|
|
33
|
-
pricing_model inline now; a full-release signature on a usage_based
|
|
34
|
-
lock would let the buyer drain it). The folded settlement step in
|
|
35
|
-
`heyarp receipt propose` (escrow) DOES handle partial release (it
|
|
36
|
-
auto-detects from the receipt's usage.computed_amount), so to support
|
|
37
|
-
usage_based work, relax the pricing gate in `auto_accept_delegation`
|
|
38
|
-
AND pass `--computed-amount` on the propose call — the settlement step
|
|
39
|
-
then signs the right (partial) digest.
|
|
40
|
-
- **Native SOL only by default.** SPL-token settlement requires
|
|
41
|
-
setting `ARP_MINT_PUBKEY` to the token's mint address (passed through
|
|
42
|
-
to the settlement command).
|
|
43
|
-
- **Single-tenant.** Events are processed serially through the SSE
|
|
44
|
-
loop. Running multiple workers against the same agent identity
|
|
45
|
-
will fight over `sender_sequence`; use distinct DIDs or a single
|
|
46
|
-
coordinator.
|
|
47
|
-
|
|
48
|
-
Discoverability:
|
|
49
|
-
This template ships bundled with the `@heyanon-arp/cli` npm package.
|
|
50
|
-
Get a local copy via:
|
|
51
|
-
|
|
52
|
-
heyarp examples copy worker --output ./my-worker.py
|
|
53
|
-
|
|
54
|
-
or pipe directly:
|
|
55
|
-
|
|
56
|
-
heyarp examples show worker > ./my-worker.py
|
|
57
|
-
|
|
58
|
-
Attribution:
|
|
59
|
-
Original contribution: Darsio Security Audit
|
|
60
|
-
(did:arp:7znVy3Doi1xpCfQpjUYxx2UipkCtdayTLTqsH8gejxfG).
|
|
61
|
-
Cleaned up + wired through `--json` flags.
|
|
62
|
-
License: MIT
|
|
63
|
-
"""
|
|
64
|
-
|
|
65
|
-
# PEP-563: defer evaluation of all annotations to runtime. Lets us
|
|
66
|
-
# use PEP-604 `dict | None` syntax (Python 3.10+) in signatures
|
|
67
|
-
# while still supporting Python 3.9, which is what the header
|
|
68
|
-
# advertises as the minimum version. Drop this when 3.9 EOL ages
|
|
69
|
-
# out of common installs.
|
|
70
|
-
from __future__ import annotations
|
|
71
|
-
|
|
72
|
-
import base64
|
|
73
|
-
import hashlib
|
|
74
|
-
import json
|
|
75
|
-
import os
|
|
76
|
-
import shutil
|
|
77
|
-
import subprocess
|
|
78
|
-
import sys
|
|
79
|
-
import time
|
|
80
|
-
import uuid
|
|
81
|
-
from datetime import datetime, timezone
|
|
82
|
-
from pathlib import Path
|
|
83
|
-
|
|
84
|
-
# ═══════════════════════════════════════════════════════════════
|
|
85
|
-
# CONFIGURATION — change these to match your setup
|
|
86
|
-
# ═══════════════════════════════════════════════════════════════
|
|
87
|
-
#
|
|
88
|
-
# Every constant below honours an env var override of the same name
|
|
89
|
-
# prefixed with `ARP_` / `HEYARP_`. That keeps the file editable for
|
|
90
|
-
# the simplest setup (one worker on one machine) while still letting
|
|
91
|
-
# you parameterise via systemd / Docker / .env for production.
|
|
92
|
-
|
|
93
|
-
# Path to the heyarp CLI binary. Resolution order:
|
|
94
|
-
# 1. `HEYARP_PATH` env var if set (overrides everything)
|
|
95
|
-
# 2. `heyarp` on `$PATH` (the `npm i -g @heyanon-arp/cli` default)
|
|
96
|
-
# 3. `~/.local/bin/heyarp` (per-user pnpm install)
|
|
97
|
-
# The actual resolution happens in `_preflight` so a missing binary
|
|
98
|
-
# fails loudly there rather than as an opaque ENOENT mid-event.
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
def _resolve_heyarp() -> str:
|
|
102
|
-
explicit = os.environ.get("HEYARP_PATH")
|
|
103
|
-
if explicit:
|
|
104
|
-
return os.path.expanduser(explicit)
|
|
105
|
-
on_path = shutil.which("heyarp")
|
|
106
|
-
if on_path:
|
|
107
|
-
return on_path
|
|
108
|
-
fallback = os.path.expanduser("~/.local/bin/heyarp")
|
|
109
|
-
return fallback
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
HEYARP = _resolve_heyarp()
|
|
113
|
-
|
|
114
|
-
# ARP server URL. **Defaults to production.** Override priority chain
|
|
115
|
-
# (same as the CLI's `resolveServerUrl`):
|
|
116
|
-
#
|
|
117
|
-
# 1. `ARP_SERVER_URL` env var
|
|
118
|
-
# 2. `heyarp config set server <url>` (persistent CLI config)
|
|
119
|
-
# 3. The template default below
|
|
120
|
-
#
|
|
121
|
-
# We only export back into `os.environ` when the operator clearly
|
|
122
|
-
# expressed a choice (env var set OR edited this constant); otherwise
|
|
123
|
-
# the CLI's config resolution wins. See `_preflight`.
|
|
124
|
-
_PRODUCTION_DEFAULT_URL = "https://api.heyanon.ai/arp"
|
|
125
|
-
SERVER_URL = os.environ.get("ARP_SERVER_URL", _PRODUCTION_DEFAULT_URL)
|
|
126
|
-
|
|
127
|
-
# Working directory for job artifacts (cloned repos, temp files,
|
|
128
|
-
# response payloads). Created lazily in `main()` — no import-time
|
|
129
|
-
# side effects.
|
|
130
|
-
WORK_DIR = Path(
|
|
131
|
-
os.path.expanduser(os.environ.get("ARP_WORKER_DIR", "~/.arp-worker/jobs"))
|
|
132
|
-
)
|
|
133
|
-
|
|
134
|
-
# Your agent's settlement public key (find via `heyarp whoami --local`).
|
|
135
|
-
# This is your Solana address where escrowed funds arrive after the
|
|
136
|
-
# buyer cosigns the receipt.
|
|
137
|
-
#
|
|
138
|
-
# **MUST be set** before running. The sentinel below makes the worker
|
|
139
|
-
# refuse to start so you don't accidentally route funds elsewhere.
|
|
140
|
-
_SETTLEMENT_SENTINEL = "REPLACE_WITH_YOUR_SETTLEMENT_PUBKEY"
|
|
141
|
-
MY_SETTLEMENT_PUBKEY = os.environ.get("ARP_SETTLEMENT_PUBKEY", _SETTLEMENT_SENTINEL)
|
|
142
|
-
|
|
143
|
-
# Cluster tag baked into the on-chain settlement digest. Must match
|
|
144
|
-
# the cluster the escrow program's `create_lock` was submitted on,
|
|
145
|
-
# otherwise `release_lock` rejects the signature.
|
|
146
|
-
# 0 = devnet
|
|
147
|
-
# 1 = mainnet-beta
|
|
148
|
-
# Default is `1` (mainnet) so a production worker is safe by default;
|
|
149
|
-
# override via `ARP_CLUSTER_TAG=0` for devnet testing.
|
|
150
|
-
CLUSTER_TAG = os.environ.get("ARP_CLUSTER_TAG", "1")
|
|
151
|
-
|
|
152
|
-
# Mint pubkey for settlement. Native SOL uses the System Program id
|
|
153
|
-
# (32 ones in base58). SPL-token settlement requires the token's mint
|
|
154
|
-
# address — set `ARP_MINT_PUBKEY` to override.
|
|
155
|
-
NATIVE_SOL_MINT = "11111111111111111111111111111111"
|
|
156
|
-
MINT_PUBKEY = os.environ.get("ARP_MINT_PUBKEY", NATIVE_SOL_MINT)
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
# ═══════════════════════════════════════════════════════════════
|
|
160
|
-
# SHELL HELPERS — thin wrappers around subprocess
|
|
161
|
-
# ═══════════════════════════════════════════════════════════════
|
|
162
|
-
|
|
163
|
-
def shell(cmd, timeout=120, cwd=None):
|
|
164
|
-
"""
|
|
165
|
-
Run a CLI command safely (no `shell=True`, no injection).
|
|
166
|
-
Returns dict: {ok, stdout, stderr, exit_code}.
|
|
167
|
-
|
|
168
|
-
Strips Node deprecation warnings (DEP0040) from stderr so real
|
|
169
|
-
error messages aren't buried.
|
|
170
|
-
"""
|
|
171
|
-
try:
|
|
172
|
-
r = subprocess.run(
|
|
173
|
-
cmd, shell=False, capture_output=True, text=True,
|
|
174
|
-
timeout=timeout, cwd=cwd,
|
|
175
|
-
)
|
|
176
|
-
stderr_clean = "\n".join(
|
|
177
|
-
line for line in r.stderr.split("\n")
|
|
178
|
-
if "DEP0040" not in line and "deprecated" not in line.lower()
|
|
179
|
-
).strip()
|
|
180
|
-
return {
|
|
181
|
-
"ok": r.returncode == 0,
|
|
182
|
-
"stdout": r.stdout.strip(),
|
|
183
|
-
"stderr": stderr_clean,
|
|
184
|
-
"exit_code": r.returncode,
|
|
185
|
-
}
|
|
186
|
-
except subprocess.TimeoutExpired:
|
|
187
|
-
return {"ok": False, "stdout": "", "stderr": "", "error": f"Timeout {timeout}s"}
|
|
188
|
-
except Exception as e:
|
|
189
|
-
return {"ok": False, "stdout": "", "stderr": "", "error": str(e)}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
def shell_json(cmd, timeout=120):
|
|
193
|
-
"""
|
|
194
|
-
Run a CLI command that emits a single JSON payload on stdout
|
|
195
|
-
(the `--json` contract) and parse it.
|
|
196
|
-
|
|
197
|
-
Returns the parsed object on success, or None on failure.
|
|
198
|
-
"""
|
|
199
|
-
r = shell(cmd, timeout=timeout)
|
|
200
|
-
if not r["ok"] or not r["stdout"]:
|
|
201
|
-
return None
|
|
202
|
-
try:
|
|
203
|
-
return json.loads(r["stdout"])
|
|
204
|
-
except json.JSONDecodeError as e:
|
|
205
|
-
print(f"[worker] - JSON parse failed for {cmd[:2]}: {e}")
|
|
206
|
-
return None
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
def error_code(r: dict) -> str | None:
|
|
210
|
-
"""
|
|
211
|
-
Extract the machine-readable error `code` from a failed `--json`
|
|
212
|
-
command. On the failure path the CLI emits a single
|
|
213
|
-
`{"code": ..., "message": ...}` object on stderr, so the worker
|
|
214
|
-
keys off `code` instead of substring-matching human text.
|
|
215
|
-
|
|
216
|
-
Returns the code string, or None if stderr wasn't structured JSON
|
|
217
|
-
(e.g. the command was run without `--json`, or a non-CLI error).
|
|
218
|
-
"""
|
|
219
|
-
stderr = r.get("stderr", "")
|
|
220
|
-
if not stderr:
|
|
221
|
-
return None
|
|
222
|
-
try:
|
|
223
|
-
parsed = json.loads(stderr)
|
|
224
|
-
except (json.JSONDecodeError, TypeError):
|
|
225
|
-
return None
|
|
226
|
-
return parsed.get("code") if isinstance(parsed, dict) else None
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
# ═══════════════════════════════════════════════════════════════
|
|
230
|
-
# FSM AUTO-RESPONDERS — handle the protocol state machine
|
|
231
|
-
# ═══════════════════════════════════════════════════════════════
|
|
232
|
-
#
|
|
233
|
-
# The ARP work cycle is: handshake → delegation → work → receipt.
|
|
234
|
-
# These handlers auto-accept every non-terminal phase so your worker
|
|
235
|
-
# runs without human intervention.
|
|
236
|
-
#
|
|
237
|
-
# All acceptors are IDEMPOTENT — they safely skip already-processed
|
|
238
|
-
# states (SSE re-delivers events, so retries are normal).
|
|
239
|
-
|
|
240
|
-
def auto_accept_handshake(sender_did: str):
|
|
241
|
-
"""
|
|
242
|
-
Auto-accept an inbound handshake.
|
|
243
|
-
|
|
244
|
-
Accepting establishes the relationship and unlocks the delegation
|
|
245
|
-
offer from the buyer.
|
|
246
|
-
"""
|
|
247
|
-
print(f"[worker] Auto-accepting handshake from {sender_did[:30]}...")
|
|
248
|
-
r = shell([
|
|
249
|
-
HEYARP, "send-handshake-response", sender_did,
|
|
250
|
-
"--decision", "accept",
|
|
251
|
-
"--notes", "Auto-accepted. Ready for work. Send a delegation offer.",
|
|
252
|
-
"--json",
|
|
253
|
-
], timeout=30)
|
|
254
|
-
if r["ok"]:
|
|
255
|
-
# The CLI's idempotency probe short-circuits "already
|
|
256
|
-
# responded" to a SUCCESS ({ok:true, idempotent:true}), so a
|
|
257
|
-
# benign retry lands here too — no special-casing needed.
|
|
258
|
-
print("[worker] ✓ handshake accepted")
|
|
259
|
-
else:
|
|
260
|
-
# Read the structured `code` instead of substring-
|
|
261
|
-
# matching the error text. DOM_INVALID_TRANSITION means the
|
|
262
|
-
# relationship already advanced past the handshake — benign.
|
|
263
|
-
code = error_code(r)
|
|
264
|
-
if code == "DOM_INVALID_TRANSITION":
|
|
265
|
-
print("[worker] - handshake already resolved (idempotent skip)")
|
|
266
|
-
else:
|
|
267
|
-
print(f"[worker] ✗ handshake accept failed: {code or r.get('stderr', '')[:200]}")
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
def fetch_delegation_row(rel_id: str, delegation_id: str) -> dict | None:
|
|
271
|
-
"""
|
|
272
|
-
Look up a single delegation row by id, paginating `delegations
|
|
273
|
-
--json` (CLI default limit is 20). Returns the matching row or
|
|
274
|
-
None if the relationship doesn't host it.
|
|
275
|
-
"""
|
|
276
|
-
after = None
|
|
277
|
-
for _ in range(50):
|
|
278
|
-
cmd = [HEYARP, "delegations", rel_id, "--json", "--limit", "100"]
|
|
279
|
-
if after:
|
|
280
|
-
cmd += ["--after", after]
|
|
281
|
-
rows = shell_json(cmd, timeout=15)
|
|
282
|
-
if rows is None or not rows:
|
|
283
|
-
return None
|
|
284
|
-
row = next((d for d in rows if d.get("delegationId") == delegation_id), None)
|
|
285
|
-
if row is not None:
|
|
286
|
-
return row
|
|
287
|
-
if len(rows) < 100:
|
|
288
|
-
return None
|
|
289
|
-
after = rows[-1].get("id")
|
|
290
|
-
return None
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
def auto_accept_delegation(rel_id: str, delegation_id: str):
|
|
294
|
-
"""
|
|
295
|
-
Auto-accept a delegation offer.
|
|
296
|
-
|
|
297
|
-
Terms are INLINE on the offer (there is no separate contract step):
|
|
298
|
-
the delegation row carries `pricingModel`, `scopeSummary`,
|
|
299
|
-
`settlementModel`, rate, amount, currency, and `deadline` — the
|
|
300
|
-
same terms the `condition_hash` binds to the escrow lock. So both
|
|
301
|
-
policy gates below (pricing model + settleable deadline) read
|
|
302
|
-
straight off the delegation row.
|
|
303
|
-
|
|
304
|
-
Pricing-model gate: this template only handles `flat` offers
|
|
305
|
-
(full-release settlement). For `usage_based` offers the on-chain
|
|
306
|
-
`partial_release` ix needs a different digest
|
|
307
|
-
(`ARP-SOLANA-PARTIAL-RELEASE-v1.5` + `--partial-payee-amount`).
|
|
308
|
-
The folded settlement step in `receipt propose` DOES generate it
|
|
309
|
-
(auto-detected from the receipt's usage.computed_amount), but this
|
|
310
|
-
template's `receipt propose` call doesn't pass `--computed-amount`,
|
|
311
|
-
so a usage_based settle would refuse downstream and strand the work
|
|
312
|
-
— refuse the offer up-front instead. To support usage_based work,
|
|
313
|
-
relax this gate AND pass `--computed-amount` on the propose call.
|
|
314
|
-
|
|
315
|
-
Settleability policy gate: this template auto-settles each receipt
|
|
316
|
-
via the settlement step folded into `receipt propose` (escrow),
|
|
317
|
-
which derives the payee signature's `expires_at` from the
|
|
318
|
-
delegation's `deadline` (+ the server-side buffer). A delegation
|
|
319
|
-
with NO deadline can't be auto-settled — propose refuses to
|
|
320
|
-
fabricate an expiry that might exceed the on-chain lock (a
|
|
321
|
-
post-commit server reject that burns a sender_sequence). If we
|
|
322
|
-
accepted a deadline-less offer we'd do the work, propose a receipt,
|
|
323
|
-
then fail to settle and strand it — so we read the delegation row
|
|
324
|
-
and refuse deadline-less offers.
|
|
325
|
-
|
|
326
|
-
Read-model lag: the offer SSE event can arrive BEFORE the
|
|
327
|
-
`delegations` read-model row materializes, so a missing row is
|
|
328
|
-
almost always a transient timing gap, NOT an invalid offer. We
|
|
329
|
-
retry with backoff; if it still hasn't appeared we accept anyway
|
|
330
|
-
rather than permanently drop a valid offer — the settlement step
|
|
331
|
-
still refuses a deadline-less settle downstream, so funds can't be
|
|
332
|
-
misrouted (worst case: the operator settles manually with
|
|
333
|
-
`--expires-at`).
|
|
334
|
-
|
|
335
|
-
Error handling (keyed off the structured `code`, not
|
|
336
|
-
substring matching):
|
|
337
|
-
- DELEGATION_INVALID_STATE → already accepted → idempotent skip
|
|
338
|
-
- ESC_* → buyer's lock is broken (insufficient funds, etc.)
|
|
339
|
-
- Other → logged for debugging
|
|
340
|
-
"""
|
|
341
|
-
# Policy pre-flight: confirm the delegation carries a deadline we
|
|
342
|
-
# can settle against. The offer SSE event can land BEFORE the
|
|
343
|
-
# `delegations` read-model row materializes, so a None here is almost
|
|
344
|
-
# always transient lag — retry with backoff instead of dropping a
|
|
345
|
-
# valid offer.
|
|
346
|
-
row = None
|
|
347
|
-
for attempt in range(5): # ~5 tries over ~7.5s to ride out read-model lag
|
|
348
|
-
row = fetch_delegation_row(rel_id, delegation_id)
|
|
349
|
-
if row is not None:
|
|
350
|
-
break
|
|
351
|
-
if attempt < 4:
|
|
352
|
-
time.sleep(min(0.5 * (2 ** attempt), 4.0)) # 0.5, 1, 2, 4
|
|
353
|
-
if row is not None and not row.get("deadline"):
|
|
354
|
-
print(
|
|
355
|
-
f"[worker] ✗ refusing delegation {delegation_id[:20]} — "
|
|
356
|
-
"no deadline set; this template auto-settles via "
|
|
357
|
-
"deadline+buffer and can't derive a safe settlement expiry. "
|
|
358
|
-
"Accept manually + run `heyarp receipt propose ... "
|
|
359
|
-
"--cluster-tag <0|1> --expires-at <unix-secs>` to handle it."
|
|
360
|
-
)
|
|
361
|
-
return
|
|
362
|
-
# Pricing-model gate: terms ride inline on the offer, so the
|
|
363
|
-
# delegation row carries `pricingModel` directly (no contract
|
|
364
|
-
# lookup). Default to 'flat' when absent (no-escrow / unpriced
|
|
365
|
-
# offers). Refuse usage_based — see the docstring for why.
|
|
366
|
-
if row is not None and str(row.get("pricingModel") or "flat") != "flat":
|
|
367
|
-
print(
|
|
368
|
-
f"[worker] ✗ refusing delegation {delegation_id[:20]} — "
|
|
369
|
-
f"pricingModel={row.get('pricingModel')!r}; this template only "
|
|
370
|
-
"handles `flat`. Decline manually, or relax this gate AND pass "
|
|
371
|
-
"`--computed-amount` on the receipt propose call to support "
|
|
372
|
-
"ARP-SOLANA-PARTIAL-RELEASE-v1.5 settlement."
|
|
373
|
-
)
|
|
374
|
-
return
|
|
375
|
-
if row is None:
|
|
376
|
-
# Row never materialized within the retry budget — accept anyway
|
|
377
|
-
# rather than drop a valid offer; the settlement step still
|
|
378
|
-
# refuses a deadline-less settle, so funds can't be misrouted.
|
|
379
|
-
print(
|
|
380
|
-
f"[worker] - delegation {delegation_id[:20]} row not visible yet "
|
|
381
|
-
"(read-model lag); accepting, deadline check deferred to settlement."
|
|
382
|
-
)
|
|
383
|
-
|
|
384
|
-
print(f"[worker] Auto-accepting delegation {delegation_id[:20]}...")
|
|
385
|
-
r = shell([HEYARP, "delegation", "accept", rel_id, delegation_id, "--json"], timeout=30)
|
|
386
|
-
if r["ok"]:
|
|
387
|
-
print("[worker] ✓ delegation accepted")
|
|
388
|
-
return
|
|
389
|
-
code = error_code(r)
|
|
390
|
-
if code == "DELEGATION_INVALID_STATE":
|
|
391
|
-
print("[worker] - delegation already accepted (idempotent skip)")
|
|
392
|
-
elif code and code.startswith("ESC"):
|
|
393
|
-
print(f"[worker] - escrow issue (buyer-side): {code}")
|
|
394
|
-
else:
|
|
395
|
-
print(f"[worker] ✗ delegation accept failed: {code or r.get('stderr', '')[:200]}")
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
# ═══════════════════════════════════════════════════════════════
|
|
399
|
-
# BUSINESS LOGIC — YOUR CUSTOM CODE GOES HERE
|
|
400
|
-
# ═══════════════════════════════════════════════════════════════
|
|
401
|
-
#
|
|
402
|
-
# This is the only function you NEED to customize.
|
|
403
|
-
# It receives the parsed work_request and must return a dict that
|
|
404
|
-
# becomes the work_response output.
|
|
405
|
-
#
|
|
406
|
-
# Input fields:
|
|
407
|
-
# params — dict from work_request body.content.params
|
|
408
|
-
# rel_id — relationship UUID
|
|
409
|
-
# del_id — delegation UUID
|
|
410
|
-
# req_id — request UUID
|
|
411
|
-
# sender_did — buyer's DID
|
|
412
|
-
#
|
|
413
|
-
# Return a dict with your results. The `report` field with base64
|
|
414
|
-
# data is optional — include it when delivering binary artifacts
|
|
415
|
-
# (PDF, image, audio). The `usage` field tells the buyer how to
|
|
416
|
-
# decode them.
|
|
417
|
-
|
|
418
|
-
def handle_work_request(params: dict, rel_id: str, del_id: str,
|
|
419
|
-
req_id: str, sender_did: str) -> dict:
|
|
420
|
-
"""
|
|
421
|
-
╔══════════════════════════════════════════════════════════╗
|
|
422
|
-
║ YOUR BUSINESS LOGIC GOES HERE ║
|
|
423
|
-
║ Replace this with your actual service implementation ║
|
|
424
|
-
╚══════════════════════════════════════════════════════════╝
|
|
425
|
-
|
|
426
|
-
This example does a trivial text analysis. Swap it for your
|
|
427
|
-
actual logic: call an API, run a model, process a file.
|
|
428
|
-
|
|
429
|
-
The returned dict is the work_response output. Protocol handles
|
|
430
|
-
signing, hashing, and delivery automatically.
|
|
431
|
-
"""
|
|
432
|
-
# Extract inputs — support flat `{key}` and nested `{target: {key}}`.
|
|
433
|
-
task = params.get("task", params.get("target", {}).get("task", "unknown"))
|
|
434
|
-
text = params.get("text", params.get("target", {}).get("text", ""))
|
|
435
|
-
|
|
436
|
-
# Your processing logic.
|
|
437
|
-
result = {
|
|
438
|
-
"task": task,
|
|
439
|
-
"input_length": len(text),
|
|
440
|
-
"word_count": len(text.split()) if text else 0,
|
|
441
|
-
"processed_at": datetime.now(timezone.utc).isoformat(),
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
# Optional binary artifact (base64 in the response body).
|
|
445
|
-
response: dict = {"verdict": "completed", "result": result}
|
|
446
|
-
if text:
|
|
447
|
-
report = f"Analysis of: {text[:100]}...\nWords: {result['word_count']}\n"
|
|
448
|
-
artifact_b64 = base64.b64encode(report.encode()).decode()
|
|
449
|
-
response["report"] = {
|
|
450
|
-
"format": "text",
|
|
451
|
-
"filename": "analysis-report.txt",
|
|
452
|
-
"data_base64": artifact_b64,
|
|
453
|
-
"sha256": f"sha256:{hashlib.sha256(report.encode()).hexdigest()}",
|
|
454
|
-
}
|
|
455
|
-
response["usage"] = {
|
|
456
|
-
"how_to_open": "Binary data is base64-encoded in report.data_base64.",
|
|
457
|
-
"python": (
|
|
458
|
-
"import base64; "
|
|
459
|
-
"open('report.txt','wb').write(base64.b64decode(data_base64))"
|
|
460
|
-
),
|
|
461
|
-
"node": (
|
|
462
|
-
"require('fs').writeFileSync("
|
|
463
|
-
"'report.txt', Buffer.from(data_base64, 'base64'))"
|
|
464
|
-
),
|
|
465
|
-
"verify_integrity": "sha256sum report.txt # must match report.sha256",
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
return response
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
# ═══════════════════════════════════════════════════════════════
|
|
472
|
-
# SETTLEMENT
|
|
473
|
-
# ═══════════════════════════════════════════════════════════════
|
|
474
|
-
#
|
|
475
|
-
# To release escrow on Solana the buyer needs BOTH parties' settlement
|
|
476
|
-
# signatures. The payee (us) must generate its signature and deliver it
|
|
477
|
-
# to the buyer.
|
|
478
|
-
#
|
|
479
|
-
# This is now FOLDED into `receipt propose`: for an escrow delegation,
|
|
480
|
-
# the SAME `heyarp receipt propose ... --cluster-tag <0|1>` command that
|
|
481
|
-
# commits the receipt also signs + delivers the payee settlement
|
|
482
|
-
# signature right after — collapsing the whole ~90-line ritual (resolve
|
|
483
|
-
# buyer key → derive condition_hash → fetch amount/decimals/deadline →
|
|
484
|
-
# compute expiry → sign release digest → deliver via settlement_signature
|
|
485
|
-
# envelope) so a worker can't forget the separate step. The main loop
|
|
486
|
-
# below just reads the `settlement` block off the propose --json result.
|
|
487
|
-
# The folded step:
|
|
488
|
-
# - auto-resolves rel-id, buyer DID, condition_hash, amount,
|
|
489
|
-
# decimals, deadline, and the receipt hashes
|
|
490
|
-
# - auto-detects FULL (flat) vs PARTIAL (usage_based, from the
|
|
491
|
-
# receipt's usage.computed_amount) release
|
|
492
|
-
# - is idempotent (skips when the payee sig was already delivered)
|
|
493
|
-
# - on partial failure (no RPC / lock not visible) leaves the receipt
|
|
494
|
-
# committed and reports {delivered: false, error, recovery}; recover
|
|
495
|
-
# with `heyarp receipt send-payee-sig` (the no-RPC primitive) or
|
|
496
|
-
# re-run propose once the lock is reachable.
|
|
497
|
-
#
|
|
498
|
-
# Requires `@heyanon-arp/cli` with the folded `receipt propose` escrow
|
|
499
|
-
# settlement (the standalone `settlement auto-sign-and-deliver` command
|
|
500
|
-
# was removed).
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
# ═══════════════════════════════════════════════════════════════
|
|
504
|
-
# WORK REQUEST PROCESSOR — wires business logic to protocol
|
|
505
|
-
# ═══════════════════════════════════════════════════════════════
|
|
506
|
-
|
|
507
|
-
def process_work_request(event_data: dict):
|
|
508
|
-
"""
|
|
509
|
-
Extract parameters from a work_request SSE event and invoke
|
|
510
|
-
your custom handler.
|
|
511
|
-
|
|
512
|
-
Returns (output_dict, rel_id, del_id, req_id, sender_did).
|
|
513
|
-
"""
|
|
514
|
-
body = event_data.get("body", {})
|
|
515
|
-
content = body.get("content", {})
|
|
516
|
-
params = content.get("params", {})
|
|
517
|
-
|
|
518
|
-
rel_id = (
|
|
519
|
-
event_data.get("relationshipId", "")
|
|
520
|
-
or event_data.get("protectedBlock", {}).get("relationship_id", "")
|
|
521
|
-
or event_data.get("protected", {}).get("relationship_id", "")
|
|
522
|
-
)
|
|
523
|
-
delegation_id = content.get("delegation_id", "")
|
|
524
|
-
request_id = content.get("request_id", "")
|
|
525
|
-
sender_did = (
|
|
526
|
-
event_data.get("senderDid", "")
|
|
527
|
-
or event_data.get("protectedBlock", {}).get("sender_did", "")
|
|
528
|
-
or event_data.get("protected", {}).get("sender_did", "")
|
|
529
|
-
)
|
|
530
|
-
|
|
531
|
-
print(f"[worker] === New job: {str(params.get('task', 'unknown'))[:60]} ===")
|
|
532
|
-
print(f"[worker] From: {sender_did[:30]}...")
|
|
533
|
-
print(f"[worker] Relationship: {rel_id}")
|
|
534
|
-
|
|
535
|
-
output = handle_work_request(params, rel_id, delegation_id, request_id, sender_did)
|
|
536
|
-
return output, rel_id, delegation_id, request_id, sender_did
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
def send_work_response(rel_id: str, delegation_id: str, request_id: str,
|
|
540
|
-
output: dict) -> dict:
|
|
541
|
-
"""
|
|
542
|
-
Send work_response via heyarp CLI. Writes output to a temp JSON
|
|
543
|
-
file to dodge shell-quoting issues with large/complex payloads.
|
|
544
|
-
"""
|
|
545
|
-
tmp_json = WORK_DIR / f"response_{uuid.uuid4().hex[:8]}.json"
|
|
546
|
-
tmp_json.write_text(json.dumps(output, ensure_ascii=False))
|
|
547
|
-
try:
|
|
548
|
-
return shell([
|
|
549
|
-
HEYARP, "work", "respond",
|
|
550
|
-
rel_id, delegation_id, request_id,
|
|
551
|
-
"--output-file", str(tmp_json),
|
|
552
|
-
], timeout=30)
|
|
553
|
-
finally:
|
|
554
|
-
tmp_json.unlink(missing_ok=True)
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
# ═══════════════════════════════════════════════════════════════
|
|
558
|
-
# MAIN EVENT LOOP — SSE listener + FSM dispatch
|
|
559
|
-
# ═══════════════════════════════════════════════════════════════
|
|
560
|
-
|
|
561
|
-
def _preflight():
|
|
562
|
-
"""Fail loudly on misconfigurations before connecting to SSE."""
|
|
563
|
-
if MY_SETTLEMENT_PUBKEY == _SETTLEMENT_SENTINEL:
|
|
564
|
-
print(
|
|
565
|
-
"[worker] FATAL: MY_SETTLEMENT_PUBKEY is still the placeholder.\n"
|
|
566
|
-
" Set it to YOUR settlement key (from `heyarp whoami --local`)\n"
|
|
567
|
-
" either by editing this file or via the ARP_SETTLEMENT_PUBKEY env var.\n"
|
|
568
|
-
" Refusing to start — funds would route to the wrong address.",
|
|
569
|
-
file=sys.stderr,
|
|
570
|
-
)
|
|
571
|
-
sys.exit(2)
|
|
572
|
-
if CLUSTER_TAG not in ("0", "1"):
|
|
573
|
-
print(
|
|
574
|
-
f"[worker] FATAL: ARP_CLUSTER_TAG={CLUSTER_TAG!r} must be '0' (devnet) "
|
|
575
|
-
"or '1' (mainnet-beta) — the on-chain validator rejects other values.",
|
|
576
|
-
file=sys.stderr,
|
|
577
|
-
)
|
|
578
|
-
sys.exit(2)
|
|
579
|
-
WORK_DIR.mkdir(parents=True, exist_ok=True)
|
|
580
|
-
# Server resolution: if the operator set `ARP_SERVER_URL` we
|
|
581
|
-
# already inherit it. If they only edited the constant in this
|
|
582
|
-
# file (SERVER_URL differs from the production default), promote
|
|
583
|
-
# that into the env so subprocess `heyarp` calls see it. If
|
|
584
|
-
# neither — leave `os.environ` alone so the CLI's own resolution
|
|
585
|
-
# (`heyarp config set server`, then the CLI default) takes over.
|
|
586
|
-
# This preserves config-only setups that don't set env vars.
|
|
587
|
-
env_set = "ARP_SERVER_URL" in os.environ
|
|
588
|
-
edited_constant = SERVER_URL != _PRODUCTION_DEFAULT_URL
|
|
589
|
-
if not env_set and edited_constant:
|
|
590
|
-
os.environ["ARP_SERVER_URL"] = SERVER_URL
|
|
591
|
-
# Verify the binary is actually callable. The display string we
|
|
592
|
-
# store in HEYARP might be `~/.local/bin/heyarp` (fallback) which
|
|
593
|
-
# doesn't exist on every machine.
|
|
594
|
-
if not os.path.isabs(HEYARP) and shutil.which(HEYARP) is None:
|
|
595
|
-
print(
|
|
596
|
-
f"[worker] FATAL: heyarp CLI not found on $PATH. Install via\n"
|
|
597
|
-
f" npm i -g @heyanon-arp/cli\n"
|
|
598
|
-
f"or set HEYARP_PATH to an absolute path.",
|
|
599
|
-
file=sys.stderr,
|
|
600
|
-
)
|
|
601
|
-
sys.exit(2)
|
|
602
|
-
if os.path.isabs(HEYARP) and not os.path.exists(HEYARP):
|
|
603
|
-
print(
|
|
604
|
-
f"[worker] FATAL: heyarp binary not at {HEYARP!r}. Install via\n"
|
|
605
|
-
f" npm i -g @heyanon-arp/cli\n"
|
|
606
|
-
f"or set HEYARP_PATH to your install location.",
|
|
607
|
-
file=sys.stderr,
|
|
608
|
-
)
|
|
609
|
-
sys.exit(2)
|
|
610
|
-
# Settlement is now signed by the `receipt propose` escrow step
|
|
611
|
-
# using the AGENT's stored settlement key (resolved from local
|
|
612
|
-
# heyarp state), NOT the MY_SETTLEMENT_PUBKEY constant. So this
|
|
613
|
-
# value is only meaningful as an assertion: verify it matches the
|
|
614
|
-
# key the worker will actually be paid on, otherwise funds would
|
|
615
|
-
# arrive at the agent's address and you'd only find out after a job
|
|
616
|
-
# settles. Uses the same sole-agent resolution as the settlement
|
|
617
|
-
# step (run a single worker per DID — see README caveats).
|
|
618
|
-
who = shell_json([HEYARP, "whoami", "--local", "--json"], timeout=15)
|
|
619
|
-
if who is None:
|
|
620
|
-
print(
|
|
621
|
-
"[worker] FATAL: couldn't resolve your local agent via\n"
|
|
622
|
-
" heyarp whoami --local --json\n"
|
|
623
|
-
" (none registered, or more than one without a selector).\n"
|
|
624
|
-
" Register one with `heyarp register` and run one worker per DID.",
|
|
625
|
-
file=sys.stderr,
|
|
626
|
-
)
|
|
627
|
-
sys.exit(2)
|
|
628
|
-
agent_key = who.get("settlementPublicKeyB58", "")
|
|
629
|
-
if agent_key != MY_SETTLEMENT_PUBKEY:
|
|
630
|
-
print(
|
|
631
|
-
f"[worker] FATAL: MY_SETTLEMENT_PUBKEY ({MY_SETTLEMENT_PUBKEY})\n"
|
|
632
|
-
f" does not match your heyarp agent's settlement key ({agent_key}).\n"
|
|
633
|
-
" `receipt propose` (escrow) signs with the AGENT's key, so\n"
|
|
634
|
-
" funds would route to the agent's address, not the one set here.\n"
|
|
635
|
-
" Fix ARP_SETTLEMENT_PUBKEY to match `heyarp whoami --local`, or\n"
|
|
636
|
-
" re-register the agent with the intended settlement key.",
|
|
637
|
-
file=sys.stderr,
|
|
638
|
-
)
|
|
639
|
-
sys.exit(2)
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
def _spawn_inbox() -> subprocess.Popen:
|
|
643
|
-
"""Open the SSE stream. Server selection is propagated via
|
|
644
|
-
`ARP_SERVER_URL` in `_preflight` so we don't need to thread
|
|
645
|
-
`--server` through every subprocess call."""
|
|
646
|
-
return subprocess.Popen(
|
|
647
|
-
[HEYARP, "inbox", "--tail", "--json"],
|
|
648
|
-
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
649
|
-
text=True, bufsize=1,
|
|
650
|
-
)
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
def main():
|
|
654
|
-
_preflight()
|
|
655
|
-
|
|
656
|
-
print("[worker] === ARP Worker ===")
|
|
657
|
-
print(f"[worker] Server: {SERVER_URL}")
|
|
658
|
-
print(f"[worker] Settlement: {MY_SETTLEMENT_PUBKEY} (verified == agent key)")
|
|
659
|
-
print(f"[worker] Cluster tag: {CLUSTER_TAG} ({'mainnet-beta' if CLUSTER_TAG == '1' else 'devnet'})")
|
|
660
|
-
print(f"[worker] Mint: {MINT_PUBKEY} ({'native SOL' if MINT_PUBKEY == NATIVE_SOL_MINT else 'SPL token'})")
|
|
661
|
-
print(f"[worker] Working dir: {WORK_DIR}")
|
|
662
|
-
print("[worker] Auto-accept: handshake ✓ | delegation ✓")
|
|
663
|
-
print("[worker] Auto-settle: receipt → sign → deliver → buyer\n")
|
|
664
|
-
|
|
665
|
-
proc = _spawn_inbox()
|
|
666
|
-
|
|
667
|
-
try:
|
|
668
|
-
for line in proc.stdout:
|
|
669
|
-
line = line.strip()
|
|
670
|
-
if not line:
|
|
671
|
-
continue
|
|
672
|
-
|
|
673
|
-
try:
|
|
674
|
-
data = json.loads(line)
|
|
675
|
-
except json.JSONDecodeError:
|
|
676
|
-
# Non-JSON line (shouldn't happen with --json, but
|
|
677
|
-
# Node sometimes leaks a deprecation warning).
|
|
678
|
-
continue
|
|
679
|
-
|
|
680
|
-
event_type = data.get("type", "")
|
|
681
|
-
envelope = data.get("data", data)
|
|
682
|
-
|
|
683
|
-
# Control messages.
|
|
684
|
-
if event_type in ("tail.started", "connected", "heartbeat"):
|
|
685
|
-
continue
|
|
686
|
-
|
|
687
|
-
if event_type == "stream.ended":
|
|
688
|
-
print("[worker] SSE stream ended — reconnecting in 5s...")
|
|
689
|
-
time.sleep(5)
|
|
690
|
-
proc.terminate()
|
|
691
|
-
proc.wait()
|
|
692
|
-
proc = _spawn_inbox()
|
|
693
|
-
continue
|
|
694
|
-
|
|
695
|
-
# Dispatch by body type.
|
|
696
|
-
body = envelope.get("body", {})
|
|
697
|
-
body_type = body.get("type", "")
|
|
698
|
-
|
|
699
|
-
if body_type == "handshake":
|
|
700
|
-
sender_did = envelope.get("senderDid", "")
|
|
701
|
-
print(f"\n[worker] >>> Handshake from {sender_did[:30]}...")
|
|
702
|
-
auto_accept_handshake(sender_did)
|
|
703
|
-
|
|
704
|
-
elif body_type == "delegation":
|
|
705
|
-
content = body.get("content", {})
|
|
706
|
-
if content.get("action") == "offer":
|
|
707
|
-
rel_id = envelope.get("relationshipId", "")
|
|
708
|
-
delegation_id = content.get("delegation_id", "")
|
|
709
|
-
print(f"\n[worker] >>> Delegation offer {delegation_id[:20]}...")
|
|
710
|
-
auto_accept_delegation(rel_id, delegation_id)
|
|
711
|
-
|
|
712
|
-
elif body_type == "work_request":
|
|
713
|
-
print("\n[worker] >>> Work request received!")
|
|
714
|
-
rel_id = del_id = req_id = ""
|
|
715
|
-
try:
|
|
716
|
-
# 1. Run your business logic.
|
|
717
|
-
output, rel_id, del_id, req_id, sender = process_work_request(envelope)
|
|
718
|
-
|
|
719
|
-
# 2. Send work_response.
|
|
720
|
-
print("[worker] Sending work_response...")
|
|
721
|
-
r = send_work_response(rel_id, del_id, req_id, output)
|
|
722
|
-
if not r["ok"]:
|
|
723
|
-
print(f"[worker] ✗ Response failed: {r.get('stderr','')[:200]}")
|
|
724
|
-
continue
|
|
725
|
-
print("[worker] ✓ work_response sent")
|
|
726
|
-
|
|
727
|
-
# 3. Skip if a receipt already exists for this
|
|
728
|
-
# delegation (one receipt per delegation by FSM).
|
|
729
|
-
rc = shell([HEYARP, "receipts", rel_id], timeout=15)
|
|
730
|
-
if rc["ok"] and del_id[:20] in rc["stdout"]:
|
|
731
|
-
print("[worker] - receipt already exists for delegation, skipping")
|
|
732
|
-
continue
|
|
733
|
-
|
|
734
|
-
# 4. Auto-propose receipt AND deliver the payee
|
|
735
|
-
# settlement signature in ONE command (--json output).
|
|
736
|
-
# For an ESCROW delegation, `receipt propose` signs +
|
|
737
|
-
# delivers the payee settlement signature itself right
|
|
738
|
-
# after committing the receipt — the worker can no
|
|
739
|
-
# longer forget a separate step (an undelivered sig
|
|
740
|
-
# would strand the buyer's cosign on
|
|
741
|
-
# ESC_SETTLEMENT_SIGS_MISSING). It resolves the buyer
|
|
742
|
-
# key, condition hash, amount/decimals/deadline, and
|
|
743
|
-
# the receipt hashes itself, signs the release digest,
|
|
744
|
-
# and delivers via a settlement_signature envelope.
|
|
745
|
-
# Idempotent on re-run; auto-detects full (flat) vs
|
|
746
|
-
# partial (usage_based) release.
|
|
747
|
-
#
|
|
748
|
-
# --cluster-tag is REQUIRED for an escrow delegation
|
|
749
|
-
# (binds the cluster into the signed release digest;
|
|
750
|
-
# a wrong/defaulted cluster fails at the buyer cosign).
|
|
751
|
-
# --fee-bps-at-lock 0: this template targets no-fee
|
|
752
|
-
# deployments (see README). The release digest binds
|
|
753
|
-
# the lock's fee snapshot; passing 0 makes that
|
|
754
|
-
# assumption explicit. On a fee-CHARGING deployment
|
|
755
|
-
# you must pass the lock's actual fee_bps_at_lock +
|
|
756
|
-
# --fee-recipient-at-lock instead.
|
|
757
|
-
print("[worker] Auto-proposing receipt (+ delivering settlement sig for escrow)...")
|
|
758
|
-
# Use shell() (not shell_json) so we still capture the
|
|
759
|
-
# JSON on a PARTIAL-FAILURE exit: an escrow propose that
|
|
760
|
-
# commits the receipt but fails to deliver the settlement
|
|
761
|
-
# sig (no RPC / lock not yet visible) exits non-zero while
|
|
762
|
-
# still emitting the `settlement` block on stdout. Gating
|
|
763
|
-
# on `is None` (shell_json) would skip the recovery handler
|
|
764
|
-
# below and loop into RECEIPT_ALREADY_EXISTS.
|
|
765
|
-
proposal = shell([
|
|
766
|
-
HEYARP, "receipt", "propose", sender,
|
|
767
|
-
del_id, "--auto-hashes", "--rel-id", rel_id,
|
|
768
|
-
"--request-id", req_id, "--verdict", "accepted",
|
|
769
|
-
"--cluster-tag", CLUSTER_TAG, "--mint-pubkey", MINT_PUBKEY,
|
|
770
|
-
"--fee-bps-at-lock", "0",
|
|
771
|
-
"--json",
|
|
772
|
-
], timeout=30)
|
|
773
|
-
receipt = None
|
|
774
|
-
if proposal["stdout"]:
|
|
775
|
-
try:
|
|
776
|
-
receipt = json.loads(proposal["stdout"])
|
|
777
|
-
except Exception:
|
|
778
|
-
receipt = None
|
|
779
|
-
if receipt is None:
|
|
780
|
-
print(f"[worker] - receipt propose failed: {proposal.get('stderr') or proposal.get('error') or 'no parseable output'}")
|
|
781
|
-
continue
|
|
782
|
-
print("[worker] ✓ receipt proposed" + ("" if proposal["ok"] else " (settlement step reported a problem — see below)"))
|
|
783
|
-
|
|
784
|
-
# 5. Report the folded settlement outcome. For an escrow
|
|
785
|
-
# delegation `receipt propose` returns a `settlement`
|
|
786
|
-
# block: {delivered, purpose, ...} on success, or
|
|
787
|
-
# {delivered: false, error, recovery} on partial
|
|
788
|
-
# failure (no RPC / lock not yet visible). The receipt
|
|
789
|
-
# is committed either way; on partial failure re-run
|
|
790
|
-
# propose once the lock is reachable, or follow the
|
|
791
|
-
# `recovery` hint (`heyarp receipt send-payee-sig`).
|
|
792
|
-
settle = receipt.get("settlement")
|
|
793
|
-
if settle is None:
|
|
794
|
-
# Prepaid (non-escrow) delegation — no settlement step.
|
|
795
|
-
print("[worker] ✓ no settlement step (prepaid delegation)")
|
|
796
|
-
elif settle.get("delivered"):
|
|
797
|
-
tag = " (idempotent)" if settle.get("idempotent") else ""
|
|
798
|
-
print(f"[worker] ✓ settlement signed + delivered: {settle.get('purpose')}{tag}")
|
|
799
|
-
else:
|
|
800
|
-
print(
|
|
801
|
-
"[worker] ✗ receipt proposed but settlement sig NOT delivered: "
|
|
802
|
-
f"{settle.get('error', 'unknown')}. Recover: {settle.get('recovery', '')}"
|
|
803
|
-
)
|
|
804
|
-
|
|
805
|
-
except Exception as e:
|
|
806
|
-
print(f"[worker] ✗ Error: {e}")
|
|
807
|
-
# Send error response so the buyer sees something
|
|
808
|
-
# went wrong on our side.
|
|
809
|
-
if rel_id and del_id and req_id:
|
|
810
|
-
try:
|
|
811
|
-
shell([
|
|
812
|
-
HEYARP, "work", "respond",
|
|
813
|
-
rel_id, del_id, req_id,
|
|
814
|
-
"--error", f"PROCESSING_ERROR:{str(e)[:200]}",
|
|
815
|
-
], timeout=30)
|
|
816
|
-
except Exception:
|
|
817
|
-
pass
|
|
818
|
-
|
|
819
|
-
else:
|
|
820
|
-
# Other event types (receipt cosign, dispute, etc.) —
|
|
821
|
-
# logged but not auto-handled. Add handlers here if you
|
|
822
|
-
# need them.
|
|
823
|
-
if body_type:
|
|
824
|
-
print(f"[worker] event: {body_type}", end="\r")
|
|
825
|
-
|
|
826
|
-
except KeyboardInterrupt:
|
|
827
|
-
print("\n[worker] Shutting down...")
|
|
828
|
-
finally:
|
|
829
|
-
proc.terminate()
|
|
830
|
-
proc.wait()
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
if __name__ == "__main__":
|
|
834
|
-
main()
|