@heyanon-arp/cli 0.0.7 → 0.0.9

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.
@@ -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()