@blockrun/franklin 3.10.0 → 3.10.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/start.d.ts +4 -0
- package/dist/commands/start.js +57 -5
- package/dist/index.js +12 -2
- package/dist/panel/html.js +501 -23
- package/dist/panel/server.js +127 -0
- package/dist/session/from-import.d.ts +18 -0
- package/dist/session/from-import.js +553 -0
- package/dist/stats/tracker.d.ts +4 -0
- package/dist/stats/tracker.js +30 -4
- package/dist/ui/app.js +6 -12
- package/package.json +1 -1
package/dist/panel/html.js
CHANGED
|
@@ -273,6 +273,63 @@ a:hover { text-decoration:underline; }
|
|
|
273
273
|
.tab.active { display:block; }
|
|
274
274
|
.empty { color:var(--text-dim); text-align:center; padding:56px 24px; font-size:13px; }
|
|
275
275
|
|
|
276
|
+
/* ── Tasks ── */
|
|
277
|
+
.tasks-toolbar { display:flex; align-items:center; gap:10px; margin-bottom:12px; }
|
|
278
|
+
.tasks-table {
|
|
279
|
+
display:flex; flex-direction:column; gap:4px;
|
|
280
|
+
}
|
|
281
|
+
.task-row {
|
|
282
|
+
display:grid; grid-template-columns:140px 1fr 110px 90px 92px;
|
|
283
|
+
gap:12px; align-items:center;
|
|
284
|
+
background:oklch(0.19 0.006 286 / 75%); border:1px solid var(--border); border-radius:8px;
|
|
285
|
+
padding:11px 14px; cursor:pointer; transition:all .15s ease;
|
|
286
|
+
backdrop-filter:blur(8px); -webkit-backdrop-filter:blur(8px);
|
|
287
|
+
}
|
|
288
|
+
.task-row:hover { background:var(--bg-card-hover); border-color:var(--border-strong); }
|
|
289
|
+
.task-row .runid { font-family:var(--mono); font-size:11px; color:var(--text-muted); }
|
|
290
|
+
.task-row .label { font-size:13px; color:var(--text); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
291
|
+
.task-row .age { font-family:var(--mono); font-size:11px; color:var(--text-dim); }
|
|
292
|
+
.task-row .actions { text-align:right; }
|
|
293
|
+
.task-row .cancel-err { grid-column:1 / -1; color:var(--danger); font-size:11px; font-family:var(--mono); padding-top:4px; }
|
|
294
|
+
.task-status {
|
|
295
|
+
display:inline-block; font-size:9px; font-family:var(--mono); font-weight:700;
|
|
296
|
+
padding:3px 8px; border-radius:5px; text-transform:uppercase; letter-spacing:0.6px;
|
|
297
|
+
}
|
|
298
|
+
.task-status.succeeded { background:oklch(0.72 0.17 150 / 14%); color:var(--success); }
|
|
299
|
+
.task-status.running { background:oklch(0.68 0.16 260 / 16%); color:var(--brand); }
|
|
300
|
+
.task-status.queued { background:oklch(1 0 0 / 6%); color:var(--text-dim); }
|
|
301
|
+
.task-status.failed,
|
|
302
|
+
.task-status.lost { background:oklch(0.65 0.20 25 / 16%); color:var(--danger); }
|
|
303
|
+
.task-status.cancelled { background:oklch(0.78 0.14 85 / 14%); color:var(--warning); }
|
|
304
|
+
.task-status.timed_out { background:oklch(0.65 0.20 25 / 16%); color:var(--danger); }
|
|
305
|
+
|
|
306
|
+
.task-detail {
|
|
307
|
+
background:var(--bg-card); border:1px solid var(--border); border-radius:var(--radius);
|
|
308
|
+
padding:18px; margin-bottom:14px;
|
|
309
|
+
}
|
|
310
|
+
.task-detail h4 { font-size:11px; color:var(--text-dim); text-transform:uppercase; letter-spacing:0.8px; font-weight:600; margin:14px 0 6px; }
|
|
311
|
+
.task-detail .top { display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:6px; }
|
|
312
|
+
.task-detail .top .title { font-size:14px; font-weight:600; }
|
|
313
|
+
.task-detail-meta {
|
|
314
|
+
display:grid; grid-template-columns:max-content 1fr; column-gap:14px; row-gap:4px;
|
|
315
|
+
font-family:var(--mono); font-size:11.5px; color:var(--text-muted);
|
|
316
|
+
}
|
|
317
|
+
.task-detail-meta .k { color:var(--text-dim); }
|
|
318
|
+
.task-detail-meta .v { word-break:break-all; }
|
|
319
|
+
.task-events { display:flex; flex-direction:column; gap:4px; font-family:var(--mono); font-size:11.5px; }
|
|
320
|
+
.task-event { display:grid; grid-template-columns:90px 130px 1fr; gap:10px; padding:3px 0; color:var(--text-muted); border-bottom:1px solid var(--border); }
|
|
321
|
+
.task-event:last-child { border:none; }
|
|
322
|
+
.task-event .kind { font-weight:600; color:var(--text); }
|
|
323
|
+
.task-log-footer { font-size:11px; color:var(--warning); margin:8px 0 4px; font-family:var(--mono); }
|
|
324
|
+
.task-log {
|
|
325
|
+
font-family:var(--mono); font-size:11.5px; color:var(--text-muted);
|
|
326
|
+
background:oklch(0 0 0 / 35%); border:1px solid var(--border);
|
|
327
|
+
border-radius:8px; padding:10px 12px; max-height:400px;
|
|
328
|
+
overflow-y:auto; white-space:pre-wrap; word-break:break-all;
|
|
329
|
+
line-height:1.5;
|
|
330
|
+
}
|
|
331
|
+
.task-detail-actions { display:flex; gap:8px; margin-top:14px; }
|
|
332
|
+
|
|
276
333
|
/* ── Wallet page ── */
|
|
277
334
|
.chain-switcher {
|
|
278
335
|
display:inline-flex; padding:3px; gap:2px;
|
|
@@ -402,6 +459,10 @@ a:hover { text-decoration:underline; }
|
|
|
402
459
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
|
403
460
|
Sessions
|
|
404
461
|
</button>
|
|
462
|
+
<button class="nav-item" data-tab="tasks">
|
|
463
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
|
|
464
|
+
Tasks
|
|
465
|
+
</button>
|
|
405
466
|
<button class="nav-item" data-tab="learnings">
|
|
406
467
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>
|
|
407
468
|
Learnings
|
|
@@ -567,8 +628,22 @@ a:hover { text-decoration:underline; }
|
|
|
567
628
|
<p>Browse past conversations</p>
|
|
568
629
|
</div>
|
|
569
630
|
<input class="search-box" id="session-search" placeholder="Search sessions..." />
|
|
570
|
-
<div class="session-list" id="session-list"></div>
|
|
571
631
|
<div class="session-detail" id="session-detail" style="display:none"></div>
|
|
632
|
+
<div class="session-list" id="session-list"></div>
|
|
633
|
+
</div>
|
|
634
|
+
|
|
635
|
+
<!-- Tasks -->
|
|
636
|
+
<div class="tab" id="tab-tasks">
|
|
637
|
+
<div class="content-header">
|
|
638
|
+
<h2>Tasks</h2>
|
|
639
|
+
<p>Detached background work — long builds, runs, jobs.</p>
|
|
640
|
+
</div>
|
|
641
|
+
<div class="tasks-toolbar">
|
|
642
|
+
<button class="btn" id="tasks-refresh-btn">Refresh</button>
|
|
643
|
+
<span id="tasks-summary" style="font-size:12px;color:var(--text-dim);"></span>
|
|
644
|
+
</div>
|
|
645
|
+
<div class="task-detail" id="task-detail" style="display:none"></div>
|
|
646
|
+
<div class="tasks-table" id="tasks-list"></div>
|
|
572
647
|
</div>
|
|
573
648
|
|
|
574
649
|
<!-- Markets -->
|
|
@@ -639,20 +714,41 @@ a:hover { text-decoration:underline; }
|
|
|
639
714
|
</div>
|
|
640
715
|
|
|
641
716
|
<script>
|
|
642
|
-
// Tab switching
|
|
717
|
+
// Tab switching — supports URL hash (e.g. #tasks) for deep links.
|
|
718
|
+
// Emits a 'tab:activated' / 'tab:deactivated' event so per-tab modules
|
|
719
|
+
// can start/stop their pollers without coupling to the dispatcher.
|
|
720
|
+
let _activeTab = 'overview';
|
|
721
|
+
function activateTab(name) {
|
|
722
|
+
if (!document.getElementById('tab-' + name)) name = 'overview';
|
|
723
|
+
if (name === _activeTab) return;
|
|
724
|
+
const prev = _activeTab;
|
|
725
|
+
document.querySelectorAll('.nav-item').forEach(b => b.classList.toggle('active', b.dataset.tab === name));
|
|
726
|
+
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.id === 'tab-' + name));
|
|
727
|
+
_activeTab = name;
|
|
728
|
+
document.dispatchEvent(new CustomEvent('tab:deactivated', { detail: { name: prev } }));
|
|
729
|
+
document.dispatchEvent(new CustomEvent('tab:activated', { detail: { name } }));
|
|
730
|
+
}
|
|
643
731
|
document.querySelectorAll('.nav-item').forEach(btn => {
|
|
644
732
|
btn.addEventListener('click', () => {
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
document.getElementById('tab-' + btn.dataset.tab).classList.add('active');
|
|
733
|
+
const name = btn.dataset.tab;
|
|
734
|
+
if (history.replaceState) history.replaceState(null, '', '#' + name);
|
|
735
|
+
activateTab(name);
|
|
649
736
|
});
|
|
650
737
|
});
|
|
738
|
+
window.addEventListener('hashchange', () => {
|
|
739
|
+
const name = (location.hash || '').replace(/^#/, '');
|
|
740
|
+
if (name) activateTab(name);
|
|
741
|
+
});
|
|
651
742
|
|
|
652
743
|
const api = (path) => fetch('/api/' + path).then(r => r.json()).catch(() => null);
|
|
653
744
|
const usd = (n) => '$' + (n || 0).toFixed(4);
|
|
654
745
|
const usdBig = (n) => '$' + (n || 0).toFixed(2);
|
|
655
|
-
const esc = (s) => s
|
|
746
|
+
const esc = (s) => String(s ?? '')
|
|
747
|
+
.replace(/&/g, '&')
|
|
748
|
+
.replace(/</g, '<')
|
|
749
|
+
.replace(/>/g, '>')
|
|
750
|
+
.replace(/"/g, '"')
|
|
751
|
+
.replace(/'/g, ''');
|
|
656
752
|
|
|
657
753
|
async function loadOverview() {
|
|
658
754
|
const [wallet, stats, insights] = await Promise.all([
|
|
@@ -683,8 +779,15 @@ async function loadOverview() {
|
|
|
683
779
|
document.getElementById('period-info').textContent = stats.period || '';
|
|
684
780
|
|
|
685
781
|
if (stats.opusCost > 0) {
|
|
686
|
-
|
|
687
|
-
|
|
782
|
+
// tracker.ts now returns saved already clamped to >= 0 and opusCost
|
|
783
|
+
// already inclusive of media (so comparing to totalCostUsd is
|
|
784
|
+
// apples-to-apples). Older summaries — or the rare path where saved
|
|
785
|
+
// is undefined — get the same Math.max clamp here so the panel
|
|
786
|
+
// never shows a negative dollar amount.
|
|
787
|
+
const saved = Math.max(0, stats.saved != null ? stats.saved : (stats.opusCost - stats.totalCostUsd));
|
|
788
|
+
const pct = stats.savedPct != null
|
|
789
|
+
? Math.max(0, stats.savedPct)
|
|
790
|
+
: (stats.opusCost > 0 ? Math.max(0, (saved / stats.opusCost) * 100) : 0);
|
|
688
791
|
document.getElementById('savings-hero').style.display = 'flex';
|
|
689
792
|
document.getElementById('savings-amount').textContent = usdBig(saved);
|
|
690
793
|
document.getElementById('savings-pct').textContent = pct.toFixed(0) + '%';
|
|
@@ -719,27 +822,57 @@ async function loadOverview() {
|
|
|
719
822
|
|
|
720
823
|
async function loadSessions() {
|
|
721
824
|
const sessions = await api('sessions');
|
|
825
|
+
clearSessionDetail();
|
|
722
826
|
if (!sessions || sessions.length === 0) {
|
|
723
827
|
document.getElementById('session-list').innerHTML = '<div class="empty">No sessions yet</div>';
|
|
724
828
|
return;
|
|
725
829
|
}
|
|
726
|
-
document.getElementById('session-list').innerHTML = sessions.slice(0, 50).map(
|
|
830
|
+
document.getElementById('session-list').innerHTML = sessions.slice(0, 50).map(renderSessionRow).join('');
|
|
831
|
+
attachSessionClickHandlers();
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function renderSessionRow(s) {
|
|
835
|
+
return (
|
|
727
836
|
'<div class="session-item" data-id="' + esc(s.id) + '">' +
|
|
728
837
|
'<div class="title">' + esc(s.model || 'unknown') + ' — ' + s.messageCount + ' messages</div>' +
|
|
729
838
|
'<div class="meta">' + new Date(s.createdAt).toLocaleString() + ' · ' + esc((s.workDir || '').split('/').pop()) + '</div>' +
|
|
730
839
|
'</div>'
|
|
731
|
-
)
|
|
732
|
-
|
|
840
|
+
);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
function renderSessionSearchRow(r) {
|
|
844
|
+
const s = r.session || {};
|
|
845
|
+
const id = s.id || r.sessionId || '';
|
|
846
|
+
const model = s.model || 'unknown';
|
|
847
|
+
const score = Number.isFinite(r.score) ? r.score.toFixed(2) : '0.00';
|
|
848
|
+
return (
|
|
849
|
+
'<div class="session-item" data-id="' + esc(id) + '">' +
|
|
850
|
+
'<div class="title">' + esc(r.snippet || '(no snippet)') + '</div>' +
|
|
851
|
+
'<div class="meta">' + esc(model) + ' · ' + esc(id) + ' · score: ' + score + '</div>' +
|
|
852
|
+
'</div>'
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
function clearSessionDetail() {
|
|
857
|
+
const detail = document.getElementById('session-detail');
|
|
858
|
+
detail.style.display = 'none';
|
|
859
|
+
detail.innerHTML = '';
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
function attachSessionClickHandlers() {
|
|
863
|
+
document.querySelectorAll('.session-item[data-id]').forEach(el => {
|
|
733
864
|
el.addEventListener('click', async () => {
|
|
865
|
+
if (!el.dataset.id) return;
|
|
734
866
|
const history = await api('sessions/' + encodeURIComponent(el.dataset.id));
|
|
735
867
|
if (!history) return;
|
|
736
868
|
const detail = document.getElementById('session-detail');
|
|
737
869
|
detail.style.display = 'block';
|
|
738
870
|
detail.innerHTML = history.map(m => {
|
|
739
871
|
const role = m.role || 'system';
|
|
740
|
-
let text = typeof m.content === 'string' ? m.content : JSON.stringify(m.content).slice(0, 500);
|
|
872
|
+
let text = typeof m.content === 'string' ? m.content : JSON.stringify(m.content ?? null).slice(0, 500);
|
|
741
873
|
return '<div class="msg ' + role + '"><div class="role">' + role + '</div><pre>' + esc(text) + '</pre></div>';
|
|
742
874
|
}).join('');
|
|
875
|
+
detail.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
743
876
|
});
|
|
744
877
|
});
|
|
745
878
|
}
|
|
@@ -751,16 +884,13 @@ document.getElementById('session-search').addEventListener('input', (e) => {
|
|
|
751
884
|
const q = e.target.value.trim();
|
|
752
885
|
if (!q) { loadSessions(); return; }
|
|
753
886
|
const results = await api('sessions/search?q=' + encodeURIComponent(q));
|
|
887
|
+
clearSessionDetail();
|
|
754
888
|
if (!results || results.length === 0) {
|
|
755
889
|
document.getElementById('session-list').innerHTML = '<div class="empty">No results</div>';
|
|
756
890
|
return;
|
|
757
891
|
}
|
|
758
|
-
document.getElementById('session-list').innerHTML = results.map(
|
|
759
|
-
|
|
760
|
-
'<div class="title">' + esc(r.snippet) + '</div>' +
|
|
761
|
-
'<div class="meta">' + esc(r.sessionId) + ' · score: ' + r.score.toFixed(2) + '</div>' +
|
|
762
|
-
'</div>'
|
|
763
|
-
).join('');
|
|
892
|
+
document.getElementById('session-list').innerHTML = results.map(renderSessionSearchRow).join('');
|
|
893
|
+
attachSessionClickHandlers();
|
|
764
894
|
}, 300);
|
|
765
895
|
});
|
|
766
896
|
|
|
@@ -878,15 +1008,24 @@ async function loadWallet() {
|
|
|
878
1008
|
solanaBtn.classList.toggle('active', w.chain === 'solana');
|
|
879
1009
|
}
|
|
880
1010
|
|
|
881
|
-
// QR via server — never leak address to third parties
|
|
1011
|
+
// QR via server — never leak address to third parties.
|
|
1012
|
+
// Encode chain + USDC token in the QR payload so wallet apps land
|
|
1013
|
+
// directly on the right network/token instead of a bare address:
|
|
1014
|
+
// Base → EIP-681: ethereum:<USDC>@8453/transfer?address=<addr>
|
|
1015
|
+
// Solana → Solana Pay: solana:<addr>?spl-token=<USDC mint>
|
|
882
1016
|
const qrBox = document.getElementById('wallet-qr');
|
|
883
1017
|
const hint = document.getElementById('wallet-qr-hint');
|
|
884
1018
|
if (addr && addr !== 'not set') {
|
|
885
|
-
const
|
|
1019
|
+
const USDC_BASE = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913';
|
|
1020
|
+
const USDC_SOL_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v';
|
|
1021
|
+
const payload = w.chain === 'solana'
|
|
1022
|
+
? 'solana:' + addr + '?spl-token=' + USDC_SOL_MINT
|
|
1023
|
+
: 'ethereum:' + USDC_BASE + '@8453/transfer?address=' + addr;
|
|
1024
|
+
const svg = await fetch('/api/wallet/qr?data=' + encodeURIComponent(payload)).then(r => r.ok ? r.text() : null);
|
|
886
1025
|
qrBox.innerHTML = svg || '';
|
|
887
1026
|
hint.textContent = w.chain === 'solana'
|
|
888
|
-
? 'Scan
|
|
889
|
-
: 'Scan to send USDC on Base
|
|
1027
|
+
? 'Scan with a Solana wallet (Phantom, Solflare) to send USDC SPL.'
|
|
1028
|
+
: 'Scan with an EVM wallet (MetaMask, Coinbase) to send USDC on Base.';
|
|
890
1029
|
} else {
|
|
891
1030
|
qrBox.innerHTML = '';
|
|
892
1031
|
hint.textContent = 'No wallet set yet — run: franklin setup';
|
|
@@ -1075,12 +1214,351 @@ async function loadAudit() {
|
|
|
1075
1214
|
document.getElementById('audit-refresh')?.addEventListener('click', loadAudit);
|
|
1076
1215
|
document.querySelector('[data-tab="audit"]')?.addEventListener('click', loadAudit);
|
|
1077
1216
|
|
|
1217
|
+
// ─── Tasks tab ───────────────────────────────────────────────────────────
|
|
1218
|
+
// Polls /api/tasks every 10s while the Tasks tab is active AND the page is
|
|
1219
|
+
// visible. Detail view layers a 2s log-tail poll using Range: bytes=N- on
|
|
1220
|
+
// top, stopping itself once the task hits a terminal status. No SSE — keeps
|
|
1221
|
+
// the panel server stateless and the wire format trivial.
|
|
1222
|
+
const TASK_TERMINAL = new Set(['succeeded', 'failed', 'timed_out', 'cancelled', 'lost']);
|
|
1223
|
+
const tasks = {
|
|
1224
|
+
pollTimer: null,
|
|
1225
|
+
logTimer: null,
|
|
1226
|
+
selected: null, // runId of currently-open detail
|
|
1227
|
+
logBytesShown: 0,
|
|
1228
|
+
cache: [], // last list snapshot (for finding the selected meta)
|
|
1229
|
+
finalLogFetched: false // ensures one final 200 fetch after task terminates
|
|
1230
|
+
};
|
|
1231
|
+
|
|
1232
|
+
function fmtAge(ts) {
|
|
1233
|
+
if (!ts) return '—';
|
|
1234
|
+
const s = Math.max(0, Math.round((Date.now() - ts) / 1000));
|
|
1235
|
+
if (s < 60) return s + 's ago';
|
|
1236
|
+
const m = Math.round(s / 60);
|
|
1237
|
+
if (m < 60) return m + 'm ago';
|
|
1238
|
+
const h = Math.round(m / 60);
|
|
1239
|
+
if (h < 48) return h + 'h ago';
|
|
1240
|
+
return Math.round(h / 24) + 'd ago';
|
|
1241
|
+
}
|
|
1242
|
+
function fmtTime(ts) {
|
|
1243
|
+
if (!ts) return '—';
|
|
1244
|
+
return new Date(ts).toLocaleString();
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
async function fetchTasks() {
|
|
1248
|
+
const data = await api('tasks');
|
|
1249
|
+
if (!data) {
|
|
1250
|
+
document.getElementById('tasks-list').innerHTML = '<div class="empty">API offline</div>';
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
1253
|
+
const list = (data.tasks || []).slice().sort((a, b) => {
|
|
1254
|
+
const at = a.lastEventAt || a.createdAt || 0;
|
|
1255
|
+
const bt = b.lastEventAt || b.createdAt || 0;
|
|
1256
|
+
return bt - at;
|
|
1257
|
+
});
|
|
1258
|
+
tasks.cache = list;
|
|
1259
|
+
document.getElementById('tasks-summary').textContent = list.length + ' task' + (list.length === 1 ? '' : 's');
|
|
1260
|
+
if (list.length === 0) {
|
|
1261
|
+
document.getElementById('tasks-list').innerHTML =
|
|
1262
|
+
'<div class="empty">No tasks. Start one via the Detach agent tool, or manually with <code>franklin task ...</code>.</div>';
|
|
1263
|
+
} else {
|
|
1264
|
+
document.getElementById('tasks-list').innerHTML = list.map(renderTaskRow).join('');
|
|
1265
|
+
attachTaskRowHandlers();
|
|
1266
|
+
}
|
|
1267
|
+
// If a detail view is open, refresh its meta panel from the new snapshot
|
|
1268
|
+
if (tasks.selected) {
|
|
1269
|
+
const meta = list.find(t => t.runId === tasks.selected);
|
|
1270
|
+
if (meta) refreshTaskDetailMeta(meta);
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
function renderTaskRow(t) {
|
|
1275
|
+
const shortId = t.runId.slice(0, 12) + '…';
|
|
1276
|
+
const age = fmtAge(t.lastEventAt || t.createdAt);
|
|
1277
|
+
const showCancel = t.status === 'running' || t.status === 'queued';
|
|
1278
|
+
const cancelBtn = showCancel
|
|
1279
|
+
? '<button class="btn btn-warn" data-cancel="' + esc(t.runId) + '">Cancel</button>'
|
|
1280
|
+
: '';
|
|
1281
|
+
return (
|
|
1282
|
+
'<div class="task-row" data-runid="' + esc(t.runId) + '">' +
|
|
1283
|
+
'<span class="runid">' + esc(shortId) + '</span>' +
|
|
1284
|
+
'<span class="label">' + esc(t.label || '(no label)') + '</span>' +
|
|
1285
|
+
'<span><span class="task-status ' + esc(t.status) + '">' + esc(t.status) + '</span></span>' +
|
|
1286
|
+
'<span class="age">' + esc(age) + '</span>' +
|
|
1287
|
+
'<span class="actions">' + cancelBtn + '</span>' +
|
|
1288
|
+
'</div>'
|
|
1289
|
+
);
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
function attachTaskRowHandlers() {
|
|
1293
|
+
document.querySelectorAll('.task-row[data-runid]').forEach(el => {
|
|
1294
|
+
el.addEventListener('click', (ev) => {
|
|
1295
|
+
// Cancel button: handle and stop propagation so the row doesn't open detail
|
|
1296
|
+
const target = ev.target;
|
|
1297
|
+
if (target instanceof HTMLElement && target.dataset.cancel) {
|
|
1298
|
+
ev.stopPropagation();
|
|
1299
|
+
cancelTask(target.dataset.cancel, el);
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
openTaskDetail(el.dataset.runid);
|
|
1303
|
+
});
|
|
1304
|
+
});
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
async function cancelTask(runId, rowEl) {
|
|
1308
|
+
if (!confirm('Cancel task ' + runId.slice(0, 12) + '…?\\n\\nFranklin will send SIGTERM to the running process.')) return;
|
|
1309
|
+
try {
|
|
1310
|
+
const r = await fetch('/api/tasks/' + encodeURIComponent(runId) + '/cancel', { method: 'POST' });
|
|
1311
|
+
const d = await r.json().catch(() => ({}));
|
|
1312
|
+
if (d && d.ok) {
|
|
1313
|
+
fetchTasks();
|
|
1314
|
+
} else {
|
|
1315
|
+
// Show inline error under the row
|
|
1316
|
+
const existing = rowEl && rowEl.querySelector('.cancel-err');
|
|
1317
|
+
if (existing) existing.remove();
|
|
1318
|
+
const err = document.createElement('div');
|
|
1319
|
+
err.className = 'cancel-err';
|
|
1320
|
+
err.textContent = 'Cancel failed: ' + (d && d.reason ? d.reason : 'unknown');
|
|
1321
|
+
if (rowEl) rowEl.appendChild(err);
|
|
1322
|
+
}
|
|
1323
|
+
} catch (err) {
|
|
1324
|
+
alert('Network error: ' + (err && err.message ? err.message : err));
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
async function openTaskDetail(runId) {
|
|
1329
|
+
tasks.selected = runId;
|
|
1330
|
+
tasks.logBytesShown = 0;
|
|
1331
|
+
tasks.finalLogFetched = false;
|
|
1332
|
+
const detail = document.getElementById('task-detail');
|
|
1333
|
+
detail.style.display = 'block';
|
|
1334
|
+
detail.innerHTML = '<div style="color:var(--text-dim);font-size:12px">Loading…</div>';
|
|
1335
|
+
detail.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
1336
|
+
|
|
1337
|
+
const meta = await api('tasks/' + encodeURIComponent(runId));
|
|
1338
|
+
if (!meta) {
|
|
1339
|
+
detail.innerHTML = '<div class="empty">Task not found</div>';
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
renderTaskDetail(meta);
|
|
1343
|
+
await Promise.all([loadTaskEvents(runId), pollTaskLog(runId, /*initial*/ true)]);
|
|
1344
|
+
// Start log polling cadence (only if not terminal and visible)
|
|
1345
|
+
startLogPolling();
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
function refreshTaskDetailMeta(meta) {
|
|
1349
|
+
// Update only the top metadata fields without resetting the log box.
|
|
1350
|
+
const top = document.getElementById('td-top');
|
|
1351
|
+
const metaBox = document.getElementById('td-meta');
|
|
1352
|
+
const cancelSlot = document.getElementById('td-cancel-slot');
|
|
1353
|
+
if (top) top.innerHTML = renderTaskDetailTop(meta);
|
|
1354
|
+
if (metaBox) metaBox.innerHTML = renderTaskDetailMetaRows(meta);
|
|
1355
|
+
if (cancelSlot) cancelSlot.innerHTML = renderTaskDetailCancelBtn(meta);
|
|
1356
|
+
attachTaskDetailButtonHandlers(meta);
|
|
1357
|
+
// If the task just hit terminal, surface the footer + stop polling
|
|
1358
|
+
if (TASK_TERMINAL.has(meta.status)) {
|
|
1359
|
+
const footer = document.getElementById('td-log-footer');
|
|
1360
|
+
if (footer && !footer.textContent) {
|
|
1361
|
+
footer.textContent = 'Final status: ' + meta.status + ' — log polling stopped.';
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
function renderTaskDetailTop(t) {
|
|
1367
|
+
return (
|
|
1368
|
+
'<span class="title">' + esc(t.label || '(no label)') + '</span>' +
|
|
1369
|
+
'<span class="task-status ' + esc(t.status) + '">' + esc(t.status) + '</span>'
|
|
1370
|
+
);
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
function renderTaskDetailMetaRows(t) {
|
|
1374
|
+
const rows = [
|
|
1375
|
+
['runId', t.runId],
|
|
1376
|
+
['command', t.command],
|
|
1377
|
+
['workingDir', t.workingDir],
|
|
1378
|
+
['pid', t.pid != null ? String(t.pid) : '—'],
|
|
1379
|
+
['createdAt', fmtTime(t.createdAt)],
|
|
1380
|
+
['startedAt', fmtTime(t.startedAt)],
|
|
1381
|
+
['lastEventAt', fmtTime(t.lastEventAt)],
|
|
1382
|
+
['endedAt', fmtTime(t.endedAt)],
|
|
1383
|
+
];
|
|
1384
|
+
if (t.exitCode !== undefined) rows.push(['exitCode', String(t.exitCode)]);
|
|
1385
|
+
if (t.terminalSummary) rows.push(['terminalSummary', t.terminalSummary]);
|
|
1386
|
+
if (t.error) rows.push(['error', t.error]);
|
|
1387
|
+
return rows.map(([k, v]) =>
|
|
1388
|
+
'<span class="k">' + esc(k) + '</span><span class="v">' + esc(v == null ? '—' : v) + '</span>'
|
|
1389
|
+
).join('');
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
function renderTaskDetailCancelBtn(t) {
|
|
1393
|
+
if (TASK_TERMINAL.has(t.status)) return '';
|
|
1394
|
+
return '<button class="btn btn-warn" id="td-cancel-btn" data-runid="' + esc(t.runId) + '">Cancel</button>';
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
function renderTaskDetail(t) {
|
|
1398
|
+
const detail = document.getElementById('task-detail');
|
|
1399
|
+
detail.innerHTML =
|
|
1400
|
+
'<div class="top" id="td-top">' + renderTaskDetailTop(t) + '</div>' +
|
|
1401
|
+
'<div class="task-detail-meta" id="td-meta">' + renderTaskDetailMetaRows(t) + '</div>' +
|
|
1402
|
+
'<h4>Recent events</h4>' +
|
|
1403
|
+
'<div class="task-events" id="td-events"><div style="color:var(--text-dim);font-size:11px">Loading…</div></div>' +
|
|
1404
|
+
'<h4>Log tail</h4>' +
|
|
1405
|
+
'<div class="task-log-footer" id="td-log-footer"></div>' +
|
|
1406
|
+
'<pre class="task-log" id="td-log"></pre>' +
|
|
1407
|
+
'<div class="task-detail-actions">' +
|
|
1408
|
+
'<span id="td-cancel-slot">' + renderTaskDetailCancelBtn(t) + '</span>' +
|
|
1409
|
+
'<button class="btn btn-ghost" id="td-close-btn">Close</button>' +
|
|
1410
|
+
'</div>';
|
|
1411
|
+
attachTaskDetailButtonHandlers(t);
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
function attachTaskDetailButtonHandlers(t) {
|
|
1415
|
+
const closeBtn = document.getElementById('td-close-btn');
|
|
1416
|
+
if (closeBtn) closeBtn.onclick = closeTaskDetail;
|
|
1417
|
+
const cancelBtn = document.getElementById('td-cancel-btn');
|
|
1418
|
+
if (cancelBtn) cancelBtn.onclick = () => cancelTask(t.runId, null);
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
function closeTaskDetail() {
|
|
1422
|
+
tasks.selected = null;
|
|
1423
|
+
stopLogPolling();
|
|
1424
|
+
const detail = document.getElementById('task-detail');
|
|
1425
|
+
detail.style.display = 'none';
|
|
1426
|
+
detail.innerHTML = '';
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
async function loadTaskEvents(runId) {
|
|
1430
|
+
const data = await api('tasks/' + encodeURIComponent(runId) + '/events');
|
|
1431
|
+
const box = document.getElementById('td-events');
|
|
1432
|
+
if (!box) return;
|
|
1433
|
+
const events = (data && data.events ? data.events : [])
|
|
1434
|
+
.slice()
|
|
1435
|
+
.sort((a, b) => (b.at || 0) - (a.at || 0))
|
|
1436
|
+
.slice(0, 10);
|
|
1437
|
+
if (events.length === 0) {
|
|
1438
|
+
box.innerHTML = '<div style="color:var(--text-dim);font-size:11px">No events recorded.</div>';
|
|
1439
|
+
return;
|
|
1440
|
+
}
|
|
1441
|
+
box.innerHTML = events.map(e =>
|
|
1442
|
+
'<div class="task-event">' +
|
|
1443
|
+
'<span class="kind">' + esc(e.kind) + '</span>' +
|
|
1444
|
+
'<span>' + esc(fmtTime(e.at)) + '</span>' +
|
|
1445
|
+
'<span>' + esc(e.summary || '') + '</span>' +
|
|
1446
|
+
'</div>'
|
|
1447
|
+
).join('');
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
async function pollTaskLog(runId, initial) {
|
|
1451
|
+
const logEl = document.getElementById('td-log');
|
|
1452
|
+
if (!logEl) return;
|
|
1453
|
+
try {
|
|
1454
|
+
const headers = (!initial && tasks.logBytesShown > 0)
|
|
1455
|
+
? { 'Range': 'bytes=' + tasks.logBytesShown + '-' }
|
|
1456
|
+
: {};
|
|
1457
|
+
const res = await fetch('/api/tasks/' + encodeURIComponent(runId) + '/log', { headers });
|
|
1458
|
+
if (res.status === 206) {
|
|
1459
|
+
const body = await res.text();
|
|
1460
|
+
if (body.length > 0) {
|
|
1461
|
+
logEl.textContent += body;
|
|
1462
|
+
tasks.logBytesShown += new Blob([body]).size;
|
|
1463
|
+
logEl.scrollTop = logEl.scrollHeight;
|
|
1464
|
+
}
|
|
1465
|
+
} else if (res.status === 200) {
|
|
1466
|
+
const body = await res.text();
|
|
1467
|
+
logEl.textContent = body;
|
|
1468
|
+
tasks.logBytesShown = new Blob([body]).size;
|
|
1469
|
+
logEl.scrollTop = logEl.scrollHeight;
|
|
1470
|
+
}
|
|
1471
|
+
} catch { /* network blip — next tick will retry */ }
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
function startLogPolling() {
|
|
1475
|
+
stopLogPolling();
|
|
1476
|
+
if (!tasks.selected) return;
|
|
1477
|
+
tasks.logTimer = setInterval(async () => {
|
|
1478
|
+
if (!tasks.selected) { stopLogPolling(); return; }
|
|
1479
|
+
if (document.visibilityState !== 'visible') return;
|
|
1480
|
+
const runId = tasks.selected;
|
|
1481
|
+
const meta = tasks.cache.find(t => t.runId === runId);
|
|
1482
|
+
const status = meta ? meta.status : 'running';
|
|
1483
|
+
if (TASK_TERMINAL.has(status)) {
|
|
1484
|
+
// One final 200 fetch to flush, then stop.
|
|
1485
|
+
if (!tasks.finalLogFetched) {
|
|
1486
|
+
tasks.finalLogFetched = true;
|
|
1487
|
+
await pollTaskLog(runId, /*initial*/ true);
|
|
1488
|
+
}
|
|
1489
|
+
const footer = document.getElementById('td-log-footer');
|
|
1490
|
+
if (footer && !footer.textContent) footer.textContent = 'Final status: ' + status + ' — log polling stopped.';
|
|
1491
|
+
stopLogPolling();
|
|
1492
|
+
return;
|
|
1493
|
+
}
|
|
1494
|
+
pollTaskLog(runId, /*initial*/ false);
|
|
1495
|
+
}, 2000);
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
function stopLogPolling() {
|
|
1499
|
+
if (tasks.logTimer) {
|
|
1500
|
+
clearInterval(tasks.logTimer);
|
|
1501
|
+
tasks.logTimer = null;
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
function startTasksPolling() {
|
|
1506
|
+
stopTasksPolling();
|
|
1507
|
+
fetchTasks();
|
|
1508
|
+
tasks.pollTimer = setInterval(() => {
|
|
1509
|
+
if (document.visibilityState === 'visible') fetchTasks();
|
|
1510
|
+
}, 10000);
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
function stopTasksPolling() {
|
|
1514
|
+
if (tasks.pollTimer) {
|
|
1515
|
+
clearInterval(tasks.pollTimer);
|
|
1516
|
+
tasks.pollTimer = null;
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
document.addEventListener('tab:activated', (e) => {
|
|
1521
|
+
if (e.detail && e.detail.name === 'tasks') {
|
|
1522
|
+
startTasksPolling();
|
|
1523
|
+
if (tasks.selected) startLogPolling();
|
|
1524
|
+
}
|
|
1525
|
+
});
|
|
1526
|
+
document.addEventListener('tab:deactivated', (e) => {
|
|
1527
|
+
if (e.detail && e.detail.name === 'tasks') {
|
|
1528
|
+
stopTasksPolling();
|
|
1529
|
+
stopLogPolling();
|
|
1530
|
+
}
|
|
1531
|
+
});
|
|
1532
|
+
document.addEventListener('visibilitychange', () => {
|
|
1533
|
+
const visible = document.visibilityState === 'visible';
|
|
1534
|
+
if (_activeTab === 'tasks') {
|
|
1535
|
+
if (visible) {
|
|
1536
|
+
startTasksPolling();
|
|
1537
|
+
if (tasks.selected) startLogPolling();
|
|
1538
|
+
} else {
|
|
1539
|
+
stopTasksPolling();
|
|
1540
|
+
stopLogPolling();
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
});
|
|
1544
|
+
|
|
1545
|
+
document.getElementById('tasks-refresh-btn')?.addEventListener('click', fetchTasks);
|
|
1546
|
+
|
|
1078
1547
|
loadOverview();
|
|
1079
1548
|
loadSessions();
|
|
1080
1549
|
loadMarkets();
|
|
1081
1550
|
loadLearnings();
|
|
1082
1551
|
loadWallet();
|
|
1083
1552
|
document.querySelector('[data-tab="markets"]')?.addEventListener('click', loadMarkets);
|
|
1553
|
+
|
|
1554
|
+
// Honor URL hash on initial load (e.g. /#tasks deep link)
|
|
1555
|
+
{
|
|
1556
|
+
const initialHash = (location.hash || '').replace(/^#/, '');
|
|
1557
|
+
if (initialHash && initialHash !== 'overview' && document.getElementById('tab-' + initialHash)) {
|
|
1558
|
+
activateTab(initialHash);
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1084
1562
|
setInterval(() => api('wallet').then(w => {
|
|
1085
1563
|
if (w) {
|
|
1086
1564
|
document.getElementById('balance').textContent = usdBig(w.balance) + ' USDC';
|