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:
@@ -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 ────────────────────────────
|
||||
|
||||
@@ -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
222
the-matrix/js/history.js
Normal 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;
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user