@danieltmn/tamnora 0.21.1 → 0.22.0
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/CHANGELOG.md +15 -0
- package/README.md +10 -6
- package/SECURITY.md +13 -0
- package/harness/packages/core/src/agent/runAgent.js +9 -5
- package/harness/packages/core/src/browser/index.js +56 -2
- package/harness/packages/core/src/hooks/index.js +9 -5
- package/harness/packages/core/src/index.js +1 -0
- package/harness/packages/core/src/mcp/stdio.js +15 -0
- package/harness/packages/core/src/net/hosts.js +181 -0
- package/harness/packages/core/src/plugins/index.js +6 -4
- package/harness/packages/core/src/redact.js +2 -0
- package/harness/packages/core/src/tools/executors.js +54 -53
- package/harness/packages/core/src/tools/index.js +1 -1
- package/harness/packages/core/src/tools/paths.js +35 -5
- package/harness/packages/core/src/tools/permissions.js +41 -2
- package/harness/packages/core/src/tools/registry.js +8 -3
- package/harness/packages/core/src/tunnel.js +15 -5
- package/harness/packages/core/test/hooks.test.js +6 -3
- package/harness/packages/core/test/hosts.test.js +50 -0
- package/harness/packages/core/test/plugins.test.js +5 -3
- package/harness/packages/core/test/tools.test.js +98 -2
- package/harness/packages/server/public/app.js +343 -6729
- package/harness/packages/server/public/chat.js +1893 -0
- package/harness/packages/server/public/chrome.js +812 -0
- package/harness/packages/server/public/commands.js +403 -0
- package/harness/packages/server/public/core.js +209 -0
- package/harness/packages/server/public/explorer.js +625 -0
- package/harness/packages/server/public/index.html +19 -19
- package/harness/packages/server/public/sessions.js +274 -0
- package/harness/packages/server/public/settings.js +1076 -0
- package/harness/packages/server/public/styles.css +29 -2
- package/harness/packages/server/public/system.js +495 -0
- package/harness/packages/server/public/voice.js +1058 -0
- package/harness/packages/server/src/auth.js +12 -10
- package/harness/packages/server/src/lib/agentRun.js +6 -1
- package/harness/packages/server/src/lib/channels/telegram.js +1 -0
- package/harness/packages/server/src/lib/presence.js +12 -1
- package/harness/packages/server/src/lib/procs.js +30 -3
- package/harness/packages/server/src/lib/scheduler.js +11 -2
- package/harness/packages/server/src/lib/tasks.js +20 -3
- package/harness/packages/server/src/lib/workspace.js +90 -2
- package/harness/packages/server/src/routes/auth.js +8 -4
- package/harness/packages/server/src/routes/chat.js +15 -4
- package/harness/packages/server/src/routes/preview.js +10 -2
- package/harness/packages/server/src/routes/procs.js +2 -6
- package/harness/packages/server/src/routes/providers.js +12 -9
- package/harness/packages/server/src/server.js +28 -2
- package/harness/packages/server/test/chat.test.js +586 -0
- package/harness/packages/server/test/helpers.js +107 -0
- package/harness/packages/server/test/ops.test.js +511 -0
- package/harness/packages/server/test/procs-manager.test.js +43 -0
- package/harness/packages/server/test/runs.test.js +816 -0
- package/harness/packages/server/test/security.test.js +112 -9
- package/harness/packages/server/test/sessions.test.js +437 -0
- package/harness/packages/server/test/settings.test.js +465 -0
- package/harness/tamnora.example.json +1 -1
- package/package.json +1 -1
- package/harness/packages/server/test/server.test.js +0 -2878
|
@@ -509,7 +509,14 @@ html, body {
|
|
|
509
509
|
font: 13.5px/1.5 var(--font);
|
|
510
510
|
}
|
|
511
511
|
|
|
512
|
-
body {
|
|
512
|
+
body {
|
|
513
|
+
display: flex;
|
|
514
|
+
flex-direction: column;
|
|
515
|
+
background: var(--page-bg);
|
|
516
|
+
/* Safe-area (notch / barra home en movil): reserva el inset para que no tape
|
|
517
|
+
el composer ni el final del chat. En desktop `env()` es 0. */
|
|
518
|
+
padding-bottom: env(safe-area-inset-bottom, 0px);
|
|
519
|
+
}
|
|
513
520
|
pre, code, kbd, samp { font-family: var(--mono); }
|
|
514
521
|
.muted { color: var(--muted); }
|
|
515
522
|
|
|
@@ -636,6 +643,12 @@ select, input, textarea {
|
|
|
636
643
|
}
|
|
637
644
|
select:focus, input:focus, textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 4px var(--accent-soft); }
|
|
638
645
|
|
|
646
|
+
/* Foco de teclado visible (los inputs tienen su estilo propio arriba). */
|
|
647
|
+
button:focus-visible, a:focus-visible, summary:focus-visible, [tabindex]:focus-visible {
|
|
648
|
+
outline: 2px solid var(--accent);
|
|
649
|
+
outline-offset: 2px;
|
|
650
|
+
}
|
|
651
|
+
|
|
639
652
|
/* layout ------------------------------------------------------------------ */
|
|
640
653
|
main { flex: 1; min-height: 0; min-width: 0; display: flex; }
|
|
641
654
|
.view { display: none; flex: 1; min-height: 0; min-width: 0; position: relative; }
|
|
@@ -2073,12 +2086,14 @@ dialog.file-modal.preview-maximized {
|
|
|
2073
2086
|
width: 100vw;
|
|
2074
2087
|
max-width: 100vw;
|
|
2075
2088
|
height: 100vh;
|
|
2089
|
+
height: 100dvh;
|
|
2076
2090
|
max-height: 100vh;
|
|
2091
|
+
max-height: 100dvh;
|
|
2077
2092
|
margin: 0;
|
|
2078
2093
|
border: 0;
|
|
2079
2094
|
border-radius: 0;
|
|
2080
2095
|
}
|
|
2081
|
-
.file-modal.preview-maximized .modal-card { height: 100vh; }
|
|
2096
|
+
.file-modal.preview-maximized .modal-card { height: 100vh; height: 100dvh; }
|
|
2082
2097
|
.file-modal.preview-maximized .file-stage,
|
|
2083
2098
|
.file-modal.preview-maximized .file-render,
|
|
2084
2099
|
.file-modal.preview-maximized .code-view,
|
|
@@ -2350,6 +2365,10 @@ dialog.file-modal.preview-maximized {
|
|
|
2350
2365
|
.messages {
|
|
2351
2366
|
padding: 12px 11px 18px;
|
|
2352
2367
|
}
|
|
2368
|
+
.confirm-actions .btn {
|
|
2369
|
+
flex: 1 1 auto;
|
|
2370
|
+
min-height: 40px;
|
|
2371
|
+
}
|
|
2353
2372
|
}
|
|
2354
2373
|
|
|
2355
2374
|
/* settings modal ---------------------------------------------------------- */
|
|
@@ -2424,6 +2443,7 @@ dialog.file-modal.preview-maximized {
|
|
|
2424
2443
|
dialog.modal {
|
|
2425
2444
|
width: min(760px, calc(100vw - 40px));
|
|
2426
2445
|
max-height: calc(100vh - 60px);
|
|
2446
|
+
max-height: calc(100dvh - 60px);
|
|
2427
2447
|
padding: 0;
|
|
2428
2448
|
border: 1px solid var(--border);
|
|
2429
2449
|
border-radius: 18px;
|
|
@@ -2436,6 +2456,9 @@ dialog.modal {
|
|
|
2436
2456
|
dialog.modal::backdrop { background: rgba(24, 22, 16, 0.55); backdrop-filter: blur(3px); }
|
|
2437
2457
|
.modal-card { display: flex; flex-direction: column; gap: 12px; padding: 20px; }
|
|
2438
2458
|
.modal-card h3 { margin: 0; font: 700 17px var(--serif); letter-spacing: -0.02em; }
|
|
2459
|
+
.confirm-modal .modal-card { max-width: min(420px, 92vw); }
|
|
2460
|
+
.confirm-text { margin: 0; color: var(--muted, inherit); line-height: 1.5; }
|
|
2461
|
+
.confirm-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
|
2439
2462
|
.row .spacer { flex: 1; }
|
|
2440
2463
|
|
|
2441
2464
|
/* update banner ----------------------------------------------------------- */
|
|
@@ -2505,6 +2528,10 @@ dialog.modal::backdrop { background: rgba(24, 22, 16, 0.55); backdrop-filter: bl
|
|
|
2505
2528
|
}
|
|
2506
2529
|
.compact-rule::before,
|
|
2507
2530
|
.compact-rule::after { content: ''; flex: 1; height: 1px; background: var(--border); }
|
|
2531
|
+
.compact-card.running .compact-rule { color: var(--accent); }
|
|
2532
|
+
.compact-card.running .compact-rule::before,
|
|
2533
|
+
.compact-card.running .compact-rule::after { background: var(--accent-soft); }
|
|
2534
|
+
.compact-rule .loader-thinking { width: 22px; height: 6px; }
|
|
2508
2535
|
.compact-text {
|
|
2509
2536
|
margin-top: 10px;
|
|
2510
2537
|
padding-left: 12px;
|
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
import { livePermission, reloadPane, renderSessions, resolvePermissionCard, sendPresence } from './chat.js';
|
|
2
|
+
import { providerSelect, setSummaryText, yesInput } from './chrome.js';
|
|
3
|
+
import { toast } from './commands.js';
|
|
4
|
+
import { $, api, clear, el, openStream, showConfirm, state, storage, streamPost } from './core.js';
|
|
5
|
+
import { diffState, localState, pollDelayMs } from './reconcile.js';
|
|
6
|
+
import { followRun } from './runStream.js';
|
|
7
|
+
import { closeTab, renderTabs, showStandalonePermission } from './sessions.js';
|
|
8
|
+
|
|
9
|
+
/* --- eval ---------------------------------------------------------------- */
|
|
10
|
+
|
|
11
|
+
export const evalTasks = $('#eval-tasks');
|
|
12
|
+
export const evalResults = $('#eval-results');
|
|
13
|
+
export const evalSummary = $('#eval-summary');
|
|
14
|
+
export const evalRunBtn = $('#eval-run');
|
|
15
|
+
|
|
16
|
+
export function parseTasks(text) {
|
|
17
|
+
const trimmed = text.trim();
|
|
18
|
+
if (!trimmed) return [];
|
|
19
|
+
try {
|
|
20
|
+
const parsed = JSON.parse(trimmed);
|
|
21
|
+
return Array.isArray(parsed) ? parsed : [parsed];
|
|
22
|
+
} catch {
|
|
23
|
+
// fall through to JSONL
|
|
24
|
+
}
|
|
25
|
+
return trimmed
|
|
26
|
+
.split('\n')
|
|
27
|
+
.map((l) => l.trim())
|
|
28
|
+
.filter(Boolean)
|
|
29
|
+
.map((l) => JSON.parse(l));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function setEvalSummary(text, cls = '') {
|
|
33
|
+
setSummaryText(evalSummary, text, cls);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function detailOf(entry) {
|
|
37
|
+
if (entry.error) return entry.error;
|
|
38
|
+
return (entry.trace?.scorers ?? []).map((s) => `${s.scorer}:${s.passed ? 'ok' : 'no'}`).join(' ');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function addResultRow(tbody, entry) {
|
|
42
|
+
const tr = el('tr');
|
|
43
|
+
const pass = entry.passed;
|
|
44
|
+
tr.append(
|
|
45
|
+
el('td', pass ? 'pass' : 'fail', pass ? '✓' : '·'),
|
|
46
|
+
el('td', '', entry.taskId ?? '—'),
|
|
47
|
+
el('td', '', entry.model ?? '—'),
|
|
48
|
+
el('td', '', entry.score === undefined || entry.score === null ? '' : String(entry.score)),
|
|
49
|
+
el('td', '', entry.durationMs === undefined ? '' : String(entry.durationMs)),
|
|
50
|
+
el('td', 'detail', detailOf(entry))
|
|
51
|
+
);
|
|
52
|
+
tbody.appendChild(tr);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function runEval() {
|
|
56
|
+
let tasks;
|
|
57
|
+
try {
|
|
58
|
+
tasks = parseTasks(evalTasks.value);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
return setEvalSummary(`Dataset inválido: ${error.message}`, 'err');
|
|
61
|
+
}
|
|
62
|
+
const models = $('#eval-models')
|
|
63
|
+
.value.split(',')
|
|
64
|
+
.map((s) => s.trim())
|
|
65
|
+
.filter(Boolean);
|
|
66
|
+
if (!tasks.length) return setEvalSummary('Ingresá al menos una tarea.', 'err');
|
|
67
|
+
if (!models.length) return setEvalSummary('Ingresá al menos un modelo.', 'err');
|
|
68
|
+
|
|
69
|
+
clear(evalResults);
|
|
70
|
+
evalRunBtn.disabled = true;
|
|
71
|
+
setEvalSummary('corriendo…');
|
|
72
|
+
try {
|
|
73
|
+
await streamPost(
|
|
74
|
+
'/api/eval',
|
|
75
|
+
{
|
|
76
|
+
tasks,
|
|
77
|
+
models,
|
|
78
|
+
provider: providerSelect.value || undefined,
|
|
79
|
+
trials: Number($('#eval-trials').value) || 1,
|
|
80
|
+
concurrency: Number($('#eval-concurrency').value) || 1,
|
|
81
|
+
judgeModel: $('#eval-judge').value.trim() || undefined,
|
|
82
|
+
yes: yesInput.checked ? true : undefined,
|
|
83
|
+
},
|
|
84
|
+
(event, data) => {
|
|
85
|
+
if (event === 'run') setEvalSummary(`Run ${data.runId} · ${data.total} ejecuciones`);
|
|
86
|
+
else if (event === 'result') addResultRow(evalResults, data);
|
|
87
|
+
else if (event === 'summary') {
|
|
88
|
+
const s = data.summary;
|
|
89
|
+
setEvalSummary(`total ${s.total} · ${s.passed} ok · ${s.errors} errores · ${(s.durationMs / 1000).toFixed(1)}s`);
|
|
90
|
+
} else if (event === 'error') setEvalSummary(`Error: ${data.message}`, 'err');
|
|
91
|
+
}
|
|
92
|
+
);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
setEvalSummary(`Error: ${error.message}`, 'err');
|
|
95
|
+
} finally {
|
|
96
|
+
evalRunBtn.disabled = false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/* --- runs ---------------------------------------------------------------- */
|
|
101
|
+
|
|
102
|
+
export const runsList = $('#runs-list');
|
|
103
|
+
export const runDetail = $('#run-detail');
|
|
104
|
+
|
|
105
|
+
export async function refreshRuns() {
|
|
106
|
+
const data = await api('/api/runs').catch(() => ({ runs: [] }));
|
|
107
|
+
clear(runsList);
|
|
108
|
+
clear(runDetail);
|
|
109
|
+
for (const run of data.runs) {
|
|
110
|
+
const li = el('li');
|
|
111
|
+
li.append(el('span', '', run.dataset || '(inline)'), el('span', 'muted', `${run.status} · ${new Date(run.created_at).toLocaleString()}`));
|
|
112
|
+
li.addEventListener('click', () => {
|
|
113
|
+
runsList.querySelectorAll('li').forEach((n) => n.classList.remove('active'));
|
|
114
|
+
li.classList.add('active');
|
|
115
|
+
openRun(run.id);
|
|
116
|
+
});
|
|
117
|
+
runsList.appendChild(li);
|
|
118
|
+
}
|
|
119
|
+
if (!data.runs.length) runsList.appendChild(el('li', 'muted', 'Sin runs.'));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function openRun(id) {
|
|
123
|
+
const data = await api(`/api/runs/${id}`);
|
|
124
|
+
clear(runDetail);
|
|
125
|
+
const summary = data.run.summary ?? {};
|
|
126
|
+
runDetail.appendChild(el('p', 'muted', `total ${summary.total ?? 0} · ${summary.passed ?? 0} ok · ${summary.errors ?? 0} errores`));
|
|
127
|
+
const table = el('table', 'results');
|
|
128
|
+
const thead = el('thead');
|
|
129
|
+
const head = el('tr');
|
|
130
|
+
for (const label of ['ok', 'tarea', 'modelo', 'score', 'ms', 'detalle']) head.appendChild(el('th', '', label));
|
|
131
|
+
thead.appendChild(head);
|
|
132
|
+
const tbody = el('tbody');
|
|
133
|
+
table.append(thead, tbody);
|
|
134
|
+
runDetail.appendChild(table);
|
|
135
|
+
for (const row of data.results) {
|
|
136
|
+
addResultRow(tbody, {
|
|
137
|
+
taskId: row.task_id,
|
|
138
|
+
model: row.model,
|
|
139
|
+
passed: Boolean(row.passed),
|
|
140
|
+
score: row.score,
|
|
141
|
+
durationMs: row.duration_ms,
|
|
142
|
+
trace: row.trace,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/* --- reconciliacion: la verdad no depende del canal ------------------------ */
|
|
148
|
+
|
|
149
|
+
// El canal global lleva el heartbeat de 15 s del server, asi que 30 s son tres
|
|
150
|
+
// pings: menos espera cuando lo que se pierde tiempo es que aparezca un permiso
|
|
151
|
+
// que te esta esperando.
|
|
152
|
+
export const CHANNEL_SILENCE_MS = 30000;
|
|
153
|
+
|
|
154
|
+
export let lastState = null;
|
|
155
|
+
export let reconciling = false;
|
|
156
|
+
export let reconcileTimer = null;
|
|
157
|
+
export let syncStale = false;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Un solo lugar decide que hacer con el estado remoto.
|
|
161
|
+
*
|
|
162
|
+
* Antes esto estaba repartido entre el canal global y un sweep: dos caminos que
|
|
163
|
+
* se contradijian, lo que hacia que un SSE caido se tradujera en "no me entero
|
|
164
|
+
* de nada". La logica de diff vive en `reconcile.js`, que es puro y testeado.
|
|
165
|
+
*
|
|
166
|
+
* La verdad es `/api/state`: un request corto. Los SSE son atajos de latencia.
|
|
167
|
+
*/
|
|
168
|
+
export async function reconcile({ immediate = false } = {}) {
|
|
169
|
+
if (reconciling) {
|
|
170
|
+
// Si hay una en curso, encadenamos otra: si no, una mutacion que cae justo
|
|
171
|
+
// en medio se pierde.
|
|
172
|
+
if (immediate) state.reconcileAgain = true;
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
reconciling = true;
|
|
176
|
+
try {
|
|
177
|
+
const remote = await api('/api/state');
|
|
178
|
+
syncStale = false;
|
|
179
|
+
renderSyncState();
|
|
180
|
+
applyState(diffState(localState({ panes: state.panes, asks: state.pendingAsks.keys() }), remote));
|
|
181
|
+
lastState = remote;
|
|
182
|
+
scheduleReconcile();
|
|
183
|
+
} catch (error) {
|
|
184
|
+
// Esto antes era un `.catch(() => null)` silencioso: un fallo de red se via
|
|
185
|
+
// exactamente igual que "no hay nada pendiente", y por eso no se podia
|
|
186
|
+
// diagnosticar nada.
|
|
187
|
+
syncStale = true;
|
|
188
|
+
renderSyncState(error);
|
|
189
|
+
scheduleReconcile();
|
|
190
|
+
} finally {
|
|
191
|
+
reconciling = false;
|
|
192
|
+
if (state.reconcileAgain) {
|
|
193
|
+
state.reconcileAgain = false;
|
|
194
|
+
reconcile();
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function scheduleReconcile() {
|
|
200
|
+
if (reconcileTimer) clearTimeout(reconcileTimer);
|
|
201
|
+
// Con un permiso pendiente hay algo esperandonos: la latencia se nota.
|
|
202
|
+
reconcileTimer = setTimeout(() => reconcile(), pollDelayMs(lastState));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function applyState(diff) {
|
|
206
|
+
for (const id of diff.gone) closeTab(id);
|
|
207
|
+
for (const id of diff.toDetach) reloadPane(id);
|
|
208
|
+
for (const id of diff.toAttach) reloadPane(id);
|
|
209
|
+
for (const askId of diff.resolvedAsks) {
|
|
210
|
+
state.pendingAsks.delete(askId);
|
|
211
|
+
// `null` = alguien lo resolvio en otro lado: la tarjeta ya se cerro alla y
|
|
212
|
+
// solo hay que apagarla.
|
|
213
|
+
resolvePermissionCard(state.permissionCards.get(askId), null);
|
|
214
|
+
}
|
|
215
|
+
for (const ask of diff.newAsks) {
|
|
216
|
+
if (state.pendingAsks.has(ask.askId)) continue;
|
|
217
|
+
state.pendingAsks.set(ask.askId, ask);
|
|
218
|
+
toast(`Permiso pendiente: ${ask.name || 'una tool'}${state.panes.has(ask.sessionId) ? '' : ' en otra sesión'}.`);
|
|
219
|
+
}
|
|
220
|
+
// Los permisos pendientes se pintan siempre, sin importar por donde llegaron:
|
|
221
|
+
// asi tambien se ven cuando el canal estaba caido y llego por el snapshot.
|
|
222
|
+
for (const [askId, ask] of state.pendingAsks) {
|
|
223
|
+
const record = state.panes.get(ask.sessionId);
|
|
224
|
+
if (record && !livePermission(askId)) showStandalonePermission(record, ask);
|
|
225
|
+
}
|
|
226
|
+
state.remoteActive = new Set(diff.remoteActive);
|
|
227
|
+
renderSessions(diff.sessions);
|
|
228
|
+
renderTabs();
|
|
229
|
+
renderAlertSignals();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/* --- señales de alerta: que se note aun mirando otra cosa ----------------- */
|
|
233
|
+
|
|
234
|
+
export const FAVICON_IDLE = '/favicon.ico';
|
|
235
|
+
export const FAVICON_SOURCE = '/icons/icon-192x192.png';
|
|
236
|
+
export let faviconAlert = '';
|
|
237
|
+
export let faviconAlertPending = null;
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* El favicon de alerta se dibuja sobre una copia del logo (mismo origen), para
|
|
241
|
+
* que el punto rojo combine con el icono real y no con un SVG desincronizado.
|
|
242
|
+
*/
|
|
243
|
+
export function alertFavicon() {
|
|
244
|
+
if (faviconAlert) return Promise.resolve(faviconAlert);
|
|
245
|
+
if (faviconAlertPending) return faviconAlertPending;
|
|
246
|
+
faviconAlertPending = new Promise((resolve) => {
|
|
247
|
+
const img = new Image();
|
|
248
|
+
img.onload = () => {
|
|
249
|
+
try {
|
|
250
|
+
const canvas = document.createElement('canvas');
|
|
251
|
+
canvas.width = 64;
|
|
252
|
+
canvas.height = 64;
|
|
253
|
+
const ctx = canvas.getContext('2d');
|
|
254
|
+
ctx.drawImage(img, 0, 0, 64, 64);
|
|
255
|
+
ctx.beginPath();
|
|
256
|
+
ctx.arc(64 - 16, 16, 13, 0, Math.PI * 2);
|
|
257
|
+
ctx.fillStyle = '#d9704a';
|
|
258
|
+
ctx.fill();
|
|
259
|
+
ctx.lineWidth = 3;
|
|
260
|
+
ctx.strokeStyle = 'rgba(35,26,23,0.9)';
|
|
261
|
+
ctx.stroke();
|
|
262
|
+
faviconAlert = canvas.toDataURL('image/png');
|
|
263
|
+
resolve(faviconAlert);
|
|
264
|
+
} catch {
|
|
265
|
+
resolve(FAVICON_IDLE);
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
img.onerror = () => resolve(FAVICON_IDLE);
|
|
269
|
+
img.src = FAVICON_SOURCE;
|
|
270
|
+
});
|
|
271
|
+
return faviconAlertPending;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export let baseTitle = '';
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Cuando iOS manda la pestana a background mata los timers y los SSE. El
|
|
278
|
+
* titulo y el favicon son las **unicas** señales que quedan, asi que los dos
|
|
279
|
+
* tienen que marcar que hay un permiso esperando.
|
|
280
|
+
*/
|
|
281
|
+
export function renderAlertSignals() {
|
|
282
|
+
const pending = state.pendingAsks.size + state.pendingQuestions.size;
|
|
283
|
+
if (!baseTitle) baseTitle = document.title;
|
|
284
|
+
const clean = baseTitle.replace(/^\(⚠ \d+\) /, '');
|
|
285
|
+
document.title = pending > 0 ? `(⚠ ${pending}) ${clean}` : clean;
|
|
286
|
+
const link = document.querySelector('link[rel="icon"]');
|
|
287
|
+
if (link) {
|
|
288
|
+
if (pending > 0) alertFavicon().then((href) => { link.href = href; });
|
|
289
|
+
else link.href = FAVICON_IDLE;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/* --- canal global: solo latencia ------------------------------------------ */
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Un SSE por pestana para enterarse al instante. **No decide nada**: dispara
|
|
297
|
+
* `reconcile()`. Si se cae -que es lo normal en un tunel, y lo que hace iOS al
|
|
298
|
+
* mandar la pestana a background- solo perdemos inmediatez, no correctitud.
|
|
299
|
+
*/
|
|
300
|
+
export function handleGlobalEvent(event) {
|
|
301
|
+
// No decide nada: solo pide el estado fresco. El diff es el unico que sabe
|
|
302
|
+
// que hay que hacer.
|
|
303
|
+
if (event === 'run_start' || event === 'run_end' || event === 'permission' || event === 'permission_resolved' || event === 'snapshot') {
|
|
304
|
+
reconcile({ immediate: true });
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export let eventsController = null;
|
|
309
|
+
export let channelState = 'off';
|
|
310
|
+
export let channelReason = '';
|
|
311
|
+
|
|
312
|
+
export function renderChannelState() {
|
|
313
|
+
const node = $('#sb-channel');
|
|
314
|
+
if (!node) return;
|
|
315
|
+
if (channelState === 'live') {
|
|
316
|
+
node.hidden = true;
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
node.hidden = false;
|
|
320
|
+
node.className = `sb-channel ${channelState}`;
|
|
321
|
+
node.textContent = channelState === 'off' ? 'sin canal' : 'reconectando…';
|
|
322
|
+
node.title = channelReason
|
|
323
|
+
? `Canal de eventos caido (${channelReason}). Los cambios llegan igual por sondeo cada ${Math.round(pollDelayMs(lastState) / 1000)} s, pero con retardo.`
|
|
324
|
+
: 'Canal de eventos: sin novedades instantaneas.';
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** El sondeo de /api/state tambien puede caerse; eso si hay que verlo. */
|
|
328
|
+
export function renderSyncState(error) {
|
|
329
|
+
const node = $('#sb-sync');
|
|
330
|
+
if (!node) return;
|
|
331
|
+
node.hidden = !syncStale;
|
|
332
|
+
node.className = 'sb-channel retrying';
|
|
333
|
+
node.textContent = 'sin sincronía';
|
|
334
|
+
node.title = error
|
|
335
|
+
? `No pude leer el estado del server: ${error.message}. Reintentando cada ${Math.round(pollDelayMs(lastState) / 1000)} s.`
|
|
336
|
+
: 'No pude leer el estado del server. Reintentando.';
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export async function followEvents() {
|
|
340
|
+
if (eventsController && !eventsController.signal.aborted) return;
|
|
341
|
+
const controller = new AbortController();
|
|
342
|
+
eventsController = controller;
|
|
343
|
+
channelState = 'waiting';
|
|
344
|
+
channelReason = '';
|
|
345
|
+
renderChannelState();
|
|
346
|
+
await followRun({
|
|
347
|
+
signal: controller.signal,
|
|
348
|
+
attempts: Infinity,
|
|
349
|
+
backoff: [1000, 2000, 5000, 10000, 15000],
|
|
350
|
+
onRetry: ({ attempt, error }) => {
|
|
351
|
+
channelState = 'retrying';
|
|
352
|
+
channelReason = error?.message ? `${error.message} (intento ${attempt})` : `sin datos (intento ${attempt})`;
|
|
353
|
+
renderChannelState();
|
|
354
|
+
},
|
|
355
|
+
open: (cursor, signal) =>
|
|
356
|
+
openStream('/api/events', { signal, silenceMs: CHANNEL_SILENCE_MS }, (event, data) => {
|
|
357
|
+
channelState = 'live';
|
|
358
|
+
channelReason = '';
|
|
359
|
+
renderChannelState();
|
|
360
|
+
handleGlobalEvent(event, data);
|
|
361
|
+
}),
|
|
362
|
+
})
|
|
363
|
+
.catch((error) => {
|
|
364
|
+
// Un AbortError es que estamos cerrando. Cualquier otra cosa es un bug de
|
|
365
|
+
// la consola: se avisa en vez de dejar un "reconectando" que no dice nada.
|
|
366
|
+
if (error?.name === 'AbortError') return;
|
|
367
|
+
channelState = 'off';
|
|
368
|
+
channelReason = error?.message ?? String(error);
|
|
369
|
+
renderChannelState();
|
|
370
|
+
toast(`Canal de eventos roto: ${error?.message ?? error}`);
|
|
371
|
+
})
|
|
372
|
+
.finally(() => {
|
|
373
|
+
if (eventsController === controller) {
|
|
374
|
+
channelState = 'off';
|
|
375
|
+
renderChannelState();
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/* --- ciclo de vida --------------------------------------------------------- */
|
|
381
|
+
|
|
382
|
+
export function watchLifecycle() {
|
|
383
|
+
// Volver a `visible` es el momento de reconciliar: iOS congela los timers en
|
|
384
|
+
// background, asi que esperar al proximo tick puede ser Mucho tiempo.
|
|
385
|
+
const wake = () => {
|
|
386
|
+
reconcile({ immediate: true });
|
|
387
|
+
sendPresence();
|
|
388
|
+
if (!eventsController || eventsController.signal.aborted) followEvents();
|
|
389
|
+
};
|
|
390
|
+
document.addEventListener('visibilitychange', () => {
|
|
391
|
+
if (document.visibilityState === 'visible') wake();
|
|
392
|
+
});
|
|
393
|
+
window.addEventListener('pageshow', wake);
|
|
394
|
+
window.addEventListener('online', wake);
|
|
395
|
+
window.addEventListener('focus', wake);
|
|
396
|
+
// La cache del server dura 10 min: revisar al volver a la pestaña es barato.
|
|
397
|
+
refreshUpdate();
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/* --- actualizacion de version --------------------------------------------- */
|
|
401
|
+
|
|
402
|
+
export const UPDATE_DISMISS_KEY = 'tamnora-update-dismissed';
|
|
403
|
+
export const updateBanner = $('#update-banner');
|
|
404
|
+
export const updateBannerText = $('#update-banner-text');
|
|
405
|
+
export let updateInfo = null;
|
|
406
|
+
|
|
407
|
+
export function showUpdateBanner(info, { force = false } = {}) {
|
|
408
|
+
if (!updateBanner || !info?.updateAvailable) return;
|
|
409
|
+
if (!force && storage.get(UPDATE_DISMISS_KEY) === String(info.latest)) return;
|
|
410
|
+
updateInfo = info;
|
|
411
|
+
updateBannerText.textContent = `Hay una versión nueva (v${info.latest}); tenés la v${info.current}.`;
|
|
412
|
+
updateBanner.hidden = false;
|
|
413
|
+
const ver = document.querySelector('.brand .ver');
|
|
414
|
+
if (ver) {
|
|
415
|
+
ver.classList.add('has-update');
|
|
416
|
+
ver.title = `Actualización disponible: v${info.latest}`;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export async function refreshUpdate() {
|
|
421
|
+
if (!updateBanner) return;
|
|
422
|
+
const info = await api('/api/update').catch(() => null);
|
|
423
|
+
if (info?.updateAvailable) showUpdateBanner(info);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export function dismissUpdate() {
|
|
427
|
+
if (updateInfo?.latest) storage.set(UPDATE_DISMISS_KEY, String(updateInfo.latest));
|
|
428
|
+
if (updateBanner) updateBanner.hidden = true;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Chequeo manual (click en la version): fuerza la consulta y avisa el resultado. */
|
|
432
|
+
export async function forceUpdateCheck() {
|
|
433
|
+
const ver = document.querySelector('.brand .ver');
|
|
434
|
+
const original = ver?.textContent;
|
|
435
|
+
if (ver) ver.textContent = '…';
|
|
436
|
+
const info = await api('/api/update?force=1').catch(() => null);
|
|
437
|
+
if (ver) ver.textContent = info?.current ? `v${info.current}` : original;
|
|
438
|
+
if (!info) return toast('No pude consultar la versión.');
|
|
439
|
+
if (info.updateAvailable) {
|
|
440
|
+
showUpdateBanner(info, { force: true });
|
|
441
|
+
toast(`Hay una versión nueva: v${info.latest}`);
|
|
442
|
+
} else if (info.error) {
|
|
443
|
+
toast(`No pude consultar npm: ${info.error}`);
|
|
444
|
+
} else {
|
|
445
|
+
toast(`Estás al día (v${info.current}).`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Espera a que el server reiniciado vuelva a responder `/api/health`. */
|
|
450
|
+
export async function waitServerVersion(tries = 45) {
|
|
451
|
+
for (let i = 0; i < tries; i++) {
|
|
452
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
453
|
+
const health = await fetch('/api/health', { cache: 'no-store' })
|
|
454
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
455
|
+
.catch(() => null);
|
|
456
|
+
if (health?.version) return health.version;
|
|
457
|
+
}
|
|
458
|
+
return '';
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
export async function runUpdate() {
|
|
462
|
+
if (!updateInfo) return;
|
|
463
|
+
if (
|
|
464
|
+
!(await showConfirm(`¿Actualizar a la v${updateInfo.latest}? La consola se reinicia sola.`, {
|
|
465
|
+
title: 'Actualizar Tamnora',
|
|
466
|
+
confirmLabel: 'actualizar',
|
|
467
|
+
}))
|
|
468
|
+
) {
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
const runBtn = $('#update-run');
|
|
472
|
+
const laterBtn = $('#update-later');
|
|
473
|
+
if (runBtn) runBtn.disabled = true;
|
|
474
|
+
if (laterBtn) laterBtn.disabled = true;
|
|
475
|
+
updateBannerText.textContent = `Actualizando a v${updateInfo.latest}…`;
|
|
476
|
+
const res = await api('/api/update', { method: 'POST' }).catch((error) => ({ error: error.message }));
|
|
477
|
+
if (res?.error) {
|
|
478
|
+
updateBannerText.textContent = `No se pudo actualizar: ${res.error}`;
|
|
479
|
+
if (runBtn) runBtn.disabled = false;
|
|
480
|
+
if (laterBtn) laterBtn.disabled = false;
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
updateBannerText.textContent = 'Instalando la versión nueva… la consola se va a reconectar.';
|
|
484
|
+
const version = await waitServerVersion();
|
|
485
|
+
if (version) {
|
|
486
|
+
updateBannerText.textContent = `Actualizado a v${version}. Recargando…`;
|
|
487
|
+
setTimeout(() => location.reload(), 900);
|
|
488
|
+
} else {
|
|
489
|
+
updateBannerText.textContent = 'No pude confirmar el reinicio. Recargá la página en unos segundos.';
|
|
490
|
+
if (runBtn) runBtn.disabled = false;
|
|
491
|
+
if (laterBtn) laterBtn.disabled = false;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
export const brandVerEl = document.querySelector('.brand .ver');
|