@blockrun/franklin 3.9.6 → 3.10.1
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/agent/context.js +1 -1
- package/dist/commands/task.d.ts +11 -0
- package/dist/commands/task.js +134 -0
- package/dist/index.js +16 -0
- package/dist/panel/html.js +492 -21
- package/dist/panel/server.js +127 -0
- package/dist/tasks/lost-detection.d.ts +15 -0
- package/dist/tasks/lost-detection.js +51 -0
- package/dist/tasks/paths.d.ts +12 -0
- package/dist/tasks/paths.js +32 -0
- package/dist/tasks/runner.d.ts +21 -0
- package/dist/tasks/runner.js +191 -0
- package/dist/tasks/spawn.d.ts +26 -0
- package/dist/tasks/spawn.js +72 -0
- package/dist/tasks/store.d.ts +24 -0
- package/dist/tasks/store.js +124 -0
- package/dist/tasks/types.d.ts +32 -0
- package/dist/tasks/types.js +14 -0
- package/dist/tools/detach.d.ts +9 -0
- package/dist/tools/detach.js +53 -0
- package/dist/tools/index.d.ts +2 -1
- package/dist/tools/index.js +3 -1
- package/dist/tools/tool-categories.js +4 -0
- 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([
|
|
@@ -719,27 +815,57 @@ async function loadOverview() {
|
|
|
719
815
|
|
|
720
816
|
async function loadSessions() {
|
|
721
817
|
const sessions = await api('sessions');
|
|
818
|
+
clearSessionDetail();
|
|
722
819
|
if (!sessions || sessions.length === 0) {
|
|
723
820
|
document.getElementById('session-list').innerHTML = '<div class="empty">No sessions yet</div>';
|
|
724
821
|
return;
|
|
725
822
|
}
|
|
726
|
-
document.getElementById('session-list').innerHTML = sessions.slice(0, 50).map(
|
|
823
|
+
document.getElementById('session-list').innerHTML = sessions.slice(0, 50).map(renderSessionRow).join('');
|
|
824
|
+
attachSessionClickHandlers();
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function renderSessionRow(s) {
|
|
828
|
+
return (
|
|
727
829
|
'<div class="session-item" data-id="' + esc(s.id) + '">' +
|
|
728
830
|
'<div class="title">' + esc(s.model || 'unknown') + ' — ' + s.messageCount + ' messages</div>' +
|
|
729
831
|
'<div class="meta">' + new Date(s.createdAt).toLocaleString() + ' · ' + esc((s.workDir || '').split('/').pop()) + '</div>' +
|
|
730
832
|
'</div>'
|
|
731
|
-
)
|
|
732
|
-
|
|
833
|
+
);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function renderSessionSearchRow(r) {
|
|
837
|
+
const s = r.session || {};
|
|
838
|
+
const id = s.id || r.sessionId || '';
|
|
839
|
+
const model = s.model || 'unknown';
|
|
840
|
+
const score = Number.isFinite(r.score) ? r.score.toFixed(2) : '0.00';
|
|
841
|
+
return (
|
|
842
|
+
'<div class="session-item" data-id="' + esc(id) + '">' +
|
|
843
|
+
'<div class="title">' + esc(r.snippet || '(no snippet)') + '</div>' +
|
|
844
|
+
'<div class="meta">' + esc(model) + ' · ' + esc(id) + ' · score: ' + score + '</div>' +
|
|
845
|
+
'</div>'
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function clearSessionDetail() {
|
|
850
|
+
const detail = document.getElementById('session-detail');
|
|
851
|
+
detail.style.display = 'none';
|
|
852
|
+
detail.innerHTML = '';
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function attachSessionClickHandlers() {
|
|
856
|
+
document.querySelectorAll('.session-item[data-id]').forEach(el => {
|
|
733
857
|
el.addEventListener('click', async () => {
|
|
858
|
+
if (!el.dataset.id) return;
|
|
734
859
|
const history = await api('sessions/' + encodeURIComponent(el.dataset.id));
|
|
735
860
|
if (!history) return;
|
|
736
861
|
const detail = document.getElementById('session-detail');
|
|
737
862
|
detail.style.display = 'block';
|
|
738
863
|
detail.innerHTML = history.map(m => {
|
|
739
864
|
const role = m.role || 'system';
|
|
740
|
-
let text = typeof m.content === 'string' ? m.content : JSON.stringify(m.content).slice(0, 500);
|
|
865
|
+
let text = typeof m.content === 'string' ? m.content : JSON.stringify(m.content ?? null).slice(0, 500);
|
|
741
866
|
return '<div class="msg ' + role + '"><div class="role">' + role + '</div><pre>' + esc(text) + '</pre></div>';
|
|
742
867
|
}).join('');
|
|
868
|
+
detail.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
743
869
|
});
|
|
744
870
|
});
|
|
745
871
|
}
|
|
@@ -751,16 +877,13 @@ document.getElementById('session-search').addEventListener('input', (e) => {
|
|
|
751
877
|
const q = e.target.value.trim();
|
|
752
878
|
if (!q) { loadSessions(); return; }
|
|
753
879
|
const results = await api('sessions/search?q=' + encodeURIComponent(q));
|
|
880
|
+
clearSessionDetail();
|
|
754
881
|
if (!results || results.length === 0) {
|
|
755
882
|
document.getElementById('session-list').innerHTML = '<div class="empty">No results</div>';
|
|
756
883
|
return;
|
|
757
884
|
}
|
|
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('');
|
|
885
|
+
document.getElementById('session-list').innerHTML = results.map(renderSessionSearchRow).join('');
|
|
886
|
+
attachSessionClickHandlers();
|
|
764
887
|
}, 300);
|
|
765
888
|
});
|
|
766
889
|
|
|
@@ -878,15 +1001,24 @@ async function loadWallet() {
|
|
|
878
1001
|
solanaBtn.classList.toggle('active', w.chain === 'solana');
|
|
879
1002
|
}
|
|
880
1003
|
|
|
881
|
-
// QR via server — never leak address to third parties
|
|
1004
|
+
// QR via server — never leak address to third parties.
|
|
1005
|
+
// Encode chain + USDC token in the QR payload so wallet apps land
|
|
1006
|
+
// directly on the right network/token instead of a bare address:
|
|
1007
|
+
// Base → EIP-681: ethereum:<USDC>@8453/transfer?address=<addr>
|
|
1008
|
+
// Solana → Solana Pay: solana:<addr>?spl-token=<USDC mint>
|
|
882
1009
|
const qrBox = document.getElementById('wallet-qr');
|
|
883
1010
|
const hint = document.getElementById('wallet-qr-hint');
|
|
884
1011
|
if (addr && addr !== 'not set') {
|
|
885
|
-
const
|
|
1012
|
+
const USDC_BASE = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913';
|
|
1013
|
+
const USDC_SOL_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v';
|
|
1014
|
+
const payload = w.chain === 'solana'
|
|
1015
|
+
? 'solana:' + addr + '?spl-token=' + USDC_SOL_MINT
|
|
1016
|
+
: 'ethereum:' + USDC_BASE + '@8453/transfer?address=' + addr;
|
|
1017
|
+
const svg = await fetch('/api/wallet/qr?data=' + encodeURIComponent(payload)).then(r => r.ok ? r.text() : null);
|
|
886
1018
|
qrBox.innerHTML = svg || '';
|
|
887
1019
|
hint.textContent = w.chain === 'solana'
|
|
888
|
-
? 'Scan
|
|
889
|
-
: 'Scan to send USDC on Base
|
|
1020
|
+
? 'Scan with a Solana wallet (Phantom, Solflare) to send USDC SPL.'
|
|
1021
|
+
: 'Scan with an EVM wallet (MetaMask, Coinbase) to send USDC on Base.';
|
|
890
1022
|
} else {
|
|
891
1023
|
qrBox.innerHTML = '';
|
|
892
1024
|
hint.textContent = 'No wallet set yet — run: franklin setup';
|
|
@@ -1075,12 +1207,351 @@ async function loadAudit() {
|
|
|
1075
1207
|
document.getElementById('audit-refresh')?.addEventListener('click', loadAudit);
|
|
1076
1208
|
document.querySelector('[data-tab="audit"]')?.addEventListener('click', loadAudit);
|
|
1077
1209
|
|
|
1210
|
+
// ─── Tasks tab ───────────────────────────────────────────────────────────
|
|
1211
|
+
// Polls /api/tasks every 10s while the Tasks tab is active AND the page is
|
|
1212
|
+
// visible. Detail view layers a 2s log-tail poll using Range: bytes=N- on
|
|
1213
|
+
// top, stopping itself once the task hits a terminal status. No SSE — keeps
|
|
1214
|
+
// the panel server stateless and the wire format trivial.
|
|
1215
|
+
const TASK_TERMINAL = new Set(['succeeded', 'failed', 'timed_out', 'cancelled', 'lost']);
|
|
1216
|
+
const tasks = {
|
|
1217
|
+
pollTimer: null,
|
|
1218
|
+
logTimer: null,
|
|
1219
|
+
selected: null, // runId of currently-open detail
|
|
1220
|
+
logBytesShown: 0,
|
|
1221
|
+
cache: [], // last list snapshot (for finding the selected meta)
|
|
1222
|
+
finalLogFetched: false // ensures one final 200 fetch after task terminates
|
|
1223
|
+
};
|
|
1224
|
+
|
|
1225
|
+
function fmtAge(ts) {
|
|
1226
|
+
if (!ts) return '—';
|
|
1227
|
+
const s = Math.max(0, Math.round((Date.now() - ts) / 1000));
|
|
1228
|
+
if (s < 60) return s + 's ago';
|
|
1229
|
+
const m = Math.round(s / 60);
|
|
1230
|
+
if (m < 60) return m + 'm ago';
|
|
1231
|
+
const h = Math.round(m / 60);
|
|
1232
|
+
if (h < 48) return h + 'h ago';
|
|
1233
|
+
return Math.round(h / 24) + 'd ago';
|
|
1234
|
+
}
|
|
1235
|
+
function fmtTime(ts) {
|
|
1236
|
+
if (!ts) return '—';
|
|
1237
|
+
return new Date(ts).toLocaleString();
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
async function fetchTasks() {
|
|
1241
|
+
const data = await api('tasks');
|
|
1242
|
+
if (!data) {
|
|
1243
|
+
document.getElementById('tasks-list').innerHTML = '<div class="empty">API offline</div>';
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
const list = (data.tasks || []).slice().sort((a, b) => {
|
|
1247
|
+
const at = a.lastEventAt || a.createdAt || 0;
|
|
1248
|
+
const bt = b.lastEventAt || b.createdAt || 0;
|
|
1249
|
+
return bt - at;
|
|
1250
|
+
});
|
|
1251
|
+
tasks.cache = list;
|
|
1252
|
+
document.getElementById('tasks-summary').textContent = list.length + ' task' + (list.length === 1 ? '' : 's');
|
|
1253
|
+
if (list.length === 0) {
|
|
1254
|
+
document.getElementById('tasks-list').innerHTML =
|
|
1255
|
+
'<div class="empty">No tasks. Start one via the Detach agent tool, or manually with <code>franklin task ...</code>.</div>';
|
|
1256
|
+
} else {
|
|
1257
|
+
document.getElementById('tasks-list').innerHTML = list.map(renderTaskRow).join('');
|
|
1258
|
+
attachTaskRowHandlers();
|
|
1259
|
+
}
|
|
1260
|
+
// If a detail view is open, refresh its meta panel from the new snapshot
|
|
1261
|
+
if (tasks.selected) {
|
|
1262
|
+
const meta = list.find(t => t.runId === tasks.selected);
|
|
1263
|
+
if (meta) refreshTaskDetailMeta(meta);
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
function renderTaskRow(t) {
|
|
1268
|
+
const shortId = t.runId.slice(0, 12) + '…';
|
|
1269
|
+
const age = fmtAge(t.lastEventAt || t.createdAt);
|
|
1270
|
+
const showCancel = t.status === 'running' || t.status === 'queued';
|
|
1271
|
+
const cancelBtn = showCancel
|
|
1272
|
+
? '<button class="btn btn-warn" data-cancel="' + esc(t.runId) + '">Cancel</button>'
|
|
1273
|
+
: '';
|
|
1274
|
+
return (
|
|
1275
|
+
'<div class="task-row" data-runid="' + esc(t.runId) + '">' +
|
|
1276
|
+
'<span class="runid">' + esc(shortId) + '</span>' +
|
|
1277
|
+
'<span class="label">' + esc(t.label || '(no label)') + '</span>' +
|
|
1278
|
+
'<span><span class="task-status ' + esc(t.status) + '">' + esc(t.status) + '</span></span>' +
|
|
1279
|
+
'<span class="age">' + esc(age) + '</span>' +
|
|
1280
|
+
'<span class="actions">' + cancelBtn + '</span>' +
|
|
1281
|
+
'</div>'
|
|
1282
|
+
);
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
function attachTaskRowHandlers() {
|
|
1286
|
+
document.querySelectorAll('.task-row[data-runid]').forEach(el => {
|
|
1287
|
+
el.addEventListener('click', (ev) => {
|
|
1288
|
+
// Cancel button: handle and stop propagation so the row doesn't open detail
|
|
1289
|
+
const target = ev.target;
|
|
1290
|
+
if (target instanceof HTMLElement && target.dataset.cancel) {
|
|
1291
|
+
ev.stopPropagation();
|
|
1292
|
+
cancelTask(target.dataset.cancel, el);
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
openTaskDetail(el.dataset.runid);
|
|
1296
|
+
});
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
async function cancelTask(runId, rowEl) {
|
|
1301
|
+
if (!confirm('Cancel task ' + runId.slice(0, 12) + '…?\\n\\nFranklin will send SIGTERM to the running process.')) return;
|
|
1302
|
+
try {
|
|
1303
|
+
const r = await fetch('/api/tasks/' + encodeURIComponent(runId) + '/cancel', { method: 'POST' });
|
|
1304
|
+
const d = await r.json().catch(() => ({}));
|
|
1305
|
+
if (d && d.ok) {
|
|
1306
|
+
fetchTasks();
|
|
1307
|
+
} else {
|
|
1308
|
+
// Show inline error under the row
|
|
1309
|
+
const existing = rowEl && rowEl.querySelector('.cancel-err');
|
|
1310
|
+
if (existing) existing.remove();
|
|
1311
|
+
const err = document.createElement('div');
|
|
1312
|
+
err.className = 'cancel-err';
|
|
1313
|
+
err.textContent = 'Cancel failed: ' + (d && d.reason ? d.reason : 'unknown');
|
|
1314
|
+
if (rowEl) rowEl.appendChild(err);
|
|
1315
|
+
}
|
|
1316
|
+
} catch (err) {
|
|
1317
|
+
alert('Network error: ' + (err && err.message ? err.message : err));
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
async function openTaskDetail(runId) {
|
|
1322
|
+
tasks.selected = runId;
|
|
1323
|
+
tasks.logBytesShown = 0;
|
|
1324
|
+
tasks.finalLogFetched = false;
|
|
1325
|
+
const detail = document.getElementById('task-detail');
|
|
1326
|
+
detail.style.display = 'block';
|
|
1327
|
+
detail.innerHTML = '<div style="color:var(--text-dim);font-size:12px">Loading…</div>';
|
|
1328
|
+
detail.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
1329
|
+
|
|
1330
|
+
const meta = await api('tasks/' + encodeURIComponent(runId));
|
|
1331
|
+
if (!meta) {
|
|
1332
|
+
detail.innerHTML = '<div class="empty">Task not found</div>';
|
|
1333
|
+
return;
|
|
1334
|
+
}
|
|
1335
|
+
renderTaskDetail(meta);
|
|
1336
|
+
await Promise.all([loadTaskEvents(runId), pollTaskLog(runId, /*initial*/ true)]);
|
|
1337
|
+
// Start log polling cadence (only if not terminal and visible)
|
|
1338
|
+
startLogPolling();
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
function refreshTaskDetailMeta(meta) {
|
|
1342
|
+
// Update only the top metadata fields without resetting the log box.
|
|
1343
|
+
const top = document.getElementById('td-top');
|
|
1344
|
+
const metaBox = document.getElementById('td-meta');
|
|
1345
|
+
const cancelSlot = document.getElementById('td-cancel-slot');
|
|
1346
|
+
if (top) top.innerHTML = renderTaskDetailTop(meta);
|
|
1347
|
+
if (metaBox) metaBox.innerHTML = renderTaskDetailMetaRows(meta);
|
|
1348
|
+
if (cancelSlot) cancelSlot.innerHTML = renderTaskDetailCancelBtn(meta);
|
|
1349
|
+
attachTaskDetailButtonHandlers(meta);
|
|
1350
|
+
// If the task just hit terminal, surface the footer + stop polling
|
|
1351
|
+
if (TASK_TERMINAL.has(meta.status)) {
|
|
1352
|
+
const footer = document.getElementById('td-log-footer');
|
|
1353
|
+
if (footer && !footer.textContent) {
|
|
1354
|
+
footer.textContent = 'Final status: ' + meta.status + ' — log polling stopped.';
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
function renderTaskDetailTop(t) {
|
|
1360
|
+
return (
|
|
1361
|
+
'<span class="title">' + esc(t.label || '(no label)') + '</span>' +
|
|
1362
|
+
'<span class="task-status ' + esc(t.status) + '">' + esc(t.status) + '</span>'
|
|
1363
|
+
);
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
function renderTaskDetailMetaRows(t) {
|
|
1367
|
+
const rows = [
|
|
1368
|
+
['runId', t.runId],
|
|
1369
|
+
['command', t.command],
|
|
1370
|
+
['workingDir', t.workingDir],
|
|
1371
|
+
['pid', t.pid != null ? String(t.pid) : '—'],
|
|
1372
|
+
['createdAt', fmtTime(t.createdAt)],
|
|
1373
|
+
['startedAt', fmtTime(t.startedAt)],
|
|
1374
|
+
['lastEventAt', fmtTime(t.lastEventAt)],
|
|
1375
|
+
['endedAt', fmtTime(t.endedAt)],
|
|
1376
|
+
];
|
|
1377
|
+
if (t.exitCode !== undefined) rows.push(['exitCode', String(t.exitCode)]);
|
|
1378
|
+
if (t.terminalSummary) rows.push(['terminalSummary', t.terminalSummary]);
|
|
1379
|
+
if (t.error) rows.push(['error', t.error]);
|
|
1380
|
+
return rows.map(([k, v]) =>
|
|
1381
|
+
'<span class="k">' + esc(k) + '</span><span class="v">' + esc(v == null ? '—' : v) + '</span>'
|
|
1382
|
+
).join('');
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
function renderTaskDetailCancelBtn(t) {
|
|
1386
|
+
if (TASK_TERMINAL.has(t.status)) return '';
|
|
1387
|
+
return '<button class="btn btn-warn" id="td-cancel-btn" data-runid="' + esc(t.runId) + '">Cancel</button>';
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
function renderTaskDetail(t) {
|
|
1391
|
+
const detail = document.getElementById('task-detail');
|
|
1392
|
+
detail.innerHTML =
|
|
1393
|
+
'<div class="top" id="td-top">' + renderTaskDetailTop(t) + '</div>' +
|
|
1394
|
+
'<div class="task-detail-meta" id="td-meta">' + renderTaskDetailMetaRows(t) + '</div>' +
|
|
1395
|
+
'<h4>Recent events</h4>' +
|
|
1396
|
+
'<div class="task-events" id="td-events"><div style="color:var(--text-dim);font-size:11px">Loading…</div></div>' +
|
|
1397
|
+
'<h4>Log tail</h4>' +
|
|
1398
|
+
'<div class="task-log-footer" id="td-log-footer"></div>' +
|
|
1399
|
+
'<pre class="task-log" id="td-log"></pre>' +
|
|
1400
|
+
'<div class="task-detail-actions">' +
|
|
1401
|
+
'<span id="td-cancel-slot">' + renderTaskDetailCancelBtn(t) + '</span>' +
|
|
1402
|
+
'<button class="btn btn-ghost" id="td-close-btn">Close</button>' +
|
|
1403
|
+
'</div>';
|
|
1404
|
+
attachTaskDetailButtonHandlers(t);
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
function attachTaskDetailButtonHandlers(t) {
|
|
1408
|
+
const closeBtn = document.getElementById('td-close-btn');
|
|
1409
|
+
if (closeBtn) closeBtn.onclick = closeTaskDetail;
|
|
1410
|
+
const cancelBtn = document.getElementById('td-cancel-btn');
|
|
1411
|
+
if (cancelBtn) cancelBtn.onclick = () => cancelTask(t.runId, null);
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
function closeTaskDetail() {
|
|
1415
|
+
tasks.selected = null;
|
|
1416
|
+
stopLogPolling();
|
|
1417
|
+
const detail = document.getElementById('task-detail');
|
|
1418
|
+
detail.style.display = 'none';
|
|
1419
|
+
detail.innerHTML = '';
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
async function loadTaskEvents(runId) {
|
|
1423
|
+
const data = await api('tasks/' + encodeURIComponent(runId) + '/events');
|
|
1424
|
+
const box = document.getElementById('td-events');
|
|
1425
|
+
if (!box) return;
|
|
1426
|
+
const events = (data && data.events ? data.events : [])
|
|
1427
|
+
.slice()
|
|
1428
|
+
.sort((a, b) => (b.at || 0) - (a.at || 0))
|
|
1429
|
+
.slice(0, 10);
|
|
1430
|
+
if (events.length === 0) {
|
|
1431
|
+
box.innerHTML = '<div style="color:var(--text-dim);font-size:11px">No events recorded.</div>';
|
|
1432
|
+
return;
|
|
1433
|
+
}
|
|
1434
|
+
box.innerHTML = events.map(e =>
|
|
1435
|
+
'<div class="task-event">' +
|
|
1436
|
+
'<span class="kind">' + esc(e.kind) + '</span>' +
|
|
1437
|
+
'<span>' + esc(fmtTime(e.at)) + '</span>' +
|
|
1438
|
+
'<span>' + esc(e.summary || '') + '</span>' +
|
|
1439
|
+
'</div>'
|
|
1440
|
+
).join('');
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
async function pollTaskLog(runId, initial) {
|
|
1444
|
+
const logEl = document.getElementById('td-log');
|
|
1445
|
+
if (!logEl) return;
|
|
1446
|
+
try {
|
|
1447
|
+
const headers = (!initial && tasks.logBytesShown > 0)
|
|
1448
|
+
? { 'Range': 'bytes=' + tasks.logBytesShown + '-' }
|
|
1449
|
+
: {};
|
|
1450
|
+
const res = await fetch('/api/tasks/' + encodeURIComponent(runId) + '/log', { headers });
|
|
1451
|
+
if (res.status === 206) {
|
|
1452
|
+
const body = await res.text();
|
|
1453
|
+
if (body.length > 0) {
|
|
1454
|
+
logEl.textContent += body;
|
|
1455
|
+
tasks.logBytesShown += new Blob([body]).size;
|
|
1456
|
+
logEl.scrollTop = logEl.scrollHeight;
|
|
1457
|
+
}
|
|
1458
|
+
} else if (res.status === 200) {
|
|
1459
|
+
const body = await res.text();
|
|
1460
|
+
logEl.textContent = body;
|
|
1461
|
+
tasks.logBytesShown = new Blob([body]).size;
|
|
1462
|
+
logEl.scrollTop = logEl.scrollHeight;
|
|
1463
|
+
}
|
|
1464
|
+
} catch { /* network blip — next tick will retry */ }
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
function startLogPolling() {
|
|
1468
|
+
stopLogPolling();
|
|
1469
|
+
if (!tasks.selected) return;
|
|
1470
|
+
tasks.logTimer = setInterval(async () => {
|
|
1471
|
+
if (!tasks.selected) { stopLogPolling(); return; }
|
|
1472
|
+
if (document.visibilityState !== 'visible') return;
|
|
1473
|
+
const runId = tasks.selected;
|
|
1474
|
+
const meta = tasks.cache.find(t => t.runId === runId);
|
|
1475
|
+
const status = meta ? meta.status : 'running';
|
|
1476
|
+
if (TASK_TERMINAL.has(status)) {
|
|
1477
|
+
// One final 200 fetch to flush, then stop.
|
|
1478
|
+
if (!tasks.finalLogFetched) {
|
|
1479
|
+
tasks.finalLogFetched = true;
|
|
1480
|
+
await pollTaskLog(runId, /*initial*/ true);
|
|
1481
|
+
}
|
|
1482
|
+
const footer = document.getElementById('td-log-footer');
|
|
1483
|
+
if (footer && !footer.textContent) footer.textContent = 'Final status: ' + status + ' — log polling stopped.';
|
|
1484
|
+
stopLogPolling();
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
pollTaskLog(runId, /*initial*/ false);
|
|
1488
|
+
}, 2000);
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
function stopLogPolling() {
|
|
1492
|
+
if (tasks.logTimer) {
|
|
1493
|
+
clearInterval(tasks.logTimer);
|
|
1494
|
+
tasks.logTimer = null;
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
function startTasksPolling() {
|
|
1499
|
+
stopTasksPolling();
|
|
1500
|
+
fetchTasks();
|
|
1501
|
+
tasks.pollTimer = setInterval(() => {
|
|
1502
|
+
if (document.visibilityState === 'visible') fetchTasks();
|
|
1503
|
+
}, 10000);
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
function stopTasksPolling() {
|
|
1507
|
+
if (tasks.pollTimer) {
|
|
1508
|
+
clearInterval(tasks.pollTimer);
|
|
1509
|
+
tasks.pollTimer = null;
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
document.addEventListener('tab:activated', (e) => {
|
|
1514
|
+
if (e.detail && e.detail.name === 'tasks') {
|
|
1515
|
+
startTasksPolling();
|
|
1516
|
+
if (tasks.selected) startLogPolling();
|
|
1517
|
+
}
|
|
1518
|
+
});
|
|
1519
|
+
document.addEventListener('tab:deactivated', (e) => {
|
|
1520
|
+
if (e.detail && e.detail.name === 'tasks') {
|
|
1521
|
+
stopTasksPolling();
|
|
1522
|
+
stopLogPolling();
|
|
1523
|
+
}
|
|
1524
|
+
});
|
|
1525
|
+
document.addEventListener('visibilitychange', () => {
|
|
1526
|
+
const visible = document.visibilityState === 'visible';
|
|
1527
|
+
if (_activeTab === 'tasks') {
|
|
1528
|
+
if (visible) {
|
|
1529
|
+
startTasksPolling();
|
|
1530
|
+
if (tasks.selected) startLogPolling();
|
|
1531
|
+
} else {
|
|
1532
|
+
stopTasksPolling();
|
|
1533
|
+
stopLogPolling();
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
});
|
|
1537
|
+
|
|
1538
|
+
document.getElementById('tasks-refresh-btn')?.addEventListener('click', fetchTasks);
|
|
1539
|
+
|
|
1078
1540
|
loadOverview();
|
|
1079
1541
|
loadSessions();
|
|
1080
1542
|
loadMarkets();
|
|
1081
1543
|
loadLearnings();
|
|
1082
1544
|
loadWallet();
|
|
1083
1545
|
document.querySelector('[data-tab="markets"]')?.addEventListener('click', loadMarkets);
|
|
1546
|
+
|
|
1547
|
+
// Honor URL hash on initial load (e.g. /#tasks deep link)
|
|
1548
|
+
{
|
|
1549
|
+
const initialHash = (location.hash || '').replace(/^#/, '');
|
|
1550
|
+
if (initialHash && initialHash !== 'overview' && document.getElementById('tab-' + initialHash)) {
|
|
1551
|
+
activateTab(initialHash);
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1084
1555
|
setInterval(() => api('wallet').then(w => {
|
|
1085
1556
|
if (w) {
|
|
1086
1557
|
document.getElementById('balance').textContent = usdBig(w.balance) + ' USDC';
|