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