diff --git a/CLAUDE.md b/CLAUDE.md index 3730a8b..bf14065 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,6 +20,7 @@ make agent-heartbeat # Build heartbeat WASM agent → agents/heartbeat/agent. make agent-pricewatcher # Build price watcher WASM agent → agents/pricewatcher/agent.wasm make agent-sentinel # Build treasury sentinel WASM agent → agents/sentinel/agent.wasm make agent-x402buyer # Build x402 buyer WASM agent → agents/x402buyer/agent.wasm +make agent-deployer # Build deployer WASM agent → agents/deployer/agent.wasm make test # Run tests: go test -v ./... make lint # golangci-lint (5m timeout) make vet # go vet @@ -31,6 +32,7 @@ make demo-portable # Build + run portable agent demo (run → stop → copy make demo-pricewatcher # Build + run price watcher demo (fetch prices → stop → resume → verify) make demo-sentinel # Build + run treasury sentinel demo (effect lifecycle → crash → reconcile) make demo-x402 # Build + run x402 payment demo (pay for premium data → crash → reconcile) +make demo-deployer # Build + run deployer demo (pay → deploy → monitor → crash → reconcile) make clean # Remove bin/, checkpoints/, agent.wasm ``` @@ -89,6 +91,7 @@ Atomic writes via temp file → fsync → rename. Every checkpoint is also archi - `agents/pricewatcher/` — Demo agent: fetches BTC/ETH prices from CoinGecko, tracks high/low/latest across checkpoint/resume - `agents/sentinel/` — Treasury sentinel: monitors simulated treasury balance, triggers refills with effect-safe intent tracking, demonstrates crash recovery and reconciliation - `agents/x402buyer/` — x402 payment demo: encounters HTTP 402 paywall, pays from budget via wallet_pay hostcall, receives premium data, crash-safe payment reconciliation +- `agents/deployer/` — Deployer demo: pays compute provider, deploys itself, monitors deployment status, multi-step effect-safe crash recovery - `agents/research/example/` — Original demo agent (Survivor) from research phases - `agents/research/reconciliation/` — Bridge reconciliation demo agent (research phase) - `scripts/demo-portable.sh` — End-to-end portable agent demo diff --git a/Makefile b/Makefile index d1c9d03..9d1f45b 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help bootstrap build build-lab clean test lint vet fmt fmt-check tidy agent agent-heartbeat agent-reconciliation agent-pricewatcher agent-sentinel agent-x402buyer run-agent demo demo-portable demo-pricewatcher demo-sentinel demo-x402 gh-check gh-metadata gh-release +.PHONY: help bootstrap build build-lab clean test lint vet fmt fmt-check tidy agent agent-heartbeat agent-reconciliation agent-pricewatcher agent-sentinel agent-x402buyer agent-deployer run-agent demo demo-portable demo-pricewatcher demo-sentinel demo-x402 demo-deployer gh-check gh-metadata gh-release .DEFAULT_GOAL := help @@ -11,6 +11,7 @@ RECONCILIATION_AGENT_DIR := agents/research/reconciliation PRICEWATCHER_AGENT_DIR := agents/pricewatcher SENTINEL_AGENT_DIR := agents/sentinel X402BUYER_AGENT_DIR := agents/x402buyer +DEPLOYER_AGENT_DIR := agents/deployer # Go commands GOCMD := go @@ -60,6 +61,7 @@ clean: ## Remove build artifacts rm -f agents/pricewatcher/agent.wasm rm -f agents/sentinel/agent.wasm rm -f agents/x402buyer/agent.wasm + rm -f agents/deployer/agent.wasm @echo "Clean complete" test: ## Run tests (with race detector) @@ -145,6 +147,13 @@ agent-x402buyer: ## Build x402 buyer demo agent WASM cd $(X402BUYER_AGENT_DIR) && $(MAKE) build @echo "Agent built: $(X402BUYER_AGENT_DIR)/agent.wasm" +agent-deployer: ## Build deployer demo agent WASM + @echo "Building deployer agent..." + @which tinygo > /dev/null || \ + (echo "tinygo not found. See docs/governance/DEVELOPMENT.md for installation" && exit 1) + cd $(DEPLOYER_AGENT_DIR) && $(MAKE) build + @echo "Agent built: $(DEPLOYER_AGENT_DIR)/agent.wasm" + demo: build agent-reconciliation ## Build and run reconciliation demo @echo "Building demo runner..." @mkdir -p $(BINARY_DIR) @@ -175,6 +184,14 @@ demo-x402: build agent-x402buyer ## Run the x402 payment demo (pay for premium d @chmod +x scripts/demo-x402.sh @./scripts/demo-x402.sh +demo-deployer: build agent-deployer ## Run the deployer demo (pay, deploy, monitor, crash recovery) + @echo "Building mockcloud server..." + @mkdir -p $(BINARY_DIR) + $(GOBUILD) -o $(BINARY_DIR)/mockcloud ./cmd/mockcloud + @echo "Running Deployer Demo..." + @chmod +x scripts/demo-deployer.sh + @./scripts/demo-deployer.sh + check: fmt-check vet lint test ## Run all checks (formatting, vet, lint, tests) @echo "All checks passed" diff --git a/agents/deployer/Makefile b/agents/deployer/Makefile new file mode 100644 index 0000000..7fa1ed3 --- /dev/null +++ b/agents/deployer/Makefile @@ -0,0 +1,7 @@ +.PHONY: build clean + +build: + tinygo build -target=wasi -no-debug -o agent.wasm . + +clean: + rm -f agent.wasm diff --git a/agents/deployer/agent.manifest.json b/agents/deployer/agent.manifest.json new file mode 100644 index 0000000..75c2424 --- /dev/null +++ b/agents/deployer/agent.manifest.json @@ -0,0 +1,26 @@ +{ + "capabilities": { + "clock": { "version": 1 }, + "rand": { "version": 1 }, + "log": { "version": 1 }, + "wallet": { "version": 1 }, + "http": { + "version": 1, + "options": { + "allowed_hosts": ["localhost"], + "timeout_ms": 5000, + "max_response_bytes": 8192 + } + }, + "x402": { + "version": 1, + "options": { + "allowed_recipients": ["mockcloud-provider"], + "max_payment_microcents": 5000000 + } + } + }, + "resource_limits": { + "max_memory_bytes": 67108864 + } +} diff --git a/agents/deployer/go.mod b/agents/deployer/go.mod new file mode 100644 index 0000000..8362bbe --- /dev/null +++ b/agents/deployer/go.mod @@ -0,0 +1,7 @@ +module github.com/simonovic86/igor/agents/deployer + +go 1.24 + +require github.com/simonovic86/igor/sdk/igor v0.0.0 + +replace github.com/simonovic86/igor/sdk/igor => ../../sdk/igor diff --git a/agents/deployer/main.go b/agents/deployer/main.go new file mode 100644 index 0000000..0668acb --- /dev/null +++ b/agents/deployer/main.go @@ -0,0 +1,616 @@ +// SPDX-License-Identifier: Apache-2.0 + +//go:build tinygo || wasip1 + +// deployer: a demo agent that pays for and deploys itself to a compute provider. +// +// The agent demonstrates Igor's self-provisioning capabilities: +// - Checks its own budget to see if it can afford compute +// - Encounters HTTP 402 from the provider (payment required) +// - Pays from its budget via wallet_pay hostcall +// - Deploys itself with the payment receipt +// - Monitors deployment status until running +// - Effect lifecycle ensures crash-safe multi-step workflow +// +// The compute provider is a mock — no real cloud involved. +// The point: an agent can pay for and provision its own infrastructure. +package main + +import ( + "encoding/binary" + + "github.com/simonovic86/igor/sdk/igor" +) + +const ( + providerURL = "http://localhost:8500" + deployURL = providerURL + "/v1/deploy" + deployCost = int64(500_000) // matches mockcloud +) + +// Intent type prefixes to distinguish payment vs deploy intents in EffectLog. +const ( + intentPayment byte = 0x01 + intentDeployment byte = 0x02 +) + +// Phases of the agent. +const ( + phaseCheckBudget uint8 = 0 // assess if budget is sufficient + phasePayProvider uint8 = 1 // pay compute provider + phaseDeploy uint8 = 2 // deploy with receipt + phaseMonitor uint8 = 3 // poll deployment status + phaseReconciling uint8 = 4 // handling unresolved intents on resume +) + +// Deployer implements a demo agent that self-provisions compute. +type Deployer struct { + TickCount uint64 + BirthNano int64 + LastNano int64 + Phase uint8 + DeploymentID [64]byte // fixed-size deployment ID + DeployIDLen uint32 + DeployStatus uint8 // 0=none, 1=pending, 2=provisioning, 3=running, 4=terminated + PaidCount uint32 + TotalPaid int64 + LastReceipt []byte + + // Effect tracking for crash-safe multi-step workflow. + Effects igor.EffectLog +} + +func (d *Deployer) Init() {} + +func (d *Deployer) Tick() bool { + d.TickCount++ + now := igor.ClockNow() + if d.BirthNano == 0 { + d.BirthNano = now + igor.Logf("[deployer] Agent initialized. Self-provisioning compute.") + } + d.LastNano = now + ageSec := (d.LastNano - d.BirthNano) / 1_000_000_000 + + // On resume, handle unresolved intents first. + unresolved := d.Effects.Unresolved() + if len(unresolved) > 0 { + d.Phase = phaseReconciling + for _, intent := range unresolved { + d.reconcile(intent) + } + d.Effects.Prune() + // After reconciliation, decide where to go. + if d.DeployIDLen > 0 { + d.Phase = phaseMonitor + } else { + d.Phase = phaseCheckBudget + } + return true + } + + // Check for pending (Recorded) intents that need execution. + pending := d.Effects.Pending() + for _, intent := range pending { + if intent.State == igor.Recorded { + if len(intent.Data) > 0 && intent.Data[0] == intentPayment { + return d.executePayment(intent) + } + if len(intent.Data) > 0 && intent.Data[0] == intentDeployment { + return d.executeDeploy(intent) + } + } + } + + // Phase-based normal operation. + switch d.Phase { + case phaseCheckBudget: + return d.tickCheckBudget(ageSec) + case phasePayProvider: + return d.tickPayProvider() + case phaseDeploy: + return d.tickDeploy() + case phaseMonitor: + return d.tickMonitor(ageSec) + } + + return false +} + +// --- Phase: Check Budget --- + +func (d *Deployer) tickCheckBudget(ageSec int64) bool { + // Already have a deployment? Go to monitor. + if d.DeployIDLen > 0 { + igor.Logf("[deployer] tick=%d age=%ds Have deployment, resuming monitoring.", + d.TickCount, ageSec) + d.Phase = phaseMonitor + return true + } + + balance := igor.WalletBalance() + igor.Logf("[deployer] tick=%d age=%ds budget=%d deploy_cost=%d", + d.TickCount, ageSec, balance, deployCost) + + if balance >= deployCost { + igor.Logf("[deployer] Budget sufficient. Proceeding to pay compute provider.") + d.Phase = phasePayProvider + return true + } + + igor.Logf("[deployer] Insufficient budget. Waiting...") + return false +} + +// --- Phase: Pay Provider --- + +func (d *Deployer) tickPayProvider() bool { + // First, probe the provider to get payment terms. + status, body, err := igor.HTTPPost(deployURL, "", nil) + if err != nil { + igor.Logf("[deployer] HTTP error probing provider: %s", err.Error()) + return false + } + + if status != 402 { + igor.Logf("[deployer] Expected 402, got %d", status) + return false + } + + amount, recipient, memo := parsePaymentTerms(body) + if amount <= 0 { + igor.Logf("[deployer] Invalid payment terms in 402 response") + return false + } + + igor.Logf("[deployer] 402 PAYMENT REQUIRED: %d microcents to %s (%s)", + amount, recipient, memo) + + return d.recordPaymentIntent(amount, recipient, memo) +} + +func (d *Deployer) recordPaymentIntent(amount int64, recipient, memo string) bool { + var key [16]byte + _ = igor.RandBytes(key[:]) + + data := igor.NewEncoder(128). + Raw([]byte{intentPayment}). // type prefix + Int64(amount). + Bytes([]byte(recipient)). + Bytes([]byte(memo)). + Finish() + + if err := d.Effects.Record(key[:], data); err != nil { + igor.Logf("[deployer] ERROR: failed to record payment intent: %s", err.Error()) + return false + } + + igor.Logf("[deployer] Payment intent RECORDED (key=%x...). Waiting for checkpoint...", + key[:4]) + return false // Wait for checkpoint before execution. +} + +func (d *Deployer) executePayment(intent igor.Intent) bool { + // Decode: [type:1][amount:8][recipient][memo] + dd := igor.NewDecoder(intent.Data[1:]) // skip type prefix + amount := dd.Int64() + recipient := string(dd.Bytes()) + memo := string(dd.Bytes()) + if err := dd.Err(); err != nil { + igor.Logf("[deployer] ERROR: corrupt payment intent: %s", err.Error()) + _ = d.Effects.Compensate(intent.ID) + return false + } + + if err := d.Effects.Begin(intent.ID); err != nil { + igor.Logf("[deployer] ERROR: failed to begin payment: %s", err.Error()) + return false + } + + igor.Logf("[deployer] Payment IN-FLIGHT: %d microcents to %s (key=%x...)", + amount, recipient, intent.ID[:4]) + + // === DANGER ZONE: crash between Begin and Confirm → Unresolved === + + receipt, err := igor.WalletPay(amount, recipient, memo) + if err != nil { + igor.Logf("[deployer] Payment FAILED: %s", err.Error()) + _ = d.Effects.Compensate(intent.ID) + d.Effects.Prune() + return false + } + + // Payment succeeded. + d.PaidCount++ + d.TotalPaid += amount + d.LastReceipt = receipt + + if err := d.Effects.Confirm(intent.ID); err != nil { + igor.Logf("[deployer] ERROR: failed to confirm payment: %s", err.Error()) + return false + } + + igor.Logf("[deployer] Payment CONFIRMED: %d microcents (receipt=%d bytes). Proceeding to deploy.", + amount, len(receipt)) + d.Effects.Prune() + d.Phase = phaseDeploy + return true +} + +// --- Phase: Deploy --- + +func (d *Deployer) tickDeploy() bool { + if len(d.LastReceipt) == 0 { + igor.Logf("[deployer] No receipt available. Restarting payment.") + d.Phase = phasePayProvider + return true + } + return d.recordDeployIntent() +} + +func (d *Deployer) recordDeployIntent() bool { + var key [16]byte + _ = igor.RandBytes(key[:]) + + data := igor.NewEncoder(128). + Raw([]byte{intentDeployment}). // type prefix + Bytes(d.LastReceipt). + Finish() + + if err := d.Effects.Record(key[:], data); err != nil { + igor.Logf("[deployer] ERROR: failed to record deploy intent: %s", err.Error()) + return false + } + + igor.Logf("[deployer] Deploy intent RECORDED (key=%x...). Waiting for checkpoint...", + key[:4]) + return false +} + +func (d *Deployer) executeDeploy(intent igor.Intent) bool { + // Decode: [type:1][receipt] + dd := igor.NewDecoder(intent.Data[1:]) + receipt := dd.Bytes() + if err := dd.Err(); err != nil { + igor.Logf("[deployer] ERROR: corrupt deploy intent: %s", err.Error()) + _ = d.Effects.Compensate(intent.ID) + return false + } + + if err := d.Effects.Begin(intent.ID); err != nil { + igor.Logf("[deployer] ERROR: failed to begin deploy: %s", err.Error()) + return false + } + + igor.Logf("[deployer] Deployment IN-FLIGHT (key=%x...)", intent.ID[:4]) + + // === DANGER ZONE === + + headers := map[string]string{ + "X-Payment": encodeBytesHex(receipt), + "Content-Type": "application/json", + } + body := []byte(`{"wasm_hash":"0000000000000000000000000000000000000000000000000000000000000000","budget_offer":500000}`) + + status, resp, err := igor.HTTPRequest("POST", deployURL, headers, body) + if err != nil { + igor.Logf("[deployer] Deploy HTTP error: %s", err.Error()) + _ = d.Effects.Compensate(intent.ID) + d.Effects.Prune() + return false + } + + if status != 201 { + igor.Logf("[deployer] Deploy failed: status=%d body=%s", status, string(resp)) + _ = d.Effects.Compensate(intent.ID) + d.Effects.Prune() + return false + } + + // Parse deployment ID from JSON response. + depID := extractJSONString(resp, "deployment_id") + if depID == "" { + igor.Logf("[deployer] Deploy response missing deployment_id") + _ = d.Effects.Compensate(intent.ID) + d.Effects.Prune() + return false + } + + d.setDeploymentID(depID) + d.DeployStatus = 1 // pending + d.LastReceipt = nil // consumed + + if err := d.Effects.Confirm(intent.ID); err != nil { + igor.Logf("[deployer] ERROR: failed to confirm deploy: %s", err.Error()) + return false + } + + igor.Logf("[deployer] DEPLOYED: id=%s status=pending. Now monitoring.", depID) + d.Effects.Prune() + d.Phase = phaseMonitor + return true +} + +// --- Phase: Monitor --- + +func (d *Deployer) tickMonitor(ageSec int64) bool { + if d.DeployIDLen == 0 { + igor.Logf("[deployer] No deployment to monitor. Restarting.") + d.Phase = phaseCheckBudget + return true + } + + depID := d.getDeploymentID() + + // Poll every 3 ticks. + if d.TickCount%3 != 0 { + if d.TickCount%5 == 0 { + igor.Logf("[deployer] tick=%d age=%ds monitoring deployment=%s status=%s payments=%d total_paid=%d", + d.TickCount, ageSec, depID, statusName(d.DeployStatus), d.PaidCount, d.TotalPaid) + } + return false + } + + statusURL := deployURL + "/" + depID + status, resp, err := igor.HTTPGet(statusURL) + if err != nil { + igor.Logf("[deployer] Status check error: %s", err.Error()) + return false + } + + if status != 200 { + igor.Logf("[deployer] Status check failed: %d", status) + return false + } + + depStatus := extractJSONString(resp, "status") + switch depStatus { + case "pending": + d.DeployStatus = 1 + case "provisioning": + d.DeployStatus = 2 + case "running": + if d.DeployStatus != 3 { + igor.Logf("[deployer] DEPLOYMENT RUNNING! id=%s — self-provisioning complete.", depID) + } + d.DeployStatus = 3 + case "terminated": + d.DeployStatus = 4 + } + + igor.Logf("[deployer] tick=%d deployment=%s status=%s", + d.TickCount, depID, depStatus) + + return false +} + +// --- Reconciliation --- + +func (d *Deployer) reconcile(intent igor.Intent) { + if len(intent.Data) == 0 { + _ = d.Effects.Compensate(intent.ID) + return + } + + switch intent.Data[0] { + case intentPayment: + d.reconcilePayment(intent) + case intentDeployment: + d.reconcileDeploy(intent) + default: + igor.Logf("[deployer] RECONCILING: unknown intent type %d, compensating", intent.Data[0]) + _ = d.Effects.Compensate(intent.ID) + } +} + +func (d *Deployer) reconcilePayment(intent igor.Intent) { + igor.Logf("[deployer] RECONCILING: unresolved payment (key=%x...)", intent.ID[:4]) + + dd := igor.NewDecoder(intent.Data[1:]) + amount := dd.Int64() + recipient := string(dd.Bytes()) + _ = dd.Bytes() // memo + + // In production: check on-chain settlement. + // Simulate: if first byte of key is even, payment completed. + paymentCompleted := (intent.ID[0] % 2) == 0 + + if paymentCompleted { + d.PaidCount++ + d.TotalPaid += amount + if err := d.Effects.Confirm(intent.ID); err != nil { + igor.Logf("[deployer] ERROR: reconcile confirm failed: %s", err.Error()) + return + } + igor.Logf("[deployer] Reconciled: payment of %d to %s COMPLETED before crash", + amount, recipient) + } else { + if err := d.Effects.Compensate(intent.ID); err != nil { + igor.Logf("[deployer] ERROR: reconcile compensate failed: %s", err.Error()) + return + } + igor.Logf("[deployer] Reconciled: payment of %d to %s DID NOT complete — will retry", + amount, recipient) + } +} + +func (d *Deployer) reconcileDeploy(intent igor.Intent) { + igor.Logf("[deployer] RECONCILING: unresolved deployment (key=%x...)", intent.ID[:4]) + + // Check if we already have a deployment ID (set before crash). + if d.DeployIDLen > 0 { + depID := d.getDeploymentID() + // Query provider for status. + statusURL := deployURL + "/" + depID + status, _, err := igor.HTTPGet(statusURL) + if err == nil && status == 200 { + if err := d.Effects.Confirm(intent.ID); err != nil { + igor.Logf("[deployer] ERROR: reconcile confirm failed: %s", err.Error()) + return + } + igor.Logf("[deployer] Reconciled: deployment %s EXISTS — confirming", depID) + return + } + } + + // Deployment didn't complete — compensate. + if err := d.Effects.Compensate(intent.ID); err != nil { + igor.Logf("[deployer] ERROR: reconcile compensate failed: %s", err.Error()) + return + } + igor.Logf("[deployer] Reconciled: deployment DID NOT complete — will retry") +} + +// --- Checkpoint Serialization --- + +func (d *Deployer) Marshal() []byte { + return igor.NewEncoder(512). + Uint64(d.TickCount). + Int64(d.BirthNano). + Int64(d.LastNano). + Raw([]byte{d.Phase}). + Raw(d.DeploymentID[:]). + Uint32(d.DeployIDLen). + Raw([]byte{d.DeployStatus}). + Uint32(d.PaidCount). + Int64(d.TotalPaid). + Bytes(d.LastReceipt). + Bytes(d.Effects.Marshal()). + Finish() +} + +func (d *Deployer) Unmarshal(data []byte) { + dd := igor.NewDecoder(data) + d.TickCount = dd.Uint64() + d.BirthNano = dd.Int64() + d.LastNano = dd.Int64() + + phaseBuf := dd.FixedBytes(1) + if len(phaseBuf) > 0 { + d.Phase = phaseBuf[0] + } + + dd.ReadInto(d.DeploymentID[:]) + d.DeployIDLen = dd.Uint32() + + statusBuf := dd.FixedBytes(1) + if len(statusBuf) > 0 { + d.DeployStatus = statusBuf[0] + } + + d.PaidCount = dd.Uint32() + d.TotalPaid = dd.Int64() + d.LastReceipt = dd.Bytes() + d.Effects.Unmarshal(dd.Bytes()) // THE RESUME RULE: InFlight → Unresolved + if err := dd.Err(); err != nil { + panic("unmarshal checkpoint: " + err.Error()) + } +} + +// --- Helpers --- + +func (d *Deployer) setDeploymentID(id string) { + n := len(id) + if n > 64 { + n = 64 + } + copy(d.DeploymentID[:], id[:n]) + d.DeployIDLen = uint32(n) +} + +func (d *Deployer) getDeploymentID() string { + return string(d.DeploymentID[:d.DeployIDLen]) +} + +func statusName(s uint8) string { + switch s { + case 0: + return "none" + case 1: + return "pending" + case 2: + return "provisioning" + case 3: + return "running" + case 4: + return "terminated" + default: + return "unknown" + } +} + +// parsePaymentTerms extracts amount, recipient, and memo from a 402 response body. +// Format: [amount:8 LE][recipient_len:4 LE][recipient][memo_len:4 LE][memo] +func parsePaymentTerms(body []byte) (amount int64, recipient, memo string) { + if len(body) < 12 { + return 0, "", "" + } + amount = int64(binary.LittleEndian.Uint64(body[:8])) + off := 8 + recipientLen := int(binary.LittleEndian.Uint32(body[off:])) + off += 4 + if off+recipientLen > len(body) { + return 0, "", "" + } + recipient = string(body[off : off+recipientLen]) + off += recipientLen + if off+4 > len(body) { + return amount, recipient, "" + } + memoLen := int(binary.LittleEndian.Uint32(body[off:])) + off += 4 + if off+memoLen > len(body) { + return amount, recipient, "" + } + memo = string(body[off : off+memoLen]) + return amount, recipient, memo +} + +// extractJSONString does minimal JSON extraction without encoding/json (keeps WASM small). +// Looks for "key":"value" and returns value. +func extractJSONString(data []byte, key string) string { + s := string(data) + needle := `"` + key + `":"` + idx := indexOf(s, needle) + if idx < 0 { + return "" + } + start := idx + len(needle) + end := indexOfFrom(s, `"`, start) + if end < 0 { + return "" + } + return s[start:end] +} + +func indexOf(s, sub string) int { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return i + } + } + return -1 +} + +func indexOfFrom(s, sub string, from int) int { + for i := from; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return i + } + } + return -1 +} + +// encodeBytesHex encodes bytes as hex string (no dependencies). +func encodeBytesHex(data []byte) string { + const hex = "0123456789abcdef" + buf := make([]byte, len(data)*2) + for i, b := range data { + buf[i*2] = hex[b>>4] + buf[i*2+1] = hex[b&0x0f] + } + return string(buf) +} + +func init() { igor.Run(&Deployer{}) } +func main() {} diff --git a/cmd/mockcloud/main.go b/cmd/mockcloud/main.go new file mode 100644 index 0000000..85416dd --- /dev/null +++ b/cmd/mockcloud/main.go @@ -0,0 +1,211 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Mock compute provider server for the deployer demo. +// +// Simulates a decentralized compute marketplace (like Akash or Golem). +// Agents pay for compute and deploy themselves. +// +// Endpoints: +// +// GET /health — health check +// POST /v1/deploy — request deployment (requires X-Payment header) +// GET /v1/deploy/{id} — check deployment status +// POST /v1/deploy/{id}/terminate — terminate deployment +// +// Without X-Payment header, POST /v1/deploy returns 402 with payment terms. +// Payment terms are binary-encoded: +// +// [amount:8 LE][recipient_len:4 LE][recipient][memo_len:4 LE][memo] +package main + +import ( + "encoding/binary" + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "strings" + "sync" + "syscall" + "time" +) + +const ( + deploymentCost int64 = 500_000 // 0.50 currency units + paymentRecipient string = "mockcloud-provider" + paymentMemo string = "compute-deployment" +) + +type deployment struct { + ID string `json:"deployment_id"` + WASMHash string `json:"wasm_hash"` + Status string `json:"status"` + queryCount int +} + +type server struct { + mu sync.Mutex + deployments map[string]*deployment + counter int +} + +func newServer() *server { + return &server{ + deployments: make(map[string]*deployment), + } +} + +func main() { + addr := ":8500" + if envAddr := os.Getenv("MOCKCLOUD_ADDR"); envAddr != "" { + addr = envAddr + } + + s := newServer() + mux := http.NewServeMux() + mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, "ok") + }) + mux.HandleFunc("/v1/deploy", s.handleDeploy) + mux.HandleFunc("/v1/deploy/", s.handleDeployByID) + + srv := &http.Server{ + Addr: addr, + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + } + + done := make(chan os.Signal, 1) + signal.Notify(done, syscall.SIGINT, syscall.SIGTERM) + + go func() { + log.Printf("[mockcloud] Listening on %s", addr) + if err := srv.ListenAndServe(); err != http.ErrServerClosed { + log.Fatalf("[mockcloud] Server error: %v", err) + } + }() + + <-done + log.Println("[mockcloud] Shutting down...") + srv.Close() +} + +func (s *server) handleDeploy(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + payment := r.Header.Get("X-Payment") + if payment == "" { + // No payment — return 402 with payment terms. + log.Printf("[mockcloud] 402 → %s (no payment header)", r.RemoteAddr) + w.Header().Set("Content-Type", "application/octet-stream") + w.WriteHeader(http.StatusPaymentRequired) + _, _ = w.Write(encodePaymentTerms(deploymentCost, paymentRecipient, paymentMemo)) + return + } + + // Parse request body for wasm_hash. + var req struct { + WASMHash string `json:"wasm_hash"` + BudgetOffer int64 `json:"budget_offer"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + s.mu.Lock() + s.counter++ + id := fmt.Sprintf("dep-%04d", s.counter) + dep := &deployment{ + ID: id, + WASMHash: req.WASMHash, + Status: "pending", + } + s.deployments[id] = dep + s.mu.Unlock() + + log.Printf("[mockcloud] 201 → %s deployment=%s wasm=%s budget=%d", + r.RemoteAddr, id, req.WASMHash, req.BudgetOffer) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(dep) +} + +func (s *server) handleDeployByID(w http.ResponseWriter, r *http.Request) { + // Extract deployment ID from path: /v1/deploy/{id} or /v1/deploy/{id}/terminate + path := strings.TrimPrefix(r.URL.Path, "/v1/deploy/") + parts := strings.SplitN(path, "/", 2) + id := parts[0] + + if id == "" { + http.Error(w, "missing deployment id", http.StatusBadRequest) + return + } + + s.mu.Lock() + dep, ok := s.deployments[id] + if !ok { + s.mu.Unlock() + http.Error(w, "deployment not found", http.StatusNotFound) + return + } + + // Check for terminate action. + if len(parts) > 1 && parts[1] == "terminate" && r.Method == http.MethodPost { + dep.Status = "terminated" + s.mu.Unlock() + log.Printf("[mockcloud] TERMINATED deployment=%s", id) + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(dep) + return + } + + if r.Method != http.MethodGet { + s.mu.Unlock() + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + // Progress status on each query. + dep.queryCount++ + switch { + case dep.queryCount <= 1: + dep.Status = "pending" + case dep.queryCount <= 2: + dep.Status = "provisioning" + default: + dep.Status = "running" + } + status := dep.Status + s.mu.Unlock() + + log.Printf("[mockcloud] STATUS deployment=%s status=%s query=%d", id, status, dep.queryCount) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(dep) +} + +// encodePaymentTerms encodes payment parameters for the 402 response body. +// Format: [amount:8 LE][recipient_len:4 LE][recipient][memo_len:4 LE][memo] +func encodePaymentTerms(amount int64, recipient, memo string) []byte { + size := 8 + 4 + len(recipient) + 4 + len(memo) + buf := make([]byte, size) + off := 0 + binary.LittleEndian.PutUint64(buf[off:], uint64(amount)) + off += 8 + binary.LittleEndian.PutUint32(buf[off:], uint32(len(recipient))) + off += 4 + copy(buf[off:], recipient) + off += len(recipient) + binary.LittleEndian.PutUint32(buf[off:], uint32(len(memo))) + off += 4 + copy(buf[off:], memo) + return buf +} diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index f425c07..80fe719 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -2,7 +2,7 @@ Truth matrix mapping spec documents to current code. Source of truth is the code; docs have been updated to match. -Last updated: 2026-03-13 +Last updated: 2026-03-15 ## Portable Agent (Product Phase 1) @@ -21,6 +21,29 @@ Last updated: 2026-03-13 | Heartbeat demo agent | Implemented | `agents/heartbeat/main.go` | | Portable demo script | Implemented | `scripts/demo-portable.sh` | +## Agent Self-Provisioning (Product Phase 2) + +| Aspect | Status | Code Reference | +|--------|--------|----------------| +| HTTP hostcall (`http_request`) | Implemented | `internal/hostcall/http.go` — allowed_hosts, timeout, max response size. Recorded for replay (CM-4). | +| HTTP SDK wrappers (`HTTPGet`, `HTTPPost`, `HTTPRequest`) | Implemented | `sdk/igor/hostcalls_http_wasm.go` — safe wrappers with buffer retry | +| Payment hostcall (`wallet_pay`) | Implemented | `internal/hostcall/payment.go` — budget deduction, Ed25519-signed receipt, recorded for replay | +| Payment SDK wrapper (`WalletPay`) | Implemented | `sdk/igor/hostcalls_payment_wasm.go` — safe wrapper with buffer retry | +| Manifest: `x402` capability | Implemented | `internal/hostcall/registry.go` — gated on `allowed_recipients`, `max_payment_microcents` | +| Effect lifecycle model | Implemented | `sdk/igor/effects.go` — EffectLog, intent state machine, resume rule (InFlight→Unresolved) | +| Effect serialization in checkpoint | Implemented | `sdk/igor/effects.go` `Marshal`/`Unmarshal` — wire format with automatic InFlight→Unresolved transition | +| Price watcher demo agent | Implemented | `agents/pricewatcher/` — fetches BTC/ETH prices via HTTP, tracks high/low/latest across checkpoint/resume | +| Price watcher demo script | Implemented | `scripts/demo-pricewatcher.sh` | +| Treasury sentinel demo agent | Implemented | `agents/sentinel/` — treasury monitoring with effect-safe crash recovery | +| Treasury sentinel demo script | Implemented | `scripts/demo-sentinel.sh` | +| x402 buyer demo agent | Implemented | `agents/x402buyer/` — encounters 402 paywall, pays from budget, receives premium data, crash-safe | +| x402 buyer demo script | Implemented | `scripts/demo-x402.sh` | +| Mock paywall server | Implemented | `cmd/paywall/main.go` — 402 payment terms, receipt-gated access | +| Deployer demo agent | Implemented | `agents/deployer/` — pays compute provider, deploys with receipt, monitors status, multi-step effect-safe workflow | +| Deployer demo script | Implemented | `scripts/demo-deployer.sh` | +| Mock compute provider | Implemented | `cmd/mockcloud/main.go` — 402 payment terms, deployment lifecycle (pending→provisioning→running) | +| Self-migration | Not implemented | Roadmap: agent decides when/where to move based on price/performance | + ## Checkpoint Format | Aspect | Status | Code Reference | @@ -86,7 +109,8 @@ Last updated: 2026-03-13 | Manifest in migration package | Implemented | `pkg/protocol/messages.go` `ManifestData` | | Pre-migration capability check (CE-5) | Implemented | `internal/migration/service.go` `handleIncomingMigration` | | HTTP hostcall (`http_request`) | Implemented | `internal/hostcall/http.go` — side-effect hostcall with allowed_hosts, timeout, max response size. Recorded in event log for replay. | -| Side-effect authority gating (CM-5) | Not implemented | Requires authority state machine (Phase 5). HTTP hostcall exists but is not gated on ACTIVE_OWNER. When authority gating is added, side-effect hostcalls MUST only execute in ACTIVE_OWNER state. | +| Payment hostcall (`wallet_pay`) | Implemented | `internal/hostcall/payment.go` — side-effect hostcall with allowed_recipients, max_payment_microcents. Budget deduction, Ed25519-signed receipt. Recorded in event log for replay. | +| Side-effect authority gating (CM-5) | Not implemented | Requires authority state machine (Phase 5). HTTP and wallet_pay hostcalls exist but are not gated on ACTIVE_OWNER. When authority gating is added, side-effect hostcalls MUST only execute in ACTIVE_OWNER state. | | KV storage hostcalls | Not implemented | Roadmap future task | ## Identity and Authority @@ -142,6 +166,8 @@ Last updated: 2026-03-13 | Heartbeat demo agent | Implemented | `agents/heartbeat/` — tick count, age, milestones | | Price watcher demo agent | Implemented | `agents/pricewatcher/` — fetches BTC/ETH prices via HTTP hostcall, tracks high/low/latest | | Treasury sentinel demo agent | Implemented | `agents/sentinel/` — treasury monitoring with effect-safe crash recovery | +| x402 buyer demo agent | Implemented | `agents/x402buyer/` — 402 paywall payment with crash-safe effect lifecycle | +| Deployer demo agent | Implemented | `agents/deployer/` — multi-step self-provisioning: pay, deploy, monitor with crash recovery | | Effect lifecycle model (SDK) | Implemented | `sdk/igor/effects.go` — EffectLog, intent state machine (Recorded→InFlight→Confirmed/Unresolved→Compensated), resume rule | | Lineage chain verifier | Implemented | `internal/inspector/chain.go` `VerifyChain`, `PrintChain` | | `--simulate` CLI flag | Implemented | `cmd/igord-lab/main.go` | diff --git a/docs/SPEC_INDEX.md b/docs/SPEC_INDEX.md index bb280a6..b8ebdca 100644 --- a/docs/SPEC_INDEX.md +++ b/docs/SPEC_INDEX.md @@ -43,7 +43,8 @@ Runtime documents describe **HOW** Igor operates — implementation details, pro | [BUDGET_MODEL.md](./runtime/BUDGET_MODEL.md) | Economic model, execution metering, and budget enforcement. | [RUNTIME_ENFORCEMENT_INVARIANTS](./enforcement/RUNTIME_ENFORCEMENT_INVARIANTS.md) | | [THREAT_MODEL.md](./runtime/THREAT_MODEL.md) | Canonical runtime threat assumptions: system/failure model, adversary classes, network assumptions, and trust boundaries. | [SECURITY_MODEL](./runtime/SECURITY_MODEL.md), [EXECUTION_INVARIANTS](./constitution/EXECUTION_INVARIANTS.md), [MIGRATION_CONTINUITY](./constitution/MIGRATION_CONTINUITY.md) | | [SECURITY_MODEL.md](./runtime/SECURITY_MODEL.md) | Current security mechanisms and explicit limitations under the threat assumptions. | [THREAT_MODEL](./runtime/THREAT_MODEL.md) | -| [HOSTCALL_ABI.md](./runtime/HOSTCALL_ABI.md) | Hostcall interface design: WASM module namespace, capability namespaces (clock, rand, kv, log, net, wallet), function signatures, error conventions, manifest format, wazero integration. | [CAPABILITY_MEMBRANE](./constitution/CAPABILITY_MEMBRANE.md), [CAPABILITY_ENFORCEMENT](./enforcement/CAPABILITY_ENFORCEMENT.md), [ARCHITECTURE](./runtime/ARCHITECTURE.md) | +| [HOSTCALL_ABI.md](./runtime/HOSTCALL_ABI.md) | Hostcall interface design: WASM module namespace, capability namespaces (clock, rand, log, wallet, x402, http, kv), function signatures, error conventions, manifest format, wazero integration. | [CAPABILITY_MEMBRANE](./constitution/CAPABILITY_MEMBRANE.md), [CAPABILITY_ENFORCEMENT](./enforcement/CAPABILITY_ENFORCEMENT.md), [ARCHITECTURE](./runtime/ARCHITECTURE.md) | +| [EFFECT_LIFECYCLE.md](./runtime/EFFECT_LIFECYCLE.md) | Effect lifecycle model: crash-safe external action tracking, intent state machine (Recorded→InFlight→Confirmed/Unresolved→Compensated), resume rule, SDK API, checkpoint serialization. | [CAPABILITY_MEMBRANE](./constitution/CAPABILITY_MEMBRANE.md), [HOSTCALL_ABI](./runtime/HOSTCALL_ABI.md), [ARCHITECTURE](./runtime/ARCHITECTURE.md) | | [REPLAY_ENGINE.md](./runtime/REPLAY_ENGINE.md) | Replay engine design (draft): event log structure, replay verification flow, divergence handling, replay modes. | [CAPABILITY_MEMBRANE](./constitution/CAPABILITY_MEMBRANE.md), [CAPABILITY_ENFORCEMENT](./enforcement/CAPABILITY_ENFORCEMENT.md), [EXECUTION_INVARIANTS](./constitution/EXECUTION_INVARIANTS.md) | | [LEASE_EPOCH.md](./runtime/LEASE_EPOCH.md) | Lease-based authority epochs design (draft): time-bounded authority, epoch advancement, anti-clone enforcement, renewal protocol. | [AUTHORITY_STATE_MACHINE](./constitution/AUTHORITY_STATE_MACHINE.md), [OWNERSHIP_AND_AUTHORITY](./constitution/OWNERSHIP_AND_AUTHORITY.md), [THREAT_MODEL](./runtime/THREAT_MODEL.md) | | [SIGNED_LINEAGE.md](./runtime/SIGNED_LINEAGE.md) | Signed checkpoint lineage: Ed25519 agent identity, checkpoint format v0x04, signing protocol, hash chain, content addressing, migration. | [EXECUTION_INVARIANTS](./constitution/EXECUTION_INVARIANTS.md), [OWNERSHIP_AND_AUTHORITY](./constitution/OWNERSHIP_AND_AUTHORITY.md), [MIGRATION_CONTINUITY](./constitution/MIGRATION_CONTINUITY.md) | diff --git a/docs/governance/ROADMAP.md b/docs/governance/ROADMAP.md index f45c2a9..6bbad06 100644 --- a/docs/governance/ROADMAP.md +++ b/docs/governance/ROADMAP.md @@ -223,8 +223,8 @@ Igor's research foundation (Phases 2–5) proved that agents can checkpoint, mig - ✅ **Effect lifecycle model** — intent state machine in SDK for crash-safe effect tracking — `sdk/igor/effects.go` - ✅ **Price watcher demo** — agent fetching live crypto prices via HTTP hostcall — `agents/pricewatcher/` - ✅ **Treasury sentinel demo** — effect-safe treasury monitoring with crash recovery — `agents/sentinel/` -- **x402/USDC wallet hostcall** — agent pays for services with real money -- **Compute provider hostcall** — agent deploys itself to Akash, Golem, or similar +- ✅ **x402/USDC wallet hostcall** — agent pays for services from budget via `wallet_pay` hostcall — `internal/hostcall/payment.go`, signed receipts, crash-safe via effect lifecycle — `agents/x402buyer/` +- ✅ **Compute provider demo** — agent pays for and deploys itself to mock compute provider — `agents/deployer/`, `cmd/mockcloud/` - **Self-migration** — agent decides when and where to move based on price/performance **Outcome:** Agents are economically autonomous — they rent infrastructure, pay for it, and move when they choose. @@ -339,8 +339,8 @@ None. v0 is experimental. Things may be: - ✅ Agent calls external HTTP APIs via hostcall - ✅ Effect lifecycle model for crash-safe side effects -- Agent pays for compute with real money (x402/USDC) -- Agent deploys itself to Akash/Golem +- ✅ Agent pays for compute with real money (x402/USDC) +- ✅ Agent deploys itself to compute provider (mock) - Agent decides when to migrate --- @@ -429,8 +429,7 @@ Igor is **not novel**. It combines existing ideas in a specific way to explore a ## Next Immediate Steps -Product Phase 2 in progress. HTTP hostcall, effect model, and demo agents delivered. Next: +Product Phase 2 in progress. HTTP hostcall, effect model, wallet_pay, x402buyer, and deployer demos delivered. Next: -1. **x402/USDC wallet hostcall** — agent pays for services with real money -2. **Compute provider integration** — agent deploys itself to Akash/Golem -3. **Self-migration** — agent decides when and where to move based on price/performance +1. **Self-migration** — agent decides when and where to move based on price/performance +2. **Real compute provider** — replace mock with Akash or Golem integration diff --git a/docs/runtime/HOSTCALL_ABI.md b/docs/runtime/HOSTCALL_ABI.md index 0cf50bd..8ccdea0 100644 --- a/docs/runtime/HOSTCALL_ABI.md +++ b/docs/runtime/HOSTCALL_ABI.md @@ -91,6 +91,45 @@ Persistent key-value storage scoped to the agent. Reads are observations; writes | `kv_put` | `(key_ptr: i32, key_len: i32, val_ptr: i32, val_len: i32) -> (i32)` | Writes value for key. Side effect. Returns 0 on success, negative on error. | | `kv_delete` | `(key_ptr: i32, key_len: i32) -> (i32)` | Deletes key. Side effect. Returns 0 on success, negative on error. | +### x402 — Payment Operations + +Payment capability for agents to pay for services from their budget. Side-effect hostcall. Recorded in event log for replay (CE-3). Declared under the `x402` manifest capability key. + +| Function | Signature | Description | +|----------|-----------|-------------| +| `wallet_pay` | `(amount: i64, recipient_ptr: i32, recipient_len: i32, memo_ptr: i32, memo_len: i32, receipt_ptr: i32, receipt_cap: i32) -> (i32)` | Deducts `amount` microcents from budget, creates a signed payment receipt, writes receipt to buffer. Returns bytes written on success, negative error code on failure. | + +**Implementation:** `internal/hostcall/payment.go` — validates recipient against `allowed_recipients`, validates amount against `max_payment_microcents`, deducts from budget, creates Ed25519-signed receipt (amount, timestamp, recipient, memo, agent pubkey), records amount and receipt in event log. + +**Replay behavior:** During replay, returns the recorded amount and receipt from the event log. + +**Manifest configuration:** +```json +{ + "x402": { + "version": 1, + "options": { + "allowed_recipients": ["service-provider"], + "max_payment_microcents": 1000000 + } + } +} +``` + +**Options:** +- `allowed_recipients` — list of permitted payment recipients (empty = all recipients allowed) +- `max_payment_microcents` — maximum amount per payment (0 = no limit) + +**Error codes:** +| Value | Meaning | +|-------|---------| +| `-1` | Insufficient budget | +| `-2` | Input too long (recipient > 256 bytes, memo > 1KB) | +| `-3` | Recipient not in allowed_recipients | +| `-4` | Amount exceeds max_payment_microcents | +| `-5` | Receipt buffer too small (first 4 bytes contain required size as LE uint32) | +| `-6` | Processing error | + ### http — HTTP Requests HTTP request/response capability for calling external APIs. Side-effect hostcall. Recorded in event log for replay (CE-3). @@ -166,7 +205,8 @@ Agents declare required capabilities in their manifest. The manifest is evaluate "rand": { "version": 1 }, "log": { "version": 1 }, "wallet": { "version": 1 }, - "http": { "version": 1, "options": { "allowed_hosts": ["api.example.com"] } } + "http": { "version": 1, "options": { "allowed_hosts": ["api.example.com"] } }, + "x402": { "version": 1, "options": { "allowed_recipients": ["service"], "max_payment_microcents": 1000000 } } }, "resource_limits": { "max_memory_bytes": 33554432 @@ -197,7 +237,7 @@ Per CM-4 and CE-3, the runtime records all observation hostcall return values in ```go type Entry struct { - HostcallID HostcallID // ClockNow=1, RandBytes=2, LogEmit=3, WalletBalance=4, etc. + HostcallID HostcallID // ClockNow=1, RandBytes=2, LogEmit=3, WalletBalance=4, ..., HTTPRequest=8, WalletPay=9 Payload []byte // Return value bytes (e.g., 8-byte timestamp, N random bytes) } ``` @@ -221,6 +261,7 @@ Hostcalls are registered using wazero's host module builder. The `internal/hostc // From internal/hostcall/registry.go registry := hostcall.NewRegistry(logger, eventLog) registry.SetWalletState(walletState) +registry.SetWalletPayState(walletPayState) // for x402 payments if err := registry.RegisterHostModule(ctx, rt, capManifest); err != nil { return err } diff --git a/scripts/demo-deployer.sh b/scripts/demo-deployer.sh new file mode 100755 index 0000000..cccd529 --- /dev/null +++ b/scripts/demo-deployer.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +# demo-deployer.sh — Compute Self-Provisioning: agent pays for and deploys itself. +# +# Demonstrates Igor's self-provisioning capabilities: +# 1. Start a mock compute provider (returns 402 without payment) +# 2. Run deployer agent — it checks budget, pays, deploys, monitors +# 3. KILL the process mid-monitoring +# 4. Resume on "Machine B" — reconcile unresolved intents +# 5. Verify cryptographic lineage across both machines +# +# The compute provider is simulated — no real cloud. The agent pays from +# its budget via wallet_pay and uses the receipt to deploy. +set -euo pipefail + +IGORD="./bin/igord" +WASM="./agents/deployer/agent.wasm" +MOCKCLOUD="./bin/mockcloud" +DIR_A="/tmp/igor-demo-deployer-a" +DIR_B="/tmp/igor-demo-deployer-b" + +# Cleanup from previous runs. +rm -rf "$DIR_A" "$DIR_B" +mkdir -p "$DIR_A" "$DIR_B" + +echo "" +echo "==========================================" +echo " Igor: Compute Self-Provisioning Demo" +echo " Agent pays for and deploys itself" +echo "==========================================" +echo "" +echo " Scenario: Agent needs compute infrastructure." +echo " Provider returns 402. Agent pays. Deploys." +echo " Monitors until running. Crash. Resume. Reconcile." +echo "" + +# --- Start mock cloud server --- +echo "[MockCloud] Starting mock compute provider on :8500..." +$MOCKCLOUD & +PID_CLOUD=$! + +# Wait for server to be ready. +for i in $(seq 1 10); do + if curl -s http://localhost:8500/health > /dev/null 2>&1; then + break + fi + sleep 0.5 +done +echo "[MockCloud] Ready." +echo "" + +# --- Machine A: Run agent --- +echo "[Machine A] Starting deployer agent..." +echo "[Machine A] Agent will check budget, pay provider, deploy, and monitor." +echo "" +$IGORD run --budget 10.0 --checkpoint-dir "$DIR_A" --agent-id deployer "$WASM" & +PID_A=$! + +# Let it run long enough to: check budget → pay → deploy → monitor a few cycles. +sleep 25 + +echo "" +echo "[Machine A] KILLING process (simulating crash mid-monitoring)..." +kill -9 "$PID_A" 2>/dev/null || true +wait "$PID_A" 2>/dev/null || true + +echo "[Machine A] Process killed. Checkpoint is the last known good state." +echo "" + +# --- Copy checkpoint to Machine B --- +CKPT_FILE="$DIR_A/deployer.checkpoint" +if [ ! -f "$CKPT_FILE" ]; then + echo "ERROR: Checkpoint file not found at $CKPT_FILE" + echo "(Agent may not have run long enough to produce a checkpoint.)" + kill "$PID_CLOUD" 2>/dev/null || true + rm -rf "$DIR_A" "$DIR_B" + exit 1 +fi + +echo "==========================================" +echo " Simulating Transfer to Machine B" +echo "==========================================" +echo "" +echo "[Transfer] Copying checkpoint + identity to Machine B..." +cp "$CKPT_FILE" "$DIR_B/" +if [ -f "$DIR_A/deployer.identity" ]; then + cp "$DIR_A/deployer.identity" "$DIR_B/" +fi +echo "[Transfer] Done. The checkpoint IS the agent." +echo "" + +# --- Machine B: Resume and reconcile --- +echo "==========================================" +echo " Machine B: Resume + Reconcile" +echo "==========================================" +echo "" +echo "[Machine B] Resuming deployer from checkpoint..." +echo "[Machine B] Any in-flight payments or deployments become UNRESOLVED." +echo "[Machine B] The agent will check status, not blindly retry." +echo "" +$IGORD resume --checkpoint "$DIR_B/deployer.checkpoint" --wasm "$WASM" --checkpoint-dir "$DIR_B" --agent-id deployer & +PID_B=$! + +# Let it reconcile and continue monitoring. +sleep 15 + +echo "" +echo "[Machine B] Stopping agent gracefully..." +kill -INT "$PID_B" 2>/dev/null || true +wait "$PID_B" 2>/dev/null || true + +# Stop mock cloud server. +kill "$PID_CLOUD" 2>/dev/null || true +wait "$PID_CLOUD" 2>/dev/null || true +echo "" + +# --- Verify Lineage --- +VERIFY_DIR="/tmp/igor-demo-deployer-verify" +rm -rf "$VERIFY_DIR" +mkdir -p "$VERIFY_DIR" + +if [ -d "$DIR_A/history/deployer" ]; then + cp "$DIR_A/history/deployer/"*.ckpt "$VERIFY_DIR/" 2>/dev/null || true +fi +if [ -d "$DIR_B/history/deployer" ]; then + cp "$DIR_B/history/deployer/"*.ckpt "$VERIFY_DIR/" 2>/dev/null || true +fi + +CKPT_COUNT=$(ls "$VERIFY_DIR/"*.ckpt 2>/dev/null | wc -l | tr -d ' ') +echo "==========================================" +echo " Lineage Verification ($CKPT_COUNT checkpoints)" +echo "==========================================" +echo "" + +if [ "$CKPT_COUNT" -gt 0 ]; then + $IGORD verify "$VERIFY_DIR" +else + echo "No checkpoint history found for verification." +fi + +echo "" +echo "==========================================" +echo " Demo Complete" +echo "==========================================" +echo "" +echo "What you just saw:" +echo " - Agent checked its budget against compute costs" +echo " - Provider returned HTTP 402 Payment Required" +echo " - Agent paid from its budget via wallet_pay hostcall" +echo " - Agent deployed itself with the payment receipt" +echo " - Agent monitored deployment: pending → provisioning → running" +echo " - Process KILLED mid-monitoring (SIGKILL)" +echo " - Resumed on Machine B from checkpoint" +echo " - In-flight intents became UNRESOLVED (the resume rule)" +echo " - Agent RECONCILED — checked payment and deployment status" +echo " - No duplicate payments. Budget conserved. Continuity." +echo "" +echo "This is compute self-provisioning:" +echo " The agent chooses, pays for, and deploys its own infrastructure." +echo " And survives the process." +echo "" + +# Cleanup. +rm -rf "$DIR_A" "$DIR_B" "$VERIFY_DIR"