feat(integration): WS bridge + Tower + payment panel + E2E test [10/10 PASS] (#26)

This commit is contained in:
2026-03-18 21:20:51 -04:00
parent 3031c399ee
commit e088ca4cd8
11 changed files with 974 additions and 37 deletions

View File

@@ -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;

View File

@@ -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` });
}
});

View File

@@ -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");
}

View File

@@ -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 110)
| 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 1116)
| 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.

238
scripts/e2e-test.sh Normal file
View File

@@ -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 '<title>[^<]*</title>' | 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

View File

@@ -3,20 +3,22 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>The Matrix</title>
<title>Timmy Tower World</title>
<link rel="manifest" href="/manifest.json" />
<link rel="manifest" href="/tower/manifest.json" />
<meta name="theme-color" content="#00ff41" />
<!-- iOS PWA -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="The Matrix" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<meta name="apple-mobile-web-app-title" content="Timmy Tower World" />
<link rel="apple-touch-icon" href="/tower/icons/icon-192.png" />
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #000; overflow: hidden; font-family: 'Courier New', monospace; }
canvas { display: block; }
/* ── HUD ──────────────────────────────────────────────────────────────── */
#ui-overlay {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
pointer-events: none; z-index: 10;
@@ -34,13 +36,13 @@
text-shadow: 0 0 6px #00ff41; max-width: 240px;
}
#chat-panel {
position: fixed; bottom: 16px; left: 16px; right: 16px;
max-height: 180px; overflow-y: auto;
position: fixed; bottom: 56px; left: 16px;
width: 320px; max-height: 160px; overflow-y: auto;
color: #00ff41; font-size: 11px; line-height: 1.6;
text-shadow: 0 0 4px #00ff41;
pointer-events: none;
}
.chat-entry { opacity: 0.8; }
.chat-entry { opacity: 0.85; }
.chat-entry .agent-name { color: #00ff88; font-weight: bold; }
.chat-ts { color: #004d18; font-size: 10px; }
#connection-status {
@@ -56,7 +58,6 @@
background: transparent; border: 1px solid #004d18;
padding: 2px 6px; cursor: pointer;
pointer-events: all; z-index: 20;
text-shadow: none;
transition: color 0.2s, border-color 0.2s;
}
#chat-clear-btn:hover { color: #00ff41; border-color: #00ff41; }
@@ -66,15 +67,12 @@
display: none;
position: fixed; inset: 0; z-index: 200;
background: rgba(0, 0, 0, 0.88);
justify-content: center;
align-items: center;
justify-content: center; align-items: center;
pointer-events: none;
}
#webgl-recovery-overlay .recovery-text {
color: #00ff41;
font-family: 'Courier New', monospace;
font-size: 16px;
letter-spacing: 3px;
color: #00ff41; font-family: 'Courier New', monospace;
font-size: 16px; letter-spacing: 3px;
text-shadow: 0 0 18px #00ff41, 0 0 6px #00ff41;
animation: ctx-blink 1.2s step-end infinite;
}
@@ -82,6 +80,123 @@
0%, 100% { opacity: 1; }
50% { opacity: 0.25; }
}
/* ── Open panel button ────────────────────────────────────────────────── */
#open-panel-btn {
position: fixed; bottom: 16px; left: 16px;
font-family: 'Courier New', monospace; font-size: 12px; font-weight: bold;
color: #000; background: #00ff88; border: none;
padding: 8px 18px; cursor: pointer; z-index: 20; letter-spacing: 2px;
box-shadow: 0 0 16px #00ff88, 0 0 4px #00ff41;
transition: background 0.15s, box-shadow 0.15s;
}
#open-panel-btn:hover {
background: #00ffcc;
box-shadow: 0 0 24px #00ffcc, 0 0 8px #00ff88;
}
/* ── Payment panel ────────────────────────────────────────────────────── */
#payment-panel {
position: fixed; top: 0; right: -420px;
width: 400px; height: 100%;
background: rgba(0, 4, 0, 0.95);
border-left: 1px solid #004d18;
padding: 24px 20px;
overflow-y: auto; z-index: 100;
font-family: 'Courier New', monospace;
transition: right 0.35s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: -8px 0 32px rgba(0,255,65,0.1);
}
#payment-panel.open { right: 0; }
#payment-panel h2 {
font-size: 14px; letter-spacing: 4px; color: #00ff88;
text-shadow: 0 0 12px #00ff88;
margin-bottom: 20px; border-bottom: 1px solid #004d18; padding-bottom: 10px;
}
#payment-close {
position: absolute; top: 16px; right: 16px;
background: transparent; border: 1px solid #004d18;
color: #004d18; font-family: 'Courier New', monospace;
font-size: 16px; width: 28px; height: 28px;
cursor: pointer; transition: color 0.2s, border-color 0.2s;
}
#payment-close:hover { color: #00ff41; border-color: #00ff41; }
.panel-label {
font-size: 10px; letter-spacing: 2px; color: #007722;
margin-bottom: 6px; margin-top: 16px;
}
#job-input {
width: 100%; background: #000d00; border: 1px solid #004d18;
color: #00ff41; font-family: 'Courier New', monospace; font-size: 12px;
padding: 10px; resize: vertical; min-height: 90px;
outline: none; transition: border-color 0.2s;
}
#job-input:focus { border-color: #00ff88; }
#job-input::placeholder { color: #004d18; }
.panel-btn {
width: 100%; margin-top: 12px;
background: transparent; border: 1px solid #00ff41;
color: #00ff41; font-family: 'Courier New', monospace;
font-size: 12px; letter-spacing: 2px; padding: 10px;
cursor: pointer; transition: all 0.2s;
}
.panel-btn:hover:not(:disabled) {
background: #00ff41; color: #000; box-shadow: 0 0 16px #00ff41;
}
.panel-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.panel-btn.primary {
border-color: #00ff88; color: #00ff88;
}
.panel-btn.primary:hover:not(:disabled) {
background: #00ff88; color: #000; box-shadow: 0 0 20px #00ff88;
}
.panel-btn.danger {
border-color: #ff6600; color: #ff6600;
}
#job-status { font-size: 11px; margin-top: 8px; color: #00ff41; min-height: 16px; }
#job-error { font-size: 11px; margin-top: 4px; min-height: 16px; }
.invoice-box {
background: #000d00; border: 1px solid #004d18;
padding: 10px; margin-top: 8px;
font-size: 10px; color: #007722;
word-break: break-all; max-height: 80px; overflow-y: auto;
}
.copy-row { display: flex; gap: 8px; margin-top: 6px; align-items: stretch; }
.copy-row .invoice-box { flex: 1; margin-top: 0; }
.copy-btn {
background: transparent; border: 1px solid #004d18; color: #004d18;
font-family: 'Courier New', monospace; font-size: 10px; letter-spacing: 1px;
padding: 0 10px; cursor: pointer; transition: all 0.2s; white-space: nowrap;
}
.copy-btn:hover { border-color: #00ff41; color: #00ff41; }
.amount-tag {
display: inline-block; background: #001a00;
border: 1px solid #007722; color: #00ff88;
font-size: 16px; font-weight: bold; letter-spacing: 2px;
padding: 6px 14px; margin-top: 8px;
text-shadow: 0 0 8px #00ff88;
}
#job-result {
background: #000d00; border: 1px solid #004d18;
color: #00ff41; padding: 12px; font-size: 12px;
line-height: 1.6; margin-top: 8px;
white-space: pre-wrap; max-height: 260px; overflow-y: auto;
}
/* api-ui link */
.panel-link {
display: block; text-align: center; margin-top: 20px;
font-size: 10px; letter-spacing: 1px; color: #004d18;
text-decoration: none; transition: color 0.2s;
}
.panel-link:hover { color: #00ff41; }
</style>
</head>
<body>
@@ -99,8 +214,61 @@
<div id="connection-status">OFFLINE</div>
</div>
<button id="open-panel-btn">⚡ SUBMIT JOB</button>
<button id="chat-clear-btn" title="Clear chat history">CLEAR</button>
<!-- ── Payment panel ──────────────────────────────────────────────────── -->
<div id="payment-panel">
<button id="payment-close"></button>
<h2>⚡ TIMMY TOWER — JOB SUBMISSION</h2>
<!-- Step: input -->
<div data-step="input">
<div class="panel-label">YOUR REQUEST</div>
<textarea id="job-input" maxlength="500"
placeholder="Ask Timmy anything… (max 500 chars)"></textarea>
<button class="panel-btn primary" id="job-submit-btn">CREATE JOB →</button>
<a class="panel-link" href="/api/ui" target="_blank">Open full UI ↗</a>
</div>
<!-- Step: eval invoice -->
<div data-step="eval-invoice" style="display:none">
<div class="panel-label">EVAL FEE</div>
<div class="amount-tag" id="eval-amount">10 sats</div>
<div class="panel-label" style="margin-top:12px">LIGHTNING INVOICE</div>
<div class="copy-row">
<div class="invoice-box" id="eval-payment-request"></div>
<button class="copy-btn" onclick="_timmyCopy('eval-payment-request')">COPY</button>
</div>
<span id="eval-hash" data-hash=""></span>
<button class="panel-btn primary" id="pay-eval-btn">⚡ SIMULATE PAYMENT</button>
</div>
<!-- Step: work invoice -->
<div data-step="work-invoice" style="display:none">
<div class="panel-label">WORK FEE (token-based)</div>
<div class="amount-tag" id="work-amount">-- sats</div>
<div class="panel-label" style="margin-top:12px">LIGHTNING INVOICE</div>
<div class="copy-row">
<div class="invoice-box" id="work-payment-request"></div>
<button class="copy-btn" onclick="_timmyCopy('work-payment-request')">COPY</button>
</div>
<span id="work-hash" data-hash=""></span>
<button class="panel-btn primary" id="pay-work-btn">⚡ SIMULATE PAYMENT</button>
</div>
<!-- Step: result -->
<div data-step="result" style="display:none">
<div class="panel-label" id="result-label">AI RESULT</div>
<pre id="job-result"></pre>
<button class="panel-btn" id="new-job-btn" style="margin-top:16px">← NEW JOB</button>
</div>
<!-- Shared status / error (outside steps so always visible) -->
<div id="job-status" style="font-size:11px;margin-top:12px;min-height:16px;"></div>
<div id="job-error" style="font-size:11px;margin-top:4px;min-height:16px;"></div>
</div>
<div id="webgl-recovery-overlay">
<span class="recovery-text">GPU context lost — recovering...</span>
</div>

View File

@@ -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;
}

View File

@@ -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(() => {});
});
}

209
the-matrix/js/payment.js Normal file
View File

@@ -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;

View File

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

View File

@@ -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',
},
},
});