@heyanon-arp/cli 0.0.3 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,894 @@
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()