WIP: Claude Code progress on #31

Automated salvage commit — agent session ended (exit 124).
Work in progress, may need continuation.
This commit is contained in:
Alexander Whitestone
2026-03-23 16:21:19 -04:00
parent 113095d2f0
commit 3cd924b44c
4 changed files with 426 additions and 1 deletions

View File

@@ -1,7 +1,7 @@
import { Router, type Request, type Response } from "express";
import { randomUUID, createHash } from "crypto";
import { db, jobs, invoices, jobDebates, type Job } from "@workspace/db";
import { eq, and } from "drizzle-orm";
import { eq, and, desc } from "drizzle-orm";
import { CreateJobBody, GetJobParams } from "@workspace/api-zod";
import { lnbitsService } from "../lib/lnbits.js";
import { agentService } from "../lib/agent.js";
@@ -494,6 +494,67 @@ async function advanceJob(job: Job): Promise<Job | null> {
return job;
}
// ── GET /jobs ─────────────────────────────────────────────────────────────────
// Returns the caller's completed/rejected job history (requires Nostr token).
router.get("/jobs", async (req: Request, res: Response) => {
const header = req.headers["x-nostr-token"];
const raw = typeof header === "string" ? header.trim() : null;
if (!raw) {
res.status(401).json({ error: "X-Nostr-Token header required" });
return;
}
const parsed = trustService.verifyToken(raw);
if (!parsed) {
res.status(401).json({ error: "Invalid or expired token" });
return;
}
try {
const rows = await db
.select({
id: jobs.id,
request: jobs.request,
state: jobs.state,
workAmountSats: jobs.workAmountSats,
actualAmountSats: jobs.actualAmountSats,
result: jobs.result,
rejectionReason: jobs.rejectionReason,
freeTier: jobs.freeTier,
absorbedSats: jobs.absorbedSats,
createdAt: jobs.createdAt,
updatedAt: jobs.updatedAt,
})
.from(jobs)
.where(
and(
eq(jobs.nostrPubkey, parsed.pubkey),
// Only terminal states are useful for history
)
)
.orderBy(desc(jobs.createdAt))
.limit(50);
res.json({ jobs: rows.map(j => ({
id: j.id,
request: j.request,
state: j.state,
costSats: j.actualAmountSats ?? j.workAmountSats ?? null,
freeTier: j.freeTier,
absorbedSats: j.absorbedSats ?? null,
result: j.state === "complete" ? j.result : null,
rejectionReason: j.state === "rejected" ? j.rejectionReason : null,
createdAt: j.createdAt.toISOString(),
completedAt: (j.state === "complete" || j.state === "rejected")
? j.updatedAt.toISOString()
: null,
})) });
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to fetch jobs";
res.status(500).json({ error: message });
}
});
// ── POST /jobs ────────────────────────────────────────────────────────────────
// ── Resolve Nostr pubkey from token header or body ────────────────────────────

View File

@@ -514,6 +514,132 @@
}
#timmy-id-card .id-npub:hover { color: #88aadd; }
#timmy-id-card .id-zaps { color: #556688; font-size: 9px; }
/* ── History panel (bottom sheet) ────────────────────────────────── */
#open-history-btn {
font-family: 'Courier New', monospace; font-size: 11px; font-weight: bold;
color: #ccaaff; background: rgba(25, 10, 45, 0.85); border: 1px solid #7755aa55;
padding: 7px 18px; cursor: pointer; letter-spacing: 1px;
box-shadow: 0 0 14px #5533aa22;
transition: background 0.15s, box-shadow 0.15s, color 0.15s;
border-radius: 2px;
min-height: 36px;
}
#open-history-btn:hover, #open-history-btn:active {
background: rgba(40, 18, 70, 0.95);
box-shadow: 0 0 20px #7755aa44;
color: #eeddff;
}
#history-panel {
position: fixed; bottom: -100%; left: 0; right: 0;
height: 80vh;
background: rgba(5, 3, 14, 0.97);
border-top: 1px solid #1a1030;
padding: 0;
overflow: hidden; z-index: 100;
font-family: 'Courier New', monospace;
transition: bottom 0.35s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 -8px 32px rgba(60, 30, 100, 0.18);
display: flex; flex-direction: column;
}
#history-panel.open { bottom: 0; }
#history-panel-header {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 20px 10px;
border-bottom: 1px solid #1a1030;
flex-shrink: 0;
}
#history-panel-header h2 {
font-size: 13px; letter-spacing: 3px; color: #9966dd;
text-shadow: 0 0 10px #5533aa;
margin: 0;
}
#history-panel-actions {
display: flex; gap: 8px; align-items: center;
}
#history-refresh-btn {
background: transparent; border: 1px solid #2a1a44;
color: #7755aa; font-family: 'Courier New', monospace;
font-size: 11px; padding: 4px 10px; cursor: pointer;
transition: all 0.15s; letter-spacing: 1px;
}
#history-refresh-btn:hover { border-color: #9966dd; color: #ccaaff; }
#history-close {
background: transparent; border: 1px solid #1a1030;
color: #554477; font-family: 'Courier New', monospace;
font-size: 16px; width: 28px; height: 28px;
cursor: pointer; transition: color 0.2s, border-color 0.2s;
}
#history-close:hover { color: #9966dd; border-color: #7755aa; }
#history-status {
font-size: 11px; padding: 12px 20px;
color: #334466; letter-spacing: 1px; min-height: 20px;
flex-shrink: 0;
}
#history-list {
flex: 1; overflow-y: auto;
padding: 0 0 16px;
-webkit-overflow-scrolling: touch;
}
.history-row {
border-bottom: 1px solid #100a20;
}
.history-row-header {
padding: 12px 20px;
cursor: default;
}
.history-row-header.history-expandable {
cursor: pointer;
}
.history-row-header.history-expandable:active {
background: rgba(100, 60, 160, 0.08);
}
.history-prompt {
color: #aabbdd; font-size: 12px; line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
margin-bottom: 6px;
}
.history-row-open .history-prompt {
-webkit-line-clamp: unset;
overflow: visible;
}
.history-meta {
display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
}
.history-agent {
font-size: 9px; letter-spacing: 2px; color: #7755aa;
border: 1px solid #3a2555; padding: 1px 5px;
}
.history-cost { font-size: 10px; color: #ffcc44; letter-spacing: 1px; }
.history-cost-free { color: #44dd88; }
.history-time { font-size: 10px; color: #445566; flex: 1; }
.history-state { font-size: 12px; font-weight: bold; }
.state-complete { color: #44dd88; }
.state-rejected { color: #dd6644; }
.state-pending { color: #ffcc44; }
.history-row-body {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.history-result {
margin: 0 20px 12px;
background: #060310; border: 1px solid #1a1030;
color: #aabbdd; padding: 12px;
font-size: 11px; line-height: 1.6;
white-space: pre-wrap; word-break: break-word;
font-family: 'Courier New', monospace;
}
.history-result-rejected { color: #dd8866; border-color: #3a1a10; }
</style>
</head>
<body>
@@ -541,6 +667,7 @@
<div id="top-buttons">
<button id="open-panel-btn">⚡ SUBMIT JOB</button>
<button id="open-session-btn">⚡ FUND SESSION</button>
<button id="open-history-btn">◷ HISTORY</button>
<a id="relay-admin-btn" href="/admin/relay">⚙ RELAY ADMIN</a>
</div>
@@ -690,6 +817,19 @@
<div id="session-error"></div>
</div>
<!-- ── History panel (bottom sheet) ──────────────────────────────── -->
<div id="history-panel">
<div id="history-panel-header">
<h2>◷ JOB HISTORY</h2>
<div id="history-panel-actions">
<button id="history-refresh-btn">↻ REFRESH</button>
<button id="history-close"></button>
</div>
</div>
<div id="history-status"></div>
<div id="history-list"></div>
</div>
<!-- ── FPS crosshair ─────────────────────────────────────────────── -->
<div id="crosshair"></div>

222
the-matrix/js/history.js Normal file
View File

@@ -0,0 +1,222 @@
/**
* history.js — Job history panel for Timmy Tower mobile.
*
* Shows completed jobs from GET /api/jobs in reverse chronological order.
* Each row is expandable to reveal the full result.
* Supports pull-to-refresh (scroll to top + overscroll) and a refresh button.
*/
import { getOrRefreshToken } from './nostr-identity.js';
const API_BASE = '/api';
// Deterministic agent label from job id (purely cosmetic — no real agent tracking)
const AGENT_LABELS = ['ALPHA', 'BETA', 'GAMMA', 'DELTA'];
function _agentForId(id) {
let sum = 0;
for (let i = 0; i < Math.min(8, id.length); i++) sum += id.charCodeAt(i);
return AGENT_LABELS[sum % AGENT_LABELS.length];
}
function _relativeTime(isoString) {
if (!isoString) return '';
const diff = Date.now() - new Date(isoString).getTime();
const sec = Math.floor(diff / 1000);
if (sec < 60) return `${sec}s ago`;
const min = Math.floor(sec / 60);
if (min < 60) return `${min} min ago`;
const hrs = Math.floor(min / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
return `${days}d ago`;
}
let _panel = null;
let _list = null;
let _status = null;
let _loading = false;
// Pull-to-refresh state
let _ptStart = 0;
let _ptActive = false;
const PT_THRESHOLD = 60; // px
export function initHistoryPanel() {
_panel = document.getElementById('history-panel');
_list = document.getElementById('history-list');
_status = document.getElementById('history-status');
if (!_panel) return;
document.getElementById('open-history-btn')
?.addEventListener('click', openHistoryPanel);
document.getElementById('history-close')
?.addEventListener('click', closeHistoryPanel);
document.getElementById('history-refresh-btn')
?.addEventListener('click', () => loadHistory());
// Pull-to-refresh on the list container
if (_list) {
_list.addEventListener('touchstart', _onPtStart, { passive: true });
_list.addEventListener('touchmove', _onPtMove, { passive: true });
_list.addEventListener('touchend', _onPtEnd, { passive: true });
}
}
function openHistoryPanel() {
if (!_panel) return;
_panel.classList.add('open');
loadHistory();
}
function closeHistoryPanel() {
_panel?.classList.remove('open');
}
// ── Pull-to-refresh ───────────────────────────────────────────────────────────
function _onPtStart(e) {
if (_list.scrollTop === 0 && e.touches.length === 1) {
_ptStart = e.touches[0].clientY;
_ptActive = true;
}
}
function _onPtMove(e) {
if (!_ptActive) return;
const dy = e.touches[0].clientY - _ptStart;
if (dy > PT_THRESHOLD) {
_ptActive = false;
if (_status) _status.textContent = 'Refreshing…';
loadHistory();
}
}
function _onPtEnd() { _ptActive = false; }
// ── Data loading ──────────────────────────────────────────────────────────────
async function loadHistory() {
if (_loading) return;
_loading = true;
if (_status) { _status.textContent = 'Loading…'; _status.style.color = '#5577aa'; }
try {
const token = await getOrRefreshToken('/api');
if (!token) {
renderEmpty('Sign in with Nostr to view your job history.');
return;
}
const res = await fetch(`${API_BASE}/jobs`, {
headers: { 'X-Nostr-Token': token },
});
if (res.status === 401) {
renderEmpty('Session expired — reload the page to sign in again.');
return;
}
if (!res.ok) {
const data = await res.json().catch(() => ({}));
if (_status) { _status.textContent = data.error || 'Failed to load history.'; _status.style.color = '#994444'; }
return;
}
const data = await res.json();
renderJobs(data.jobs ?? []);
} catch (err) {
if (_status) { _status.textContent = 'Network error: ' + err.message; _status.style.color = '#994444'; }
} finally {
_loading = false;
}
}
function renderEmpty(msg) {
if (_list) { _list.innerHTML = ''; }
if (_status) { _status.textContent = msg; _status.style.color = '#334466'; }
}
function renderJobs(jobList) {
if (!_list) return;
_list.innerHTML = '';
if (_status) _status.textContent = '';
if (!jobList.length) {
renderEmpty('No completed jobs yet. Submit a job to get started!');
return;
}
for (const job of jobList) {
_list.appendChild(_buildJobRow(job));
}
}
function _buildJobRow(job) {
const isComplete = job.state === 'complete';
const isRejected = job.state === 'rejected';
const hasContent = isComplete || isRejected;
const row = document.createElement('div');
row.className = 'history-row';
// ── Header (always visible) ────────────────────────────────────────────────
const header = document.createElement('div');
header.className = 'history-row-header';
const prompt = document.createElement('div');
prompt.className = 'history-prompt';
prompt.textContent = job.request;
const meta = document.createElement('div');
meta.className = 'history-meta';
const agentSpan = document.createElement('span');
agentSpan.className = 'history-agent';
agentSpan.textContent = _agentForId(job.id);
const costSpan = document.createElement('span');
costSpan.className = 'history-cost';
if (job.freeTier) {
costSpan.textContent = 'FREE';
costSpan.classList.add('history-cost-free');
} else {
costSpan.textContent = job.costSats != null ? `${job.costSats} sats` : '— sats';
}
const timeSpan = document.createElement('span');
timeSpan.className = 'history-time';
timeSpan.textContent = _relativeTime(job.completedAt ?? job.createdAt);
const stateSpan = document.createElement('span');
stateSpan.className = 'history-state';
if (isComplete) { stateSpan.textContent = '✓'; stateSpan.classList.add('state-complete'); }
else if (isRejected) { stateSpan.textContent = '✗'; stateSpan.classList.add('state-rejected'); }
else { stateSpan.textContent = '…'; stateSpan.classList.add('state-pending'); }
meta.append(agentSpan, costSpan, timeSpan, stateSpan);
header.append(prompt, meta);
// ── Expandable result ──────────────────────────────────────────────────────
const body = document.createElement('div');
body.className = 'history-row-body';
if (hasContent) {
const content = isComplete ? (job.result || '') : (job.rejectionReason || 'Request rejected.');
const pre = document.createElement('pre');
pre.className = 'history-result';
pre.textContent = content;
if (isRejected) pre.classList.add('history-result-rejected');
body.appendChild(pre);
header.classList.add('history-expandable');
header.addEventListener('click', () => {
const isOpen = row.classList.toggle('history-row-open');
// Animate body height
if (isOpen) {
body.style.maxHeight = body.scrollHeight + 'px';
} else {
body.style.maxHeight = '0';
}
});
}
row.append(header, body);
return row;
}

View File

@@ -11,6 +11,7 @@ import { initInteraction, disposeInteraction, registerSlapTarget } from './inter
import { initWebSocket, getConnectionState, getJobCount } from './websocket.js';
import { initPaymentPanel } from './payment.js';
import { initSessionPanel } from './session.js';
import { initHistoryPanel } from './history.js';
import { initNostrIdentity } from './nostr-identity.js';
import { warmup as warmupEdgeWorker, onReady as onEdgeWorkerReady } from './edge-worker-client.js';
import { setEdgeWorkerReady } from './ui.js';
@@ -46,6 +47,7 @@ function buildWorld(firstInit, stateSnapshot) {
initWebSocket(scene);
initPaymentPanel();
initSessionPanel();
initHistoryPanel();
void initNostrIdentity('/api');
warmupEdgeWorker();
onEdgeWorkerReady(() => setEdgeWorkerReady());