feat(integration): WS bridge + Tower + payment panel + E2E test [10/10 PASS] (#26)
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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` });
|
||||
}
|
||||
});
|
||||
|
||||
187
artifacts/api-server/src/routes/events.ts
Normal file
187
artifacts/api-server/src/routes/events.ts
Normal 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");
|
||||
}
|
||||
@@ -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.
|
||||
238
scripts/e2e-test.sh
Normal file
238
scripts/e2e-test.sh
Normal 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
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
209
the-matrix/js/payment.js
Normal 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;
|
||||
@@ -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() {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user