lampson 0.2.4 → 0.2.5
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/.env.example +1 -1
- package/README.md +58 -445
- package/chat.syn +139 -73
- package/hub.tpl.syn +18 -4
- package/lib/provider.syn +7 -2
- package/lib/workspaces.syn +70 -6
- package/package.json +1 -1
- package/public/css/chat.css +7 -0
- package/public/css/hub.css +4 -0
- package/public/css/panel.css +4 -0
- package/public/css/tokens.css +2 -2
- package/public/favicon.svg +13 -0
- package/public/hub.html +1 -0
- package/public/index.html +1 -0
- package/public/js/events.js +31 -3
- package/public/js/hub.js +9 -1
- package/public/js/panel.js +2 -0
- package/public/js/workspaces.js +11 -7
- package/web.syn +2 -1
package/public/js/events.js
CHANGED
|
@@ -2,10 +2,32 @@
|
|
|
2
2
|
// Los supervisores de procesos publican cada línea y cambio de estado; los subagentes, inicio/fin/pasos; las
|
|
3
3
|
// tareas programadas y aprobaciones, sus cambios. La UI reacciona a eso (con un pequeño debounce) en vez de
|
|
4
4
|
// recargar por timer; los timers quedan como respaldo lento por si el stream se cae (reconexión con backoff).
|
|
5
|
-
let events = null, evBackoff = 1000;
|
|
5
|
+
let events = null, evBackoff = 1000, evOffline = false, evEverOpen = false;
|
|
6
|
+
// El hub responde aunque el workspace esté apagado: por ahí se pregunta "¿sigue vivo?" sin llenar la consola de 502.
|
|
7
|
+
const EV_HUB = (location.port && location.port !== '8080') ? location.protocol + '//' + location.hostname + ':8080' : '';
|
|
8
|
+
async function wsAlive() {
|
|
9
|
+
if (!WS_SLUG) return true;
|
|
10
|
+
try { const r = await fetch(EV_HUB + '/api/workspaces'); const d = await r.json(); const w = (d.workspaces || []).find(x => x.slug === WS_SLUG); return w ? { alive: w.alive, paused: w.paused } : { alive: true }; } catch (e) { return { alive: true }; }
|
|
11
|
+
}
|
|
12
|
+
function goOffline(state) {
|
|
13
|
+
if (evOffline) return;
|
|
14
|
+
evOffline = true;
|
|
15
|
+
// apagado a mano (desde el hub o desde este mismo panel): volver al inicio; el banner queda para caídas
|
|
16
|
+
if (state.paused) { location.href = EV_HUB + '/'; return; }
|
|
17
|
+
document.title = '○ ' + document.title.replace(/^○ /, '');
|
|
18
|
+
const why = state.paused ? 'Lo apagaste desde el hub.' : 'El proceso no responde.';
|
|
19
|
+
const m = add('meta wsoff', `○ <b>Este workspace está apagado.</b> ${why} <a href="#" class="wson">▶ encender</a> · <a href="${EV_HUB}/">ir al hub</a> <span class="wsst"></span>`);
|
|
20
|
+
m.querySelector('.wson').onclick = async (e) => { e.preventDefault(); m.querySelector('.wsst').textContent = 'encendiendo…'; try { await fetch(EV_HUB + '/api/workspaces/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ slug: WS_SLUG }) }); } catch (err) {} };
|
|
21
|
+
const poll = async () => {
|
|
22
|
+
const s = await wsAlive();
|
|
23
|
+
if (s.alive) { evOffline = false; document.title = document.title.replace(/^○ /, ''); add('meta wson', '● workspace encendido · reconectado'); evBackoff = 1000; connectEvents(); if (typeof loadProcs === 'function') loadProcs(); if (typeof loadSessions === 'function') loadSessions(); }
|
|
24
|
+
else setTimeout(poll, 3000);
|
|
25
|
+
};
|
|
26
|
+
setTimeout(poll, 3000);
|
|
27
|
+
}
|
|
6
28
|
function connectEvents() {
|
|
7
29
|
try { events = new EventSource(BASE + '/api/events'); } catch (e) { events = null; return; }
|
|
8
|
-
events.onopen = () => { evBackoff = 1000; };
|
|
30
|
+
events.onopen = () => { evBackoff = 1000; evEverOpen = true; };
|
|
9
31
|
events.addEventListener('event', e => {
|
|
10
32
|
let ev; try { ev = JSON.parse(e.data); } catch { return; }
|
|
11
33
|
const t = ev.topic || '';
|
|
@@ -26,6 +48,12 @@ function connectEvents() {
|
|
|
26
48
|
if (id && agentOpen === id && agentPaint) debounce('agentview', agentPaint, 250);
|
|
27
49
|
}
|
|
28
50
|
});
|
|
29
|
-
events.onerror = () => {
|
|
51
|
+
events.onerror = async () => {
|
|
52
|
+
events.close(); events = null;
|
|
53
|
+
// ¿se cayó el stream o se apagó el workspace? Si el hub dice que no está vivo, no insistimos contra el proxy.
|
|
54
|
+
const s = await wsAlive();
|
|
55
|
+
if (!s.alive) { goOffline(s); return; }
|
|
56
|
+
setTimeout(connectEvents, evBackoff); evBackoff = Math.min(evBackoff * 2, 30000);
|
|
57
|
+
};
|
|
30
58
|
}
|
|
31
59
|
connectEvents();
|
package/public/js/hub.js
CHANGED
|
@@ -24,8 +24,16 @@ async function paintGrid() {
|
|
|
24
24
|
const grid = $('#wsGrid'); let r; try { r = await (await fetch(BASE + '/api/workspaces')).json(); } catch (e) { grid.innerHTML = '<div class="card"><p>el hub no responde</p></div>'; return; }
|
|
25
25
|
grid.innerHTML = '';
|
|
26
26
|
for (const w of (r.workspaces || [])) {
|
|
27
|
-
const c = document.createElement('a'); c.className = 'card ws' + (w.alive ? ' on' : ''); c.href = w.url;
|
|
27
|
+
const c = document.createElement('a'); c.className = 'card ws' + (w.alive ? ' on' : ' off'); c.href = w.url;
|
|
28
28
|
c.innerHTML = `<h3>${w.alive ? '● vivo' : '○ apagado'}${w.schedules_on ? ' · ⏰ ' + w.schedules_on : ''}</h3><p class="nm">${esc(w.name)}</p><p class="pth">${esc(w.path)}</p><p class="meta">último uso ${esc(fmtWhen(w.last_used))}</p>`;
|
|
29
|
+
// entrar = usarlo: si está apagado se enciende acá mismo (con aviso en la tarjeta) y después se abre. La ruta
|
|
30
|
+
// /w/<slug> del hub también lo enciende sola, así que un link directo funciona igual; esto solo da feedback.
|
|
31
|
+
if (!w.alive) c.onclick = async (e) => {
|
|
32
|
+
e.preventDefault(); if (c.dataset.busy) return; c.dataset.busy = '1';
|
|
33
|
+
const h = c.querySelector('h3'); h.textContent = '⟳ encendiendo…'; c.classList.add('busy');
|
|
34
|
+
try { const r = await api('/api/workspaces/start', { slug: w.slug }); if (!r.ok || !r.data.ok) { h.textContent = '○ no arrancó · mirá .lampson/ws/' + w.slug + '/.lampson/web.log'; c.classList.remove('busy'); delete c.dataset.busy; return; } } catch (err) {}
|
|
35
|
+
location.href = w.url;
|
|
36
|
+
};
|
|
29
37
|
grid.appendChild(c);
|
|
30
38
|
}
|
|
31
39
|
const add = document.createElement('a'); add.className = 'card ws new'; add.href = '#'; add.innerHTML = '<h3>+</h3><p class="nm">nuevo workspace</p><p class="pth">elegí una carpeta de tu disco</p>';
|
package/public/js/panel.js
CHANGED
|
@@ -71,6 +71,8 @@ const Panel = (() => {
|
|
|
71
71
|
if (!cur || cur.layout !== 'browse') return;
|
|
72
72
|
const spec = cur, q = query(), my = ++seq;
|
|
73
73
|
const keep = pendingKey != null ? pendingKey : (rows[sel] && spec.browse.key ? spec.browse.key(rows[sel]) : null); pendingKey = null;
|
|
74
|
+
// mientras carga, decirlo (la lista de workspaces puede tardar si el hub tiene que sondear procesos)
|
|
75
|
+
if (!rows.length) { const it = box().querySelector('.bitems'); if (it) it.innerHTML = '<div class="bempty">cargando…</div>'; }
|
|
74
76
|
let list; try { list = await spec.browse.load(q); } catch (e) { list = []; }
|
|
75
77
|
if (cur !== spec || my !== seq) return;
|
|
76
78
|
rows = list || [];
|
package/public/js/workspaces.js
CHANGED
|
@@ -4,8 +4,9 @@ let wsList = [], wsIdle = 4;
|
|
|
4
4
|
// abierto por el puerto de un proceso (:808N) las URLs de otros workspaces tienen que ir al hub (:8080), no a este proceso
|
|
5
5
|
const HUB_BASE = (location.port && location.port !== '8080') ? location.protocol + '//' + location.hostname + ':8080' : '';
|
|
6
6
|
function wsUrl(w) { return HUB_BASE + w.url; }
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
// fresh=true: saltear la caché del hub (después de encender/apagar)
|
|
8
|
+
async function fetchWorkspaces(fresh) {
|
|
9
|
+
try { const r = await (await fetch('/api/workspaces' + (fresh ? '?fresh=1' : ''))).json(); wsList = r.workspaces || []; wsIdle = r.idle_hours; return true; } catch (e) { wsList = []; return false; }
|
|
9
10
|
}
|
|
10
11
|
async function paintWorkspacePill() {
|
|
11
12
|
const pill = $('#wsPill'); if (!pill) return;
|
|
@@ -22,12 +23,12 @@ function openWorkspaces(selectSlug) {
|
|
|
22
23
|
browse: {
|
|
23
24
|
placeholder: 'buscar workspace…', listWidth: '300px', key: w => w.slug,
|
|
24
25
|
load: async (q) => { await fetchWorkspaces(); const list = wsList.filter(w => !q || (w.name + ' ' + w.path).toLowerCase().includes(q.toLowerCase())); return q ? list : [...list, WS_NEW]; },
|
|
25
|
-
render: (w) => w === WS_NEW ? `<span class="dot">+</span><div><div class="nm" style="color:var(--accent);font-weight:400">nuevo workspace…</div><div class="meta">elegí una carpeta</div></div>` : `<span class="dot">${w.alive ? '●' : '○'}</span><div><div class="nm">${esc(w.name)}${w.slug === WS_SLUG ? ' <span class="meta">(este)</span>' : ''}</div><div class="meta">${esc(w.path)}</div></div>`,
|
|
26
|
+
render: (w) => w === WS_NEW ? `<span class="dot">+</span><div><div class="nm" style="color:var(--accent);font-weight:400">nuevo workspace…</div><div class="meta">elegí una carpeta</div></div>` : `<span class="dot ${w.alive ? 'on' : 'off'}">${w.alive ? '●' : '○'}</span><div><div class="nm">${esc(w.name)}${w.slug === WS_SLUG ? ' <span class="meta">(este)</span>' : ''}</div><div class="meta">${esc(w.path)}</div></div>`,
|
|
26
27
|
count: (rows) => { const n = rows.filter(r => r !== WS_NEW).length, a = rows.filter(r => r !== WS_NEW && r.alive).length; return `${n} workspace${n === 1 ? '' : 's'} · ${a} vivo${a === 1 ? '' : 's'}`; },
|
|
27
28
|
emptyHtml: 'nada coincide', emptyDetail: 'Todavía no hay workspaces. Creá uno con «nuevo workspace…» o abrí <code>lampson</code> en la carpeta de un proyecto.',
|
|
28
|
-
detail: (w) => w === WS_NEW ? wsNewForm() : `<div class="dhead"><span class="nm">${esc(w.name)}</span><span class="meta">${w.alive ? '● vivo' : '○ apagado'} · ${esc(w.policy)}</span></div>
|
|
29
|
+
detail: (w) => w === WS_NEW ? wsNewForm() : `<div class="dhead"><span class="nm">${esc(w.name)}</span><span class="meta"><b class="st ${w.alive ? 'on' : 'off'}">${w.alive ? '● vivo' : (w.paused ? '○ apagado a mano' : '○ apagado')}</b> · ${esc(w.policy)}</span></div>
|
|
29
30
|
<div class="dcap">${esc(w.path)}</div>
|
|
30
|
-
<div class="ddesc">${w.schedules_on ? `${w.schedules_on} tarea${w.schedules_on === 1 ? '' : 's'} programada${w.schedules_on === 1 ? '' : 's'} encendida${w.schedules_on === 1 ? '' : 's'} (lo mantienen vivo). ` : ''}Último uso: ${esc(fmtWhen(w.last_used))}.</div>
|
|
31
|
+
<div class="ddesc">${w.schedules_on ? `${w.schedules_on} tarea${w.schedules_on === 1 ? '' : 's'} programada${w.schedules_on === 1 ? '' : 's'} encendida${w.schedules_on === 1 ? '' : 's'} ${w.alive ? '(lo mantienen vivo)' : (w.paused ? '— en pausa hasta que lo enciendas' : '')}. ` : ''}${!w.alive && w.paused ? 'Lo apagaste a mano: no se enciende solo hasta ▶ encender o abrir <code>lampson</code> en la carpeta. ' : ''}Último uso: ${esc(fmtWhen(w.last_used))}.</div>
|
|
31
32
|
<div class="dacts"><button class="primary" data-open>${w.slug === WS_SLUG ? 'Ya estás acá' : 'Abrir'}</button>${w.alive ? '<button data-stop>■ apagar</button>' : '<button data-start>▶ encender</button>'}</div>
|
|
32
33
|
<div class="dform" style="margin-top:14px"><label>vida <select name="policy"><option value="auto" ${w.policy === 'auto' ? 'selected' : ''}>auto — vivo mientras se use (${wsIdle === 0 ? 'nunca se apaga' : 'se apaga tras ' + wsIdle + ' h sin uso'}) o tenga tareas</option><option value="always" ${w.policy === 'always' ? 'selected' : ''}>siempre vivo</option><option value="off" ${w.policy === 'off' ? 'selected' : ''}>apagado — solo cuando lo abrís</option></select></label><span class="ds">las horas de inactividad se cambian en ⚙ → General</span></div>
|
|
33
34
|
<div class="dfoot"><span>/w/${esc(w.slug)} · :${w.port} (solo loopback)</span><span class="del">quitar del registro</span></div><div class="derr"></div>`,
|
|
@@ -35,8 +36,11 @@ function openWorkspaces(selectSlug) {
|
|
|
35
36
|
const err = (m) => { const e = box.querySelector('.derr'); if (e) e.textContent = m || ''; };
|
|
36
37
|
if (w === WS_NEW) { wsNewWire(box, err); return; }
|
|
37
38
|
box.querySelector('[data-open]').onclick = () => { if (w.slug !== WS_SLUG) location.href = wsUrl(w); else Panel.close(); };
|
|
38
|
-
|
|
39
|
-
|
|
39
|
+
// esperar a que el hub vea el estado nuevo (matar/arrancar un proceso tarda unos segundos): sin esto el refresco
|
|
40
|
+
// a los 0,8 s todavía mostraba «vivo» y el botón parecía no hacer nada
|
|
41
|
+
const waitFor = async (alive, secs) => { const t0 = Date.now(); while (Date.now() - t0 < secs * 1000) { await fetchWorkspaces(true); const x = wsList.find(y => y.slug === w.slug); if (x && x.alive === alive) return true; await new Promise(r => setTimeout(r, 700)); } return false; };
|
|
42
|
+
const st = box.querySelector('[data-start]'); if (st) st.onclick = async () => { st.disabled = true; st.textContent = '⟳ encendiendo…'; err(''); const r = await api('/api/workspaces/start', { slug: w.slug }); const ok = r.data.ok || await waitFor(true, 6); err(ok ? '' : 'no respondió a tiempo · mirá .lampson/ws/' + w.slug + '/.lampson/web.log'); Panel.refresh(); };
|
|
43
|
+
const sp = box.querySelector('[data-stop]'); if (sp) sp.onclick = async () => { sp.disabled = true; sp.textContent = '⟳ apagando…'; err(''); await api('/api/workspaces/stop', { slug: w.slug }); const ok = await waitFor(false, 10); if (ok && w.slug === WS_SLUG) { location.href = HUB_BASE + '/'; return; } err(ok ? '' : 'sigue respondiendo · probá de nuevo'); Panel.refresh(); };
|
|
40
44
|
box.querySelector('[name="policy"]').onchange = async (ev) => { const r = await api('/api/workspaces/policy', { slug: w.slug, policy: ev.target.value }); if (!r.ok) err(r.data.error || 'error'); };
|
|
41
45
|
const del = box.querySelector('.dfoot .del');
|
|
42
46
|
del.onclick = () => inlineConfirm(del, `¿quitar ${w.name} del registro?`, async () => { const r = await api('/api/workspaces/remove', { slug: w.slug }); if (!r.ok) { err(r.data.error || 'error'); return; } if (w.slug === WS_SLUG) location.href = HUB_BASE + '/'; else Panel.refresh(); });
|
package/web.syn
CHANGED
|
@@ -89,7 +89,8 @@ task as_text(x)
|
|
|
89
89
|
|
|
90
90
|
task boot(profile, mode, ask_fn, pname, model)
|
|
91
91
|
let cfg be provider.config_for(pname, model)
|
|
92
|
-
|
|
92
|
+
-- worker es interno de delegate: por la API tampoco se elige como perfil de sesión
|
|
93
|
+
let p be when contains(agents.names(), profile) then profile otherwise "build"
|
|
93
94
|
let opts be loop.default_opts(agents.registry_for(p), agents.catalog_for(p), ask_fn)
|
|
94
95
|
set opts to loop.with_steps(opts, agents.steps_for(p))
|
|
95
96
|
set opts to loop.with_inbox(opts, agents.parent_inbox, "")
|