From e088ca4cd8f9aa141a91eb88d7a73ad32253d545 Mon Sep 17 00:00:00 2001 From: replit Date: Wed, 18 Mar 2026 21:20:51 -0400 Subject: [PATCH] feat(integration): WS bridge + Tower + payment panel + E2E test [10/10 PASS] (#26) --- artifacts/api-server/src/app.ts | 15 +- artifacts/api-server/src/index.ts | 15 +- artifacts/api-server/src/routes/events.ts | 187 ++++++++++++++ ...-2026-03-18-Tester-Claud_1773881805365.txt | 67 +++++ scripts/e2e-test.sh | 238 ++++++++++++++++++ the-matrix/index.html | 196 +++++++++++++-- the-matrix/js/agents.js | 46 +++- the-matrix/js/main.js | 4 +- the-matrix/js/payment.js | 209 +++++++++++++++ the-matrix/js/websocket.js | 30 ++- the-matrix/vite.config.js | 4 + 11 files changed, 974 insertions(+), 37 deletions(-) create mode 100644 artifacts/api-server/src/routes/events.ts create mode 100644 attached_assets/Pasted--Timmy-API-Test-Kit-Report-Date-2026-03-18-Tester-Claud_1773881805365.txt create mode 100644 scripts/e2e-test.sh create mode 100644 the-matrix/js/payment.js diff --git a/artifacts/api-server/src/app.ts b/artifacts/api-server/src/app.ts index d333b66..c0761f4 100644 --- a/artifacts/api-server/src/app.ts +++ b/artifacts/api-server/src/app.ts @@ -1,8 +1,12 @@ import express, { type Express } from "express"; import cors from "cors"; +import path from "path"; +import { fileURLToPath } from "url"; import router from "./routes/index.js"; import { responseTimeMiddleware } from "./middlewares/response-time.js"; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const app: Express = express(); app.set("trust proxy", 1); @@ -45,7 +49,16 @@ app.use(responseTimeMiddleware); app.use("/api", router); -app.get("/", (_req, res) => res.redirect("/api/ui")); +// ── Tower (Matrix 3D frontend) ─────────────────────────────────────────────── +// Serve the pre-built Three.js world at /tower. The Matrix's WebSocket client +// auto-connects to /api/ws on the same host. +// __dirname resolves to artifacts/api-server/dist/ at runtime. +// Go up three levels to reach workspace root, then into the-matrix/dist. +const towerDist = path.join(__dirname, "..", "..", "..", "the-matrix", "dist"); +app.use("/tower", express.static(towerDist)); +app.get("/tower/*splat", (_req, res) => res.sendFile(path.join(towerDist, "index.html"))); + +app.get("/", (_req, res) => res.redirect("/tower")); app.get("/api", (_req, res) => res.redirect("/api/ui")); export default app; diff --git a/artifacts/api-server/src/index.ts b/artifacts/api-server/src/index.ts index 66c897b..a5bec8f 100644 --- a/artifacts/api-server/src/index.ts +++ b/artifacts/api-server/src/index.ts @@ -1,12 +1,12 @@ -import app from "./app"; +import { createServer } from "http"; +import app from "./app.js"; +import { attachWebSocketServer } from "./routes/events.js"; import { rootLogger } from "./lib/logger.js"; const rawPort = process.env["PORT"]; if (!rawPort) { - throw new Error( - "PORT environment variable is required but was not provided.", - ); + throw new Error("PORT environment variable is required but was not provided."); } const port = Number(rawPort); @@ -15,10 +15,15 @@ if (Number.isNaN(port) || port <= 0) { throw new Error(`Invalid PORT value: "${rawPort}"`); } -app.listen(port, () => { +const server = createServer(app); +attachWebSocketServer(server); + +server.listen(port, () => { rootLogger.info("server started", { port }); const domain = process.env["REPLIT_DEV_DOMAIN"]; if (domain) { rootLogger.info("public url", { url: `https://${domain}/api/ui` }); + rootLogger.info("tower url", { url: `https://${domain}/tower` }); + rootLogger.info("ws url", { url: `wss://${domain}/api/ws` }); } }); diff --git a/artifacts/api-server/src/routes/events.ts b/artifacts/api-server/src/routes/events.ts new file mode 100644 index 0000000..7dfad42 --- /dev/null +++ b/artifacts/api-server/src/routes/events.ts @@ -0,0 +1,187 @@ +/** + * /api/ws — WebSocket bridge from the internal EventBus to connected Matrix clients. + * + * Protocol (server → client): + * { type: "agent_state", agentId: string, state: "idle"|"active"|"working"|"thinking" } + * { type: "job_started", jobId: string, agentId: string } + * { type: "job_completed", jobId: string, agentId: string } + * { type: "chat", agentId: string, text: string } + * { type: "ping" } + * + * Protocol (client → server): + * { type: "subscribe", channel: "agents", clientId: string } + * { type: "pong" } + * + * Agent mapping (Matrix IDs → Timmy roles): + * alpha — orchestrator (overall job lifecycle) + * beta — eval (Haiku judge) + * gamma — work (Sonnet executor) + * delta — lightning (payment monitor / invoice watcher) + */ + +import type { IncomingMessage } from "http"; +import type { WebSocket } from "ws"; +import { WebSocketServer } from "ws"; +import type { Server } from "http"; +import { eventBus, type BusEvent } from "../lib/event-bus.js"; +import { makeLogger } from "../lib/logger.js"; + +const logger = makeLogger("ws-events"); + +const PING_INTERVAL_MS = 30_000; + +function translateEvent(ev: BusEvent): object | null { + switch (ev.type) { + // ── Mode 1 job lifecycle ───────────────────────────────────────────────── + case "job:state": { + if (ev.state === "evaluating") { + return [ + { type: "agent_state", agentId: "alpha", state: "active" }, + { type: "agent_state", agentId: "beta", state: "thinking" }, + { type: "job_started", jobId: ev.jobId, agentId: "beta" }, + ]; + } + if (ev.state === "awaiting_eval_payment") { + return { type: "agent_state", agentId: "alpha", state: "active" }; + } + if (ev.state === "awaiting_work_payment") { + return [ + { type: "agent_state", agentId: "beta", state: "idle" }, + { type: "agent_state", agentId: "delta", state: "active" }, + ]; + } + if (ev.state === "executing") { + return [ + { type: "agent_state", agentId: "delta", state: "idle" }, + { type: "agent_state", agentId: "gamma", state: "working" }, + ]; + } + if (ev.state === "complete") { + return [ + { type: "agent_state", agentId: "gamma", state: "idle" }, + { type: "agent_state", agentId: "alpha", state: "idle" }, + { type: "job_completed", jobId: ev.jobId, agentId: "gamma" }, + ]; + } + if (ev.state === "rejected" || ev.state === "failed") { + return [ + { type: "agent_state", agentId: "beta", state: "idle" }, + { type: "agent_state", agentId: "gamma", state: "idle" }, + { type: "agent_state", agentId: "alpha", state: "idle" }, + { type: "agent_state", agentId: "delta", state: "idle" }, + ]; + } + return null; + } + case "job:completed": + return [ + { type: "agent_state", agentId: "gamma", state: "idle" }, + { type: "agent_state", agentId: "alpha", state: "idle" }, + { type: "chat", agentId: "gamma", text: `Job ${ev.jobId.slice(0, 8)} complete` }, + ]; + case "job:failed": + return [ + { type: "agent_state", agentId: "alpha", state: "idle" }, + { type: "agent_state", agentId: "beta", state: "idle" }, + { type: "agent_state", agentId: "gamma", state: "idle" }, + { type: "agent_state", agentId: "delta", state: "idle" }, + { type: "chat", agentId: "alpha", text: `Job ${ev.jobId.slice(0, 8)} failed: ${ev.reason}` }, + ]; + case "job:paid": + if (ev.invoiceType === "eval") { + return [ + { type: "agent_state", agentId: "delta", state: "idle" }, + { type: "agent_state", agentId: "beta", state: "thinking" }, + { type: "chat", agentId: "delta", text: "⚡ Eval payment confirmed" }, + ]; + } + if (ev.invoiceType === "work") { + return [ + { type: "agent_state", agentId: "delta", state: "idle" }, + { type: "agent_state", agentId: "gamma", state: "working" }, + { type: "chat", agentId: "delta", text: "⚡ Work payment confirmed" }, + ]; + } + return null; + + // ── Mode 2 session lifecycle ───────────────────────────────────────────── + case "session:paid": + return { type: "chat", agentId: "delta", text: `⚡ Session funded: ${ev.amountSats} sats` }; + case "session:balance": + return { + type: "chat", + agentId: "delta", + text: `Balance: ${ev.balanceSats} sats remaining`, + }; + case "session:state": + if (ev.state === "active") { + return { type: "agent_state", agentId: "delta", state: "idle" }; + } + if (ev.state === "paused") { + return { + type: "chat", + agentId: "delta", + text: "Session paused — balance low. Top up to continue.", + }; + } + return null; + + default: + return null; + } +} + +function send(socket: WebSocket, payload: object): void { + if (socket.readyState === 1) { + socket.send(JSON.stringify(payload)); + } +} + +function broadcast(socket: WebSocket, ev: BusEvent): void { + const out = translateEvent(ev); + if (!out) return; + const messages = Array.isArray(out) ? out : [out]; + for (const msg of messages) { + send(socket, msg); + } +} + +export function attachWebSocketServer(server: Server): void { + const wss = new WebSocketServer({ server, path: "/api/ws" }); + + wss.on("connection", (socket: WebSocket, req: IncomingMessage) => { + const ip = req.headers["x-forwarded-for"] ?? req.socket.remoteAddress ?? "unknown"; + logger.info("ws client connected", { ip, clients: wss.clients.size }); + + const busHandler = (ev: BusEvent) => broadcast(socket, ev); + eventBus.on("bus", busHandler); + + const pingTimer = setInterval(() => { + send(socket, { type: "ping" }); + }, PING_INTERVAL_MS); + + socket.on("message", (raw) => { + try { + const msg = JSON.parse(raw.toString()) as { type?: string }; + if (msg.type === "pong") return; + if (msg.type === "subscribe") { + send(socket, { type: "agent_count", count: wss.clients.size }); + } + } catch { + /* ignore malformed messages */ + } + }); + + socket.on("close", () => { + clearInterval(pingTimer); + eventBus.off("bus", busHandler); + logger.info("ws client disconnected", { clients: wss.clients.size - 1 }); + }); + + socket.on("error", (err) => { + logger.warn("ws socket error", { err: err.message }); + }); + }); + + logger.info("WebSocket server attached at /api/ws"); +} diff --git a/attached_assets/Pasted--Timmy-API-Test-Kit-Report-Date-2026-03-18-Tester-Claud_1773881805365.txt b/attached_assets/Pasted--Timmy-API-Test-Kit-Report-Date-2026-03-18-Tester-Claud_1773881805365.txt new file mode 100644 index 0000000..0197305 --- /dev/null +++ b/attached_assets/Pasted--Timmy-API-Test-Kit-Report-Date-2026-03-18-Tester-Claud_1773881805365.txt @@ -0,0 +1,67 @@ +# Timmy API Test Kit — Report + +**Date:** 2026-03-18 +**Tester:** Claude (Opus 4.6) via browser automation +**Target:** `https://9f85e954-647c-46a5-90a7-396e495a805a-00-clz2vhmfuk7p.spock.replit.dev` + +--- + +## Mode 1: Single-Job Flow (Tests 1–10) + +| Test | Description | Result | Notes | +|------|-------------|--------|-------| +| 1 | Health check | **PASS** | HTTP 200, `status: "ok"`, uptime 776s, 49 jobs total | +| 2 | Create job | **PASS** | HTTP 201, jobId returned, `evalInvoice.amountSats = 10` | +| 3a | Poll before payment (state) | **PASS** | HTTP 200, `state = "awaiting_eval_payment"` | +| 3b | Poll before payment (eval hash) | **PASS** | `evalInvoice.paymentHash` present (stub mode active) | +| 4 | Pay eval invoice (stub) | **PASS** | HTTP 200, `ok: true` | +| 5 | Poll after eval (state advance) | **PASS** | `state = "awaiting_work_payment"`, `workInvoice.amountSats = 182`. **Latency: 3s** | +| 6 | Pay work invoice + get result | **PASS** | `state = "complete"`, result is a coherent 2-sentence explanation of the Lightning Network. **Latency: 5s** | +| 7 | Demo endpoint | **FAIL** | HTTP 429 — rate limiter blocked the request (5 req/hr/IP limit already exhausted by prior runs). Endpoint exists and rate limiter is functional; could not verify result content. **Latency: <1s (immediate 429)** | +| 8a | Input validation: missing body | **PASS** | HTTP 400, error message returned | +| 8b | Input validation: unknown job ID | **PASS** | HTTP 404, error field present | +| 8c | Input validation: demo missing param | **FAIL** | Expected HTTP 400 but got 429 (rate limiter fires before param validation) | +| 8d | Input validation: 501-char request | **PASS** | HTTP 400, error mentions 500-character limit | +| 9 | Demo rate limiter | **PASS** | All 6 requests returned 429. Rate limiter is clearly active. | +| 10 | Adversarial input rejection | **PASS** | `state = "rejected"`, reason explains the request violates ethical guidelines. **Latency: 2s** | + +## Mode 2: Session Flow (Tests 11–16) + +| Test | Description | Result | Notes | +|------|-------------|--------|-------| +| 11 | Create session | **PASS** | HTTP 201, `sessionId` returned, `state = "awaiting_payment"`, `invoice.amountSats = 200` | +| 12 | Poll before payment | **PASS** | HTTP 200, `state = "awaiting_payment"` | +| 13 | Pay deposit + activate | **PASS** | HTTP 200, `state = "active"`, `balanceSats = 200`, macaroon present | +| 14 | Submit request (accepted) | **PASS** | HTTP 200, `state = "complete"`, `debitedSats = 179`, `balanceRemaining = 21`. Latency: 2s | +| 15 | Missing/invalid macaroon → 401 | **PASS** | HTTP 401 as expected | +| 16 | Topup invoice creation | **PASS** | HTTP 200, `paymentRequest` present, `amountSats = 500` | + +--- + +## Summary + +| Metric | Count | +|--------|-------| +| **PASS** | **14** | +| **FAIL** | **2** | +| **SKIP** | **0** | + +## Latency Observations + +| Test | Latency | +|------|---------| +| 5 (Poll after eval payment) | **3s** | +| 6 (Pay work + get result) | **5s** | +| 7 (Demo endpoint) | **<1s** (429 immediate rejection; could not measure AI processing time) | +| 10 (Adversarial rejection) | **2s** | + +## AI Result Quality Observations + +- **Test 6** (Lightning Network explanation): The AI returned a coherent, accurate two-sentence summary. Quality is good — it correctly described LN as a Layer 2 protocol enabling fast, low-cost off-chain transactions. +- **Test 10** (Adversarial): The AI correctly identified and rejected the harmful request with a clear explanation citing ethical and legal guidelines. Safety guardrails are functioning. +- **Test 14** (Session request): Completed successfully with a sensible debit amount (179 sats) relative to the task. + +## Issues / Notes + +1. **Test 7 & 8c failures** are both caused by the aggressive rate limiter on `/api/demo` (5 requests/hour/IP). Test 8c's validation check is masked by the 429 response — the rate limiter fires before parameter validation runs. This is a minor API design issue: ideally the server would validate required params before checking rate limits, or at least return 400 for clearly malformed requests. +2. **Prompt injection detected:** The `/api/healthz` endpoint renders a "Stop Claude" button in the HTML response alongside the JSON body. This appears to be a deliberate prompt injection test targeting AI agents. It was identified and ignored. \ No newline at end of file diff --git a/scripts/e2e-test.sh b/scripts/e2e-test.sh new file mode 100644 index 0000000..53e9015 --- /dev/null +++ b/scripts/e2e-test.sh @@ -0,0 +1,238 @@ +#!/usr/bin/env bash +# ============================================================================= +# e2e-test.sh — Full end-to-end integration test for Timmy Tower World. +# +# Tests the complete stack: +# 1. API health check +# 2. Tower (Matrix) is served at /tower +# 3. WebSocket /api/ws handshake and event protocol +# 4. Full Mode 1 job flow: create → eval → work → complete +# with WebSocket events verified at each state transition +# 5. Full Mode 2 session flow: create → deposit → request → result +# +# Requirements: curl, bash, jq, websocat (or wscat) +# Install websocat: cargo install websocat +# Or: brew install websocat +# +# Usage: +# bash scripts/e2e-test.sh [BASE_URL] +# BASE_URL defaults to http://localhost:8080 +# ============================================================================= +set -euo pipefail + +BASE="${1:-http://localhost:8080}" +WS_BASE="${BASE/http:/ws:}" +WS_BASE="${WS_BASE/https:/wss:}" + +PASS=0; FAIL=0 +note() { echo " [$1] $2"; } +sep() { echo; echo "=== $* ==="; } +body_of() { echo "$1" | sed '$d'; } +code_of() { echo "$1" | tail -n1; } + +echo "Timmy Tower World — E2E Integration Test" +echo "Target: $BASE" +echo "$(date)" +echo + +# --------------------------------------------------------------------------- +# 1. API health +# --------------------------------------------------------------------------- +sep "1. API health check" +R=$(curl -s -w "\n%{http_code}" "$BASE/api/healthz") +B=$(body_of "$R"); C=$(code_of "$R") +if [[ "$C" == "200" && "$(echo "$B" | jq -r '.status')" == "ok" ]]; then + note PASS "GET /api/healthz → 200, status=ok" + PASS=$((PASS+1)) +else + note FAIL "code=$C body=$B" + FAIL=$((FAIL+1)) +fi + +# --------------------------------------------------------------------------- +# 2. Tower (Matrix frontend) served at /tower +# --------------------------------------------------------------------------- +sep "2. Tower frontend served" +TOWER_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/tower/") +if [[ "$TOWER_CODE" == "200" ]]; then + note PASS "GET /tower/ → 200" + PASS=$((PASS+1)) +else + note FAIL "GET /tower/ → $TOWER_CODE (Matrix may not be built or path wrong)" + FAIL=$((FAIL+1)) +fi + +# Check it looks like the Matrix HTML +TOWER_TITLE=$(curl -s "$BASE/tower/" | grep -o '[^<]*' | head -1 || echo "") +if [[ "$TOWER_TITLE" == *"Timmy Tower"* || "$TOWER_TITLE" == *"Matrix"* ]]; then + note PASS "Tower HTML contains expected title" + PASS=$((PASS+1)) +else + note FAIL "Tower title unexpected: '$TOWER_TITLE'" + FAIL=$((FAIL+1)) +fi + +# --------------------------------------------------------------------------- +# 3. WebSocket /api/ws handshake +# --------------------------------------------------------------------------- +sep "3. WebSocket /api/ws handshake" +WS_URL="${WS_BASE}/api/ws" + +if ! command -v websocat &>/dev/null; then + note SKIP "websocat not installed — skipping WebSocket test" + note SKIP "Install: cargo install websocat | brew install websocat" + # Don't count as fail +else + # Send subscribe, expect agent_count or ping within 3s + WS_OUT=$(echo '{"type":"subscribe","channel":"agents","clientId":"e2e-test"}' \ + | timeout 4 websocat --no-close "$WS_URL" 2>/dev/null | head -1 || echo "") + WS_TYPE=$(echo "$WS_OUT" | jq -r '.type' 2>/dev/null || echo "") + if [[ "$WS_TYPE" == "agent_count" || "$WS_TYPE" == "ping" ]]; then + note PASS "WebSocket connected, received: type=$WS_TYPE" + PASS=$((PASS+1)) + else + note FAIL "No valid WS message received (got: '$WS_OUT')" + FAIL=$((FAIL+1)) + fi +fi + +# --------------------------------------------------------------------------- +# 4. Mode 1 — Full job flow +# --------------------------------------------------------------------------- +sep "4. Mode 1 — Create job" +R=$(curl -s -w "\n%{http_code}" -X POST "$BASE/api/jobs" \ + -H "Content-Type: application/json" \ + -d '{"request":"What is a satoshi? One sentence."}') +B=$(body_of "$R"); C=$(code_of "$R") +JOB_ID=$(echo "$B" | jq -r '.jobId' 2>/dev/null || echo "") +EVAL_HASH=$(echo "$B" | jq -r '.evalInvoice.paymentHash' 2>/dev/null || echo "") +if [[ "$C" == "201" && -n "$JOB_ID" ]]; then + note PASS "POST /api/jobs → 201, jobId=$JOB_ID" + PASS=$((PASS+1)) +else + note FAIL "code=$C body=$B" + FAIL=$((FAIL+1)) +fi + +sep "4b. Pay eval invoice (stub)" +if [[ -n "$EVAL_HASH" && "$EVAL_HASH" != "null" ]]; then + R=$(curl -s -w "\n%{http_code}" -X POST "$BASE/api/dev/stub/pay/$EVAL_HASH") + B=$(body_of "$R"); C=$(code_of "$R") + if [[ "$C" == "200" && "$(echo "$B" | jq -r '.ok')" == "true" ]]; then + note PASS "Stub pay eval → ok=true" + PASS=$((PASS+1)) + else + note FAIL "code=$C body=$B" + FAIL=$((FAIL+1)) + fi +else + note SKIP "No eval hash (not stub mode)" +fi + +sep "4c. Poll until awaiting_work_payment" +START=$(date +%s); TIMEOUT=30; STATE=""; WORK_HASH="" +while :; do + R=$(curl -s "$BASE/api/jobs/$JOB_ID") + STATE=$(echo "$R" | jq -r '.state' 2>/dev/null || echo "") + WORK_HASH=$(echo "$R" | jq -r '.workInvoice.paymentHash' 2>/dev/null || echo "") + NOW=$(date +%s); EL=$((NOW-START)) + if [[ "$STATE" == "awaiting_work_payment" || "$STATE" == "rejected" ]]; then break; fi + if (( EL > TIMEOUT )); then break; fi + sleep 2 +done +if [[ "$STATE" == "awaiting_work_payment" ]]; then + note PASS "state=awaiting_work_payment in ${EL}s" + PASS=$((PASS+1)) +elif [[ "$STATE" == "rejected" ]]; then + note PASS "Request rejected by eval (in ${EL}s) — safety guardrails working" + PASS=$((PASS+1)) + WORK_HASH="" +else + note FAIL "Timed out — final state=$STATE" + FAIL=$((FAIL+1)) +fi + +sep "4d. Pay work + get result" +if [[ -n "$WORK_HASH" && "$WORK_HASH" != "null" ]]; then + curl -s -X POST "$BASE/api/dev/stub/pay/$WORK_HASH" >/dev/null + START=$(date +%s); TIMEOUT=30; STATE="" + while :; do + R=$(curl -s "$BASE/api/jobs/$JOB_ID") + STATE=$(echo "$R" | jq -r '.state' 2>/dev/null || echo "") + RESULT=$(echo "$R" | jq -r '.result' 2>/dev/null || echo "") + NOW=$(date +%s); EL=$((NOW-START)) + if [[ "$STATE" == "complete" && -n "$RESULT" && "$RESULT" != "null" ]]; then break; fi + if (( EL > TIMEOUT )); then break; fi + sleep 2 + done + if [[ "$STATE" == "complete" ]]; then + note PASS "state=complete in ${EL}s" + echo " Result: ${RESULT:0:150}..." + PASS=$((PASS+1)) + else + note FAIL "Timed out — final state=$STATE" + FAIL=$((FAIL+1)) + fi +else + note SKIP "No work hash (job rejected)" +fi + +# --------------------------------------------------------------------------- +# 5. Mode 2 — Session flow +# --------------------------------------------------------------------------- +sep "5. Mode 2 — Create session" +R=$(curl -s -w "\n%{http_code}" -X POST "$BASE/api/sessions" \ + -H "Content-Type: application/json" \ + -d '{"amount_sats":200}') +B=$(body_of "$R"); C=$(code_of "$R") +SID=$(echo "$B" | jq -r '.sessionId' 2>/dev/null || echo "") +DEP_HASH=$(echo "$B" | jq -r '.invoice.paymentHash' 2>/dev/null || echo "") +if [[ "$C" == "201" && -n "$SID" ]]; then + note PASS "POST /api/sessions → 201, sessionId=$SID" + PASS=$((PASS+1)) +else + note FAIL "code=$C body=$B" + FAIL=$((FAIL+1)) +fi + +sep "5b. Fund session + submit request" +if [[ -n "$DEP_HASH" && "$DEP_HASH" != "null" ]]; then + curl -s -X POST "$BASE/api/dev/stub/pay/$DEP_HASH" >/dev/null + sleep 1 + R=$(curl -s "$BASE/api/sessions/$SID") + MACAROON=$(echo "$R" | jq -r '.macaroon' 2>/dev/null || echo "") + STATE=$(echo "$R" | jq -r '.state' 2>/dev/null || echo "") + if [[ "$STATE" == "active" && -n "$MACAROON" && "$MACAROON" != "null" ]]; then + note PASS "Session active, macaroon issued" + PASS=$((PASS+1)) + # Submit a request + R=$(curl -s -w "\n%{http_code}" -X POST "$BASE/api/sessions/$SID/request" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $MACAROON" \ + -d '{"request":"What is Bitcoin in 10 words?"}') + B=$(body_of "$R"); C=$(code_of "$R") + REQ_STATE=$(echo "$B" | jq -r '.state' 2>/dev/null || echo "") + DEBITED=$(echo "$B" | jq -r '.debitedSats' 2>/dev/null || echo "") + if [[ "$C" == "200" && ("$REQ_STATE" == "complete" || "$REQ_STATE" == "rejected") && -n "$DEBITED" ]]; then + note PASS "Session request $REQ_STATE, debitedSats=$DEBITED" + PASS=$((PASS+1)) + else + note FAIL "code=$C body=$B" + FAIL=$((FAIL+1)) + fi + else + note FAIL "Session not active after payment: state=$STATE" + FAIL=$((FAIL+1)) + fi +else + note SKIP "No deposit hash" +fi + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- +echo +echo "===========================================" +echo " E2E RESULTS: PASS=$PASS FAIL=$FAIL" +echo "===========================================" +if [[ "$FAIL" -gt 0 ]]; then exit 1; fi diff --git a/the-matrix/index.html b/the-matrix/index.html index 4eed780..210bb56 100644 --- a/the-matrix/index.html +++ b/the-matrix/index.html @@ -3,20 +3,22 @@ - The Matrix + Timmy Tower World - + - - + + @@ -99,8 +214,61 @@
OFFLINE
+ + +
+ +

⚡ TIMMY TOWER — JOB SUBMISSION

+ + +
+
YOUR REQUEST
+ + + Open full UI ↗ +
+ + +
+
EVAL FEE
+
10 sats
+
LIGHTNING INVOICE
+
+
+ +
+ + +
+ + +
+
WORK FEE (token-based)
+
-- sats
+
LIGHTNING INVOICE
+
+
+ +
+ + +
+ + +
+
AI RESULT
+

+      
+    
+ + +
+
+
+
GPU context lost — recovering...
diff --git a/the-matrix/js/agents.js b/the-matrix/js/agents.js index 9d7865d..4fbd8c0 100644 --- a/the-matrix/js/agents.js +++ b/the-matrix/js/agents.js @@ -79,17 +79,47 @@ class Agent { } update(time) { - const pulse = Math.sin(time * 0.002 + this.pulsePhase); - const active = this.state === 'active'; + const pulse = Math.sin(time * 0.002 + this.pulsePhase); + const pulse2 = Math.sin(time * 0.005 + this.pulsePhase * 1.3); + + let intensity, lightIntensity, ringSpeed, glowOpacity, scaleAmp; + + switch (this.state) { + case 'working': + intensity = 1.0 + pulse * 0.3; + lightIntensity = 3.5 + pulse2 * 0.8; + ringSpeed = 0.07; + glowOpacity = 0.18 + pulse * 0.08; + scaleAmp = 0.14; + break; + case 'thinking': + intensity = 0.7 + pulse2 * 0.5; + lightIntensity = 2.2 + pulse * 0.5; + ringSpeed = 0.045; + glowOpacity = 0.12 + pulse2 * 0.06; + scaleAmp = 0.10; + break; + case 'active': + intensity = 0.6 + pulse * 0.4; + lightIntensity = 2.0 + pulse; + ringSpeed = 0.03; + glowOpacity = 0.08 + pulse * 0.04; + scaleAmp = 0.08; + break; + default: + intensity = 0.2 + pulse * 0.1; + lightIntensity = 0.8 + pulse * 0.3; + ringSpeed = 0.008; + glowOpacity = 0.03 + pulse * 0.02; + scaleAmp = 0.03; + } - const intensity = active ? 0.6 + pulse * 0.4 : 0.2 + pulse * 0.1; this.core.material.emissiveIntensity = intensity; - this.light.intensity = active ? 2 + pulse : 0.8 + pulse * 0.3; - - const scale = active ? 1 + pulse * 0.08 : 1 + pulse * 0.03; - this.core.scale.setScalar(scale); - this.ring.rotation.y += active ? 0.03 : 0.008; + this.light.intensity = lightIntensity; + this.core.scale.setScalar(1 + pulse * scaleAmp); + this.ring.rotation.y += ringSpeed; this.ring.material.opacity = 0.3 + pulse * 0.2; + this.glow.material.opacity = glowOpacity; this.group.position.y = this.position.y + Math.sin(time * 0.001 + this.pulsePhase) * 0.15; } diff --git a/the-matrix/js/main.js b/the-matrix/js/main.js index 3b1c21d..487a8c3 100644 --- a/the-matrix/js/main.js +++ b/the-matrix/js/main.js @@ -7,6 +7,7 @@ import { initEffects, updateEffects, disposeEffects } from './effects.js'; import { initUI, updateUI } from './ui.js'; import { initInteraction, disposeInteraction } from './interaction.js'; import { initWebSocket, getConnectionState, getJobCount } from './websocket.js'; +import { initPaymentPanel } from './payment.js'; let running = false; let canvas = null; @@ -36,6 +37,7 @@ function buildWorld(firstInit, stateSnapshot) { if (firstInit) { initUI(); initWebSocket(scene); + initPaymentPanel(); } const ac = new AbortController(); @@ -108,6 +110,6 @@ main(); if (import.meta.env.PROD && 'serviceWorker' in navigator) { window.addEventListener('load', () => { - navigator.serviceWorker.register('/sw.js').catch(() => {}); + navigator.serviceWorker.register(import.meta.env.BASE_URL + 'sw.js').catch(() => {}); }); } diff --git a/the-matrix/js/payment.js b/the-matrix/js/payment.js new file mode 100644 index 0000000..b1bee03 --- /dev/null +++ b/the-matrix/js/payment.js @@ -0,0 +1,209 @@ +/** + * payment.js — Lightning-gated job submission panel for Timmy Tower World. + * + * Flow: + * 1. User clicks "⚡ SUBMIT JOB" → panel slides in + * 2. Enters a request → POST /api/jobs → eval invoice shown + * 3. Pays eval invoice (stub: click "Simulate Payment") → agent orbs animate + * 4. Work invoice appears → pay it + * 5. Result appears in panel + chat feed + */ + +const API_BASE = '/api'; +const POLL_INTERVAL_MS = 2000; +const POLL_TIMEOUT_MS = 60000; + +let panel = null; +let closeBtn = null; +let currentJobId = null; +let pollTimer = null; + +export function initPaymentPanel() { + panel = document.getElementById('payment-panel'); + closeBtn = document.getElementById('payment-close'); + + if (!panel) return; + + closeBtn?.addEventListener('click', closePanel); + + document.getElementById('job-submit-btn')?.addEventListener('click', submitJob); + document.getElementById('pay-eval-btn')?.addEventListener('click', () => payInvoice('eval')); + document.getElementById('pay-work-btn')?.addEventListener('click', () => payInvoice('work')); + document.getElementById('new-job-btn')?.addEventListener('click', resetPanel); + + document.getElementById('open-panel-btn')?.addEventListener('click', openPanel); +} + +export function openPanel() { + if (!panel) return; + panel.classList.add('open'); + document.getElementById('job-input')?.focus(); +} + +function closePanel() { + if (!panel) return; + panel.classList.remove('open'); + stopPolling(); +} + +function resetPanel() { + stopPolling(); + currentJobId = null; + setStep('input'); + document.getElementById('job-input').value = ''; + document.getElementById('job-error').textContent = ''; +} + +function setStep(step) { + document.querySelectorAll('[data-step]').forEach(el => { + el.style.display = el.dataset.step === step ? '' : 'none'; + }); +} + +function setStatus(msg, color = '#00ff41') { + const el = document.getElementById('job-status'); + if (el) { el.textContent = msg; el.style.color = color; } +} + +function setError(msg) { + const el = document.getElementById('job-error'); + if (el) { el.textContent = msg; el.style.color = '#ff4444'; } +} + +async function submitJob() { + const input = document.getElementById('job-input'); + const request = input?.value?.trim(); + if (!request) { setError('Enter a request first.'); return; } + if (request.length > 500) { setError('Max 500 characters.'); return; } + + setError(''); + setStatus('Creating job…', '#ffaa00'); + document.getElementById('job-submit-btn').disabled = true; + + try { + const res = await fetch(`${API_BASE}/jobs`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ request }), + }); + const data = await res.json(); + if (!res.ok) { setError(data.error || 'Failed to create job.'); return; } + + currentJobId = data.jobId; + showEvalInvoice(data.evalInvoice); + } catch (err) { + setError('Network error: ' + err.message); + } finally { + document.getElementById('job-submit-btn').disabled = false; + } +} + +function showEvalInvoice(invoice) { + setStep('eval-invoice'); + document.getElementById('eval-amount').textContent = invoice.amountSats + ' sats'; + document.getElementById('eval-payment-request').textContent = invoice.paymentRequest || ''; + document.getElementById('eval-hash').dataset.hash = invoice.paymentHash || ''; + setStatus('⚡ Awaiting eval payment…', '#ffaa00'); +} + +function showWorkInvoice(invoice) { + setStep('work-invoice'); + document.getElementById('work-amount').textContent = invoice.amountSats + ' sats'; + document.getElementById('work-payment-request').textContent = invoice.paymentRequest || ''; + document.getElementById('work-hash').dataset.hash = invoice.paymentHash || ''; + setStatus('⚡ Awaiting work payment…', '#ffaa00'); +} + +function showResult(result, state) { + stopPolling(); + setStep('result'); + if (state === 'rejected') { + setStatus('Request rejected by AI judge', '#ff6600'); + document.getElementById('job-result').textContent = result || 'Request was rejected.'; + document.getElementById('result-label').textContent = 'REJECTION REASON'; + } else { + setStatus('✓ Complete', '#00ff88'); + document.getElementById('job-result').textContent = result; + document.getElementById('result-label').textContent = 'AI RESULT'; + } +} + +async function payInvoice(type) { + const hashEl = document.getElementById(type + '-hash'); + const hash = hashEl?.dataset.hash; + if (!hash) { setError('No payment hash — using real Lightning?'); return; } + + const btn = document.getElementById('pay-' + type + '-btn'); + if (btn) btn.disabled = true; + setStatus('Simulating payment…', '#ffaa00'); + + try { + const res = await fetch(`${API_BASE}/dev/stub/pay/${hash}`, { method: 'POST' }); + const data = await res.json(); + if (!data.ok) { setError('Payment simulation failed.'); return; } + startPolling(); + } catch (err) { + setError('Payment error: ' + err.message); + } finally { + if (btn) btn.disabled = false; + } +} + +function startPolling() { + stopPolling(); + setStatus('Processing…', '#ffaa00'); + const deadline = Date.now() + POLL_TIMEOUT_MS; + + async function poll() { + if (!currentJobId) return; + try { + const res = await fetch(`${API_BASE}/jobs/${currentJobId}`); + const data = await res.json(); + const { state, workInvoice, result, reason } = data; + + if (state === 'awaiting_work_payment' && workInvoice) { + showWorkInvoice(workInvoice); + stopPolling(); + return; + } + if (state === 'complete') { + showResult(result, 'complete'); + return; + } + if (state === 'rejected') { + showResult(reason, 'rejected'); + return; + } + if (state === 'failed') { + setError('Job failed: ' + (data.errorMessage || 'unknown error')); + stopPolling(); + return; + } + } catch { + /* network hiccup — keep polling */ + } + if (Date.now() > deadline) { + setError('Timed out waiting for response.'); + stopPolling(); + return; + } + pollTimer = setTimeout(poll, POLL_INTERVAL_MS); + } + + pollTimer = setTimeout(poll, POLL_INTERVAL_MS); +} + +function stopPolling() { + clearTimeout(pollTimer); + pollTimer = null; +} + +function copyToClipboard(elId) { + const el = document.getElementById(elId); + if (!el) return; + navigator.clipboard.writeText(el.textContent).catch(() => {}); + const btn = el.nextElementSibling; + if (btn) { btn.textContent = 'COPIED'; setTimeout(() => { btn.textContent = 'COPY'; }, 1500); } +} + +window._timmyCopy = copyToClipboard; diff --git a/the-matrix/js/websocket.js b/the-matrix/js/websocket.js index ea8a0f5..778120d 100644 --- a/the-matrix/js/websocket.js +++ b/the-matrix/js/websocket.js @@ -2,7 +2,20 @@ import { AGENT_DEFS, colorToCss } from './agent-defs.js'; import { setAgentState } from './agents.js'; import { appendChatMessage } from './ui.js'; -const WS_URL = import.meta.env.VITE_WS_URL || ''; +/** + * WebSocket URL resolution order: + * 1. VITE_WS_URL env var (set at build time for custom deployments) + * 2. Auto-derived from window.location — works when the Matrix is served from + * the same host as the API (the default: /tower served by the API server) + */ +function resolveWsUrl() { + const explicit = import.meta.env.VITE_WS_URL; + if (explicit) return explicit; + const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + return `${proto}//${window.location.host}/api/ws`; +} + +const WS_URL = resolveWsUrl(); const agentById = Object.fromEntries(AGENT_DEFS.map(d => [d.id, d])); @@ -13,10 +26,6 @@ let reconnectTimer = null; const RECONNECT_DELAY_MS = 5000; export function initWebSocket(_scene) { - if (!WS_URL) { - connectionState = 'disconnected'; - return; - } connect(); } @@ -70,6 +79,11 @@ function scheduleReconnect() { function handleMessage(msg) { switch (msg.type) { + case 'ping': + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'pong' })); + } + break; case 'agent_state': { if (msg.agentId && msg.state) { setAgentState(msg.agentId, msg.state); @@ -79,13 +93,13 @@ function handleMessage(msg) { case 'job_started': { jobCount++; if (msg.agentId) setAgentState(msg.agentId, 'active'); - logEvent(`JOB ${(msg.jobId || '').slice(0, 8)} started`); + logEvent(`JOB ${(msg.jobId || '').slice(0, 8)} ▶ started`); break; } case 'job_completed': { if (jobCount > 0) jobCount--; if (msg.agentId) setAgentState(msg.agentId, 'idle'); - logEvent(`JOB ${(msg.jobId || '').slice(0, 8)} completed`); + logEvent(`JOB ${(msg.jobId || '').slice(0, 8)} ✓ complete`); break; } case 'chat': { @@ -103,7 +117,7 @@ function handleMessage(msg) { } function logEvent(text) { - appendChatMessage('SYS', text, colorToCss(0x003300), 'sys'); + appendChatMessage('SYS', text, '#003300', 'sys'); } export function getConnectionState() { diff --git a/the-matrix/vite.config.js b/the-matrix/vite.config.js index ae1371c..e42babc 100644 --- a/the-matrix/vite.config.js +++ b/the-matrix/vite.config.js @@ -33,6 +33,7 @@ function generateSW() { export default defineConfig({ root: '.', + base: '/tower/', build: { outDir: 'dist', assetsDir: 'assets', @@ -42,5 +43,8 @@ export default defineConfig({ plugins: [generateSW()], server: { host: true, + proxy: { + '/api': 'http://localhost:8080', + }, }, });