lampson 0.1.2 → 0.1.4
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 +9 -0
- package/README.md +60 -2
- package/chat.syn +264 -9
- package/lampson.ps1 +17 -3
- package/lampson.sh +17 -3
- package/lib/agents.syn +1 -1
- package/lib/approvals.syn +176 -0
- package/lib/diff.syn +37 -11
- package/lib/line.syn +13 -8
- package/lib/loop.syn +25 -11
- package/lib/md.syn +76 -18
- package/lib/permission.syn +18 -0
- package/lib/prompt.syn +1 -1
- package/lib/sched_run.syn +161 -0
- package/lib/schedule.syn +660 -0
- package/lib/session.syn +38 -1
- package/lib/settings.syn +55 -0
- package/lib/tools.syn +94 -2
- package/lib/ui.syn +161 -0
- package/package.json +1 -1
- package/public/css/chat.css +56 -0
- package/public/css/layout.css +97 -0
- package/public/css/panel.css +125 -0
- package/public/css/sidebar.css +80 -0
- package/public/css/tokens.css +57 -0
- package/public/index.html +49 -1187
- package/public/js/agents.js +31 -0
- package/public/js/app.js +21 -0
- package/public/js/approvals.js +26 -0
- package/public/js/chat.js +92 -0
- package/public/js/config.js +98 -0
- package/public/js/core.js +88 -0
- package/public/js/events.js +31 -0
- package/public/js/lamps.js +112 -0
- package/public/js/lsp.js +81 -0
- package/public/js/mcp.js +74 -0
- package/public/js/memory.js +17 -0
- package/public/js/panel.js +101 -0
- package/public/js/procs.js +45 -0
- package/public/js/schedules.js +118 -0
- package/public/js/sessions.js +69 -0
- package/public/js/sidebar.js +33 -0
- package/public/js/terminal.js +49 -0
- package/public/js/theme.js +6 -0
- package/public/js/todo.js +10 -0
- package/public/js/tree.js +53 -0
- package/public/js/update.js +14 -0
- package/skills/lampson/SKILL.md +16 -0
- package/web.syn +106 -15
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// terminal.js — terminal real: xterm.js ↔ WebSocket /api/term ↔ pty en el servidor
|
|
2
|
+
// Frames binarios = bytes del pty; texto JSON = control (hello / exit). Cerrar el panel cierra el socket y mata el shell.
|
|
3
|
+
let term = null, termWs = null, termFit = null;
|
|
4
|
+
function termTheme() {
|
|
5
|
+
const s = getComputedStyle(document.documentElement); const v = n => s.getPropertyValue(n).trim();
|
|
6
|
+
return { background: v('--paper'), foreground: v('--ink'), cursor: v('--accent'), cursorAccent: v('--paper'), selectionBackground: v('--sel'),
|
|
7
|
+
black: v('--paper-3'), brightBlack: v('--ink-3'), red: v('--rubric'), brightRed: v('--rubric'), green: v('--str'), brightGreen: v('--str'),
|
|
8
|
+
yellow: v('--amber'), brightYellow: v('--amber'), blue: v('--term-blue'), brightBlue: v('--accent'), magenta: v('--rubric'), brightMagenta: v('--rubric'),
|
|
9
|
+
cyan: v('--accent'), brightCyan: v('--accent'), white: v('--ink-2'), brightWhite: v('--ink') };
|
|
10
|
+
}
|
|
11
|
+
function openTerm() {
|
|
12
|
+
if (term) { showPane('term'); termFit.fit(); term.focus(); return; }
|
|
13
|
+
showPane('term'); procOpen = null; clearInterval(procTimer);
|
|
14
|
+
term = new Terminal({ cursorBlink: true, fontFamily: getComputedStyle(document.documentElement).getPropertyValue('--mono'), fontSize: 13, lineHeight: 1.25, theme: termTheme(), scrollback: 5000, allowProposedApi: true });
|
|
15
|
+
termFit = new FitAddon.FitAddon(); term.loadAddon(termFit); term.open($('#xterm')); termFit.fit();
|
|
16
|
+
// URLs clickeables (npm run dev imprime http://localhost:3000): link provider mínimo con la API nativa
|
|
17
|
+
// de xterm v5 — el addon web-links no está vendorizado y no hace falta para http/https
|
|
18
|
+
const TERM_URL_RE = /https?:\/\/[^\s"'`<>()\[\]{}]*[^\s"'`<>()\[\]{}.,;:!?]/g;
|
|
19
|
+
term.registerLinkProvider({
|
|
20
|
+
provideLinks(y, cb) {
|
|
21
|
+
const line = term.buffer.active.getLine(y - 1);
|
|
22
|
+
if (!line) return cb(undefined);
|
|
23
|
+
const text = line.translateToString(true);
|
|
24
|
+
const links = []; let m; TERM_URL_RE.lastIndex = 0;
|
|
25
|
+
while ((m = TERM_URL_RE.exec(text))) {
|
|
26
|
+
links.push({ range: { start: { x: m.index + 1, y }, end: { x: m.index + m[0].length, y } }, text: m[0], activate: (_e, uri) => window.open(uri, '_blank') });
|
|
27
|
+
}
|
|
28
|
+
cb(links.length ? links : undefined);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
$('#tmeta').textContent = 'conectando…';
|
|
32
|
+
termWs = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/api/term'); termWs.binaryType = 'arraybuffer';
|
|
33
|
+
const send = (o) => { if (termWs && termWs.readyState === 1) termWs.send(JSON.stringify(o)); };
|
|
34
|
+
termWs.onopen = () => { $('#termpane').classList.add('live'); send({ type: 'resize', cols: term.cols, rows: term.rows }); term.focus(); };
|
|
35
|
+
termWs.onmessage = (e) => { // "o" + salida del pty | "c" + JSON de control
|
|
36
|
+
const s = String(e.data);
|
|
37
|
+
if (s[0] === 'o') { term.write(s.slice(1)); return; }
|
|
38
|
+
let m; try { m = JSON.parse(s.slice(1)); } catch { return; }
|
|
39
|
+
if (m.type === 'hello') $('#tmeta').textContent = `${m.shell} · pid ${m.pid} · ${m.cwd || 'workspace'}`;
|
|
40
|
+
if (m.type === 'exit') { term.write(`\r\n\x1b[2m[shell terminado · código ${m.code}]\x1b[0m\r\n`); $('#termpane').classList.remove('live'); }
|
|
41
|
+
};
|
|
42
|
+
termWs.onclose = () => { $('#termpane').classList.remove('live'); if ($('#tmeta').textContent === 'conectando…') $('#tmeta').textContent = 'sin conexión'; };
|
|
43
|
+
term.onData(d => send({ type: 'in', data: d }));
|
|
44
|
+
term.onResize(({ cols, rows }) => send({ type: 'resize', cols, rows }));
|
|
45
|
+
new ResizeObserver(() => { if ($('#termpane').style.display !== 'none') termFit.fit(); }).observe($('#xterm'));
|
|
46
|
+
}
|
|
47
|
+
function closeTerm() { if (termWs) { try { termWs.close(); } catch (e) {} } if (term) term.dispose(); term = null; termWs = null; termFit = null; $('#xterm').innerHTML = ''; $('#termpane').classList.remove('live'); showPane('log'); }
|
|
48
|
+
$('#term').onclick = openTerm;
|
|
49
|
+
$('#tclose').onclick = closeTerm;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// theme.js — tema día / noche (el valor inicial ya lo puso el script del <head>)
|
|
2
|
+
const SUN = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><path d="M12 2.7v2.1M12 19.2v2.1M2.7 12h2.1M19.2 12h2.1M5.3 5.3l1.5 1.5M17.2 17.2l1.5 1.5M18.7 5.3l-1.5 1.5M6.8 17.2l-1.5 1.5"/></svg>';
|
|
3
|
+
const MOON = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M20.2 14.6A8.6 8.6 0 0 1 9.4 3.8a8.6 8.6 0 1 0 10.8 10.8z"/></svg>';
|
|
4
|
+
function paintTheme() { const light = document.documentElement.getAttribute('data-theme') === 'light'; $('#theme').innerHTML = light ? MOON : SUN; $('#theme').title = light ? 'Cambiar a tema oscuro' : 'Cambiar a tema claro'; }
|
|
5
|
+
$('#theme').onclick = () => { const r = document.documentElement; r.setAttribute('data-theme', r.getAttribute('data-theme') === 'light' ? 'dark' : 'light'); try { localStorage.setItem('lampson.theme', r.getAttribute('data-theme')); } catch (e) {} paintTheme(); if (typeof term !== 'undefined' && term) term.options.theme = termTheme(); };
|
|
6
|
+
paintTheme();
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// todo.js — lista de tareas del agente en la sesión actual (tool todo)
|
|
2
|
+
async function loadTodo() {
|
|
3
|
+
let r; try { r = await (await fetch('/api/todo' + (session ? '?session=' + encodeURIComponent(session) : ''))).json(); } catch (e) { return; }
|
|
4
|
+
const box = $('#todoBox'); box.innerHTML = '';
|
|
5
|
+
const items = r.items || []; const open = items.filter(i => i.status === 'pending' || i.status === 'in_progress').length;
|
|
6
|
+
$('#todoCount').textContent = items.length ? (open ? `${open} · ${items.length}` : `${items.length}`) : ''; autoSec('todo', open > 0);
|
|
7
|
+
if (!items.length) { box.innerHTML = '<div class="p none">ninguna</div>'; return; }
|
|
8
|
+
const mark = { completed: '☑', in_progress: '▶', cancelled: '✕', pending: '☐' };
|
|
9
|
+
for (const it of items) { const d = document.createElement('div'); d.className = 'p' + (it.status === 'in_progress' ? ' run' : ''); d.style.cursor = 'default'; d.innerHTML = `<span class="nm" style="opacity:${it.status === 'completed' || it.status === 'cancelled' ? .5 : 1}">${mark[it.status] || '☐'} ${esc(it.content)}</span>`; box.appendChild(d); }
|
|
10
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// tree.js — árbol de archivos (panel derecho) con estado git, y el visor de archivos
|
|
2
|
+
let gitStatus = { changes: {} };
|
|
3
|
+
async function loadGit() {
|
|
4
|
+
try { gitStatus = await (await fetch('/api/git')).json(); } catch (e) { gitStatus = { repo: false, changes: {} }; }
|
|
5
|
+
const g = $('#git'); if (!gitStatus.repo) { g.style.display = 'none'; return; }
|
|
6
|
+
const k = gitStatus.counts || {}; const nuevos = (k.added||0)+(k.untracked||0);
|
|
7
|
+
const parts = []; // solo contadores distintos de cero — lo demás es ruido
|
|
8
|
+
if (k.modified) parts.push(`<b class="w">~${k.modified}</b>`);
|
|
9
|
+
if (nuevos) parts.push(`<b class="g">+${nuevos}</b>`);
|
|
10
|
+
if (k.deleted) parts.push(`<b class="r">−${k.deleted}</b>`);
|
|
11
|
+
g.innerHTML = `⎇ <span class="br">${esc(gitStatus.branch)}</span>` + (parts.length ? parts.join('') : '<b class="c">✓</b>');
|
|
12
|
+
g.title = parts.length ? `${k.modified||0} modificados · ${nuevos} nuevos · ${k.deleted||0} borrados` : 'working tree limpio';
|
|
13
|
+
g.style.display = 'flex';
|
|
14
|
+
}
|
|
15
|
+
function gitClass(path, isDir) {
|
|
16
|
+
const ch = gitStatus.changes || {};
|
|
17
|
+
if (!isDir) { const c = ch[path]; return c === '??' ? 'gU' : c === 'A' ? 'gA' : c === 'D' ? 'gD' : c ? 'gM' : ''; }
|
|
18
|
+
const p = path + '/'; for (const k in ch) if (k.startsWith(p)) return 'gM'; return '';
|
|
19
|
+
}
|
|
20
|
+
async function loadTree() {
|
|
21
|
+
await loadGit();
|
|
22
|
+
const box = $('#tree'); box.innerHTML = '';
|
|
23
|
+
let t; try { t = await (await fetch('/api/tree')).json(); } catch (e) { box.innerHTML = '<div class="row none">sin árbol</div>'; return; }
|
|
24
|
+
const render = (entries, parent, depth) => {
|
|
25
|
+
for (const e of entries) {
|
|
26
|
+
const n = document.createElement('div'); n.className = 'node';
|
|
27
|
+
const gc = gitClass(e.path, e.is_dir); const code = (gitStatus.changes || {})[e.path];
|
|
28
|
+
const r = document.createElement('div'); r.className = 'row' + (e.is_dir ? ' dir' : '') + (gc ? ' ' + gc : ''); r.title = e.path + (code ? ' · git ' + code : '');
|
|
29
|
+
r.innerHTML = `<span class="tw">${e.is_dir ? '▸' : ''}</span><span>${esc(e.name)}${e.is_dir ? '/' : ''}</span>` + (code && !e.is_dir ? `<span class="gs">${code === '??' ? 'N' : code}</span>` : '');
|
|
30
|
+
n.appendChild(r);
|
|
31
|
+
if (e.is_dir) {
|
|
32
|
+
const k = document.createElement('div'); k.className = 'kids hidden'; render(e.children || [], k, depth + 1); n.appendChild(k);
|
|
33
|
+
r.querySelector('.tw').textContent = '▸';
|
|
34
|
+
r.onclick = () => { k.classList.toggle('hidden'); r.querySelector('.tw').textContent = k.classList.contains('hidden') ? '▸' : '▾'; };
|
|
35
|
+
} else { r.onclick = () => openFile(e.path, r); }
|
|
36
|
+
parent.appendChild(n);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
render(t.entries || [], box, 0);
|
|
40
|
+
if (t.truncated) { const d = document.createElement('div'); d.className = 'row none'; d.textContent = '… árbol truncado'; box.appendChild(d); }
|
|
41
|
+
}
|
|
42
|
+
async function openFile(path, row) {
|
|
43
|
+
document.querySelectorAll('.row.active').forEach(x => x.classList.remove('active')); if (row) row.classList.add('active');
|
|
44
|
+
const r = await fetch('/api/file?path=' + encodeURIComponent(path)); const d = await r.json();
|
|
45
|
+
if (!r.ok) { add('denied', esc(d.error || 'error')); return; }
|
|
46
|
+
$('#vpath').textContent = d.path; $('#vmeta').textContent = d.lines + ' líneas';
|
|
47
|
+
$('#vbody').className = ''; $('#vbody').innerHTML = String(d.content).split('\n').map((l, i) => `<span class="ln">${i + 1}</span>${esc(l)}`).join('\n');
|
|
48
|
+
showPane('viewer'); $('#stage').scrollTop = 0;
|
|
49
|
+
}
|
|
50
|
+
// el árbol se refresca solo cuando una tool pudo crear/borrar archivos, con un pequeño debounce; ↻ lo fuerza a mano
|
|
51
|
+
function treeChanged() { debounce('tree', loadTree, 400); }
|
|
52
|
+
$('#tree-reload').onclick = e => { e.stopPropagation(); const b = e.currentTarget; b.classList.remove('spin'); void b.offsetWidth; b.classList.add('spin'); loadTree(); };
|
|
53
|
+
$('#vclose').onclick = () => { showPane('log'); procOpen = null; clearInterval(procTimer); $('#vbody').className = ''; document.querySelectorAll('.row.active, .m.active, .p.active').forEach(x => x.classList.remove('active')); memOpen = null; };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// update.js — actualizaciones de Lampson (una vez por carga; el fetch a origin puede tardar)
|
|
2
|
+
async function checkUpdate() {
|
|
3
|
+
let u; try { u = await (await fetch('/api/update')).json(); } catch (e) { return; }
|
|
4
|
+
const b = $('#update'); if (!u.available) { b.style.display = 'none'; return; }
|
|
5
|
+
b.textContent = `⬆ actualizar · ${u.behind} commit${u.behind === 1 ? '' : 's'}`;
|
|
6
|
+
b.title = `${u.current} → ${u.latest}\n${(u.notes || []).map(n => '· ' + n).join('\n')}\n\nen terminal: ${u.command}`;
|
|
7
|
+
b.style.display = '';
|
|
8
|
+
b.onclick = async () => {
|
|
9
|
+
b.disabled = true; b.textContent = 'actualizando…';
|
|
10
|
+
let r; try { r = await (await fetch('/api/update', { method: 'POST' })).json(); } catch (e) { r = { result: 'no se pudo actualizar: ' + e.message }; }
|
|
11
|
+
add('meta', '⬆ ' + esc(r.result || ''));
|
|
12
|
+
b.disabled = false; b.textContent = '⬆ reiniciá el servidor'; b.onclick = null;
|
|
13
|
+
};
|
|
14
|
+
}
|
package/skills/lampson/SKILL.md
CHANGED
|
@@ -40,6 +40,22 @@ description: How this harness works — tools, workspace mount, permissions, age
|
|
|
40
40
|
brief — a child does not see this conversation, cannot ask the user and cannot delegate. Reports are
|
|
41
41
|
self-reports: verify before claiming success. Live logs: `.lampson/agents/<id>.log`.
|
|
42
42
|
|
|
43
|
+
## Scheduled tasks (`schedule` tool)
|
|
44
|
+
- When the user says "every day at 9", "each 6 hours", "on Mondays", "periodically", "send me", propose
|
|
45
|
+
`schedule(action=add, name, at, kind, …)`: `at` = `every 6h` | `daily 09:00` | `mon,wed 08:30` |
|
|
46
|
+
`weekdays 09:00`; one-time: `today 15:14` | `tomorrow 09:00` | `once 2026-08-29 15:14` | `in 2h` (it turns itself off after running) — the USER'S LOCAL time: write the hour as they say it, never convert to UTC (the tool result shows the next run with its offset). Tasks belong to the current workspace. `kind=lamp` (a lamp that is ON: lamp + tool + args), `kind=bash` (one
|
|
47
|
+
command that finishes on its own), `kind=prompt` (an unattended agent run: write a self-contained prompt —
|
|
48
|
+
what to do, how to verify, what to report — and pick `agent` build/review/plan/explore).
|
|
49
|
+
- `permission` is the envelope of a `prompt` run with nobody watching: `strict` (dangerous → denied),
|
|
50
|
+
`ask` (default: the user gets an approval request in the web UI and, if configured, a link on their
|
|
51
|
+
phone; denied if unanswered within `approval_timeout`), `yolo`. Prefer `strict` or `review`/`plan` profiles
|
|
52
|
+
for reports; `ask`/`build` only when the task must change things.
|
|
53
|
+
- `add` ALWAYS asks the user (it authorizes future runs). `notify` = a webhook URL that receives the result
|
|
54
|
+
as JSON — the way to "search X and send it to me" without an MCP.
|
|
55
|
+
- Tasks are executed by Lampson's resident process. If `schedule(action=list)` says no scheduler is running,
|
|
56
|
+
tell the user: `lampson --daemon start` (or keep `lampson --web` open). A `prompt` run's report lands in a NEW session named
|
|
57
|
+
`⏰ <name>` (never in the current chat — say so); `action=log` shows the runs.
|
|
58
|
+
|
|
43
59
|
## When you need the USER to run something
|
|
44
60
|
The user can run any command themselves from the chat by prefixing it with `!` — e.g. `!npm run dev`,
|
|
45
61
|
`!cat .env`, `!git push`. Their command runs without the permission policy and its output is added to
|
package/web.syn
CHANGED
|
@@ -23,6 +23,7 @@ require serve(8080)
|
|
|
23
23
|
require net
|
|
24
24
|
require time
|
|
25
25
|
require exec
|
|
26
|
+
require random
|
|
26
27
|
require env("LAMPSON_*")
|
|
27
28
|
require env("OS")
|
|
28
29
|
require secret("LAMPSON_*")
|
|
@@ -54,6 +55,10 @@ use "./lib/mcp.syn" as mcp
|
|
|
54
55
|
use "./lib/lamps.syn" as lamps
|
|
55
56
|
use "./lib/lsp.syn" as lsp
|
|
56
57
|
use "./lib/tools/todo.syn" as todo
|
|
58
|
+
use "./lib/schedule.syn" as schedule
|
|
59
|
+
use "./lib/sched_run.syn" as sched_run
|
|
60
|
+
use "./lib/approvals.syn" as approvals
|
|
61
|
+
use "./lib/permission.syn" as permission
|
|
57
62
|
|
|
58
63
|
let APPROVAL_TIMEOUT be 180
|
|
59
64
|
|
|
@@ -106,6 +111,15 @@ task boot(profile, mode, ask_fn, pname, model)
|
|
|
106
111
|
task lampson_subagent(spec_json)
|
|
107
112
|
give agents.run_child_json(spec_json)
|
|
108
113
|
|
|
114
|
+
-- TAREAS PROGRAMADAS (lib/schedule.syn): este proceso es el residente de lampson — con `lampson --daemon start`
|
|
115
|
+
-- (synsema daemon) queda en background y ejecuta lo programado aunque no haya ninguna UI abierta. Un tick cada
|
|
116
|
+
-- 30 s (cron_every de Synsema: intervalo puro; la hora de reloj la resuelve schedule.syn) corre lo vencido:
|
|
117
|
+
-- lámparas, comandos y corridas del agente con su sobre de permisos; una aprobación en una corrida desatendida
|
|
118
|
+
-- sale por lib/approvals.syn (panel «Aprobaciones» de la UI, webhook firmado, link de un solo uso).
|
|
119
|
+
task sched_tick()
|
|
120
|
+
give sched_run.tick()
|
|
121
|
+
cron_every(schedule.TICK_SECONDS, sched_tick)
|
|
122
|
+
|
|
109
123
|
-- servers MCP: supervisores (agentes) lanzados al arrancar; los handlers leen su estado del blackboard
|
|
110
124
|
mcp.start_all(8)
|
|
111
125
|
|
|
@@ -122,6 +136,12 @@ serve on 8080
|
|
|
122
136
|
route "GET /api/sessions" requires auth
|
|
123
137
|
give {"sessions": session.list()}
|
|
124
138
|
|
|
139
|
+
-- buscador de sesiones (modal «ver todas»): ?q=texto busca en títulos y mensajes de este proyecto
|
|
140
|
+
route "GET /api/sessions/search" requires auth
|
|
141
|
+
let q be when contains(query, "q") then text(query.q) otherwise ""
|
|
142
|
+
let n be when contains(query, "limit") then floor(number(query.limit)) otherwise 200
|
|
143
|
+
give {"q": q, "sessions": session.search(q, n)}
|
|
144
|
+
|
|
125
145
|
route "GET /api/sessions/:id" requires auth
|
|
126
146
|
when not session.exists(params.id)
|
|
127
147
|
give not_found("session not found")
|
|
@@ -311,7 +331,7 @@ serve on 8080
|
|
|
311
331
|
-- tiene su cola acotada (drop_oldest). Heartbeat cada 25 s para que el proxy/navegador no cierre.
|
|
312
332
|
route "GET /api/events" requires auth
|
|
313
333
|
stream
|
|
314
|
-
let sub be bus_subscribe(["proc.*", "subagent.*", "mcp.*"], {"max_queue": 512})
|
|
334
|
+
let sub be bus_subscribe(["proc.*", "subagent.*", "mcp.*", "approval.*", "schedule.*"], {"max_queue": 512})
|
|
315
335
|
let open be true
|
|
316
336
|
while open
|
|
317
337
|
let ev be bus_recv(sub, 25)
|
|
@@ -375,11 +395,88 @@ serve on 8080
|
|
|
375
395
|
give fail(400, "path required")
|
|
376
396
|
give tree.file_content(query.path)
|
|
377
397
|
|
|
398
|
+
-- configuración general (rueda de la UI): zona horaria, URL pública, webhook… → .lampson/config.json
|
|
399
|
+
route "GET /api/settings/values" requires auth
|
|
400
|
+
give {"values": settings.values(), "tz_detected": schedule.tz_offset()}
|
|
401
|
+
|
|
402
|
+
route "POST /api/settings/values" requires auth
|
|
403
|
+
let b be json of request
|
|
404
|
+
try
|
|
405
|
+
each k in keys(b)
|
|
406
|
+
settings.set_value(k, b[k])
|
|
407
|
+
-- la zona horaria cacheada en el blackboard se recalcula
|
|
408
|
+
share nothing as "lampson:tz"
|
|
409
|
+
give {"ok": true, "values": settings.values(), "tz_detected": schedule.tz_offset()}
|
|
410
|
+
recover err
|
|
411
|
+
give fail(400, text(err))
|
|
412
|
+
|
|
413
|
+
-- aprobaciones (lib/approvals.syn): las del chat y las de tareas programadas comparten la cola
|
|
378
414
|
route "POST /api/approve" requires auth
|
|
379
415
|
expect body {id: text, decision: bool}
|
|
380
416
|
let b be json of request
|
|
381
|
-
|
|
382
|
-
|
|
417
|
+
give {"ok": approvals.answer(text(b["id"]), b["decision"])}
|
|
418
|
+
|
|
419
|
+
route "GET /api/approvals" requires auth
|
|
420
|
+
give {"approvals": approvals.list(), "public_url": approvals.public_url(), "webhook": approvals.webhook_url() != ""}
|
|
421
|
+
|
|
422
|
+
-- link de decisión (PÚBLICO a propósito: lo abre el humano desde el teléfono; el token de un solo uso es la
|
|
423
|
+
-- autorización, como en el `approve` nativo de Synsema bajo serve). GET porque un link en un chat es un GET.
|
|
424
|
+
route "GET /approve/:id/:token"
|
|
425
|
+
let d be when contains(query, "d") then lower(text(query.d)) otherwise ""
|
|
426
|
+
when d != "yes" and d != "no"
|
|
427
|
+
give html(approvals.html_page("¿Permitir?", "<a href='?d=yes' style='display:inline-block;padding:10px 18px;border-radius:4px;background:#1d1c1a;color:#fff;text-decoration:none;margin-right:10px'>Sí, permitir</a> <a href='?d=no' style='display:inline-block;padding:10px 18px;border-radius:4px;border:1px solid #a33;color:#a33;text-decoration:none'>No, denegar</a>"))
|
|
428
|
+
let r be approvals.answer_with_token(text(params.id), text(params.token), d == "yes")
|
|
429
|
+
when not r["ok"]
|
|
430
|
+
give html(approvals.html_page("Este link ya no sirve", "La aprobación venció, ya fue respondida o el link no es válido (" + r["why"] + ")."))
|
|
431
|
+
give html(approvals.html_page(when d == "yes" then "✓ Permitido" otherwise "✗ Denegado", "Lampson sigue con la tarea. Podés cerrar esta pestaña."))
|
|
432
|
+
|
|
433
|
+
-- tareas programadas (lib/schedule.syn): listar, crear, quitar, encender/apagar, correr ahora, log
|
|
434
|
+
route "GET /api/schedules" requires auth
|
|
435
|
+
let age be schedule.daemon_age()
|
|
436
|
+
-- este proceso tiene su propio tick (cron_every): las tareas corren mientras la web esté abierta
|
|
437
|
+
give {"tasks": schedule.summary(), "tick": schedule.TICK_SECONDS, "alive": true, "heartbeat_age": age, "mount_ok": schedule.mount_ok(), "workspace": env("LAMPSON_WORKSPACE", ""), "tz": schedule.tz_offset(), "lamps": lamps.summary()}
|
|
438
|
+
|
|
439
|
+
route "POST /api/schedules/add" requires auth
|
|
440
|
+
let b be json of request
|
|
441
|
+
try
|
|
442
|
+
let t be schedule.add(b)
|
|
443
|
+
give {"ok": true, "task": t, "result": "programada «" + t["name"] + "»: " + schedule.describe_plan(t["plan"]) + " · próxima " + schedule.fmt_local(t["next_run"])}
|
|
444
|
+
recover err
|
|
445
|
+
give fail(400, text(err))
|
|
446
|
+
|
|
447
|
+
route "POST /api/schedules/remove" requires auth
|
|
448
|
+
expect body {id: text}
|
|
449
|
+
let b be json of request
|
|
450
|
+
try
|
|
451
|
+
give {"ok": true, "result": schedule.remove(text(b["id"]))}
|
|
452
|
+
recover err
|
|
453
|
+
give fail(400, text(err))
|
|
454
|
+
|
|
455
|
+
route "POST /api/schedules/toggle" requires auth
|
|
456
|
+
expect body {id: text}
|
|
457
|
+
let b be json of request
|
|
458
|
+
let on be contains(b, "enabled") and b["enabled"] == true
|
|
459
|
+
try
|
|
460
|
+
give {"ok": true, "result": schedule.set_enabled(text(b["id"]), on)}
|
|
461
|
+
recover err
|
|
462
|
+
give fail(400, text(err))
|
|
463
|
+
|
|
464
|
+
-- correr ahora: la marca run_now la levanta el próximo tick (así el request no se queda minutos abierto
|
|
465
|
+
-- y la corrida vive en el hilo del cron, no en el del request)
|
|
466
|
+
route "POST /api/schedules/run" requires auth
|
|
467
|
+
expect body {id: text}
|
|
468
|
+
let b be json of request
|
|
469
|
+
try
|
|
470
|
+
let t be schedule.request_run(text(b["id"]))
|
|
471
|
+
give {"ok": true, "result": "«" + t["name"] + "» corre en el próximo tick (≤ " + text(schedule.TICK_SECONDS) + " s)"}
|
|
472
|
+
recover err
|
|
473
|
+
give fail(400, text(err))
|
|
474
|
+
|
|
475
|
+
route "GET /api/schedules/log" requires auth
|
|
476
|
+
when not contains(query, "id")
|
|
477
|
+
give fail(400, "id required")
|
|
478
|
+
let n be when contains(query, "tail") then floor(number(query.tail)) otherwise 300
|
|
479
|
+
give {"id": query.id, "log": schedule.log_tail(query.id, n), "task": schedule.get(query.id)}
|
|
383
480
|
|
|
384
481
|
route "POST /api/chat" requires auth
|
|
385
482
|
expect body {message: text}
|
|
@@ -396,20 +493,14 @@ serve on 8080
|
|
|
396
493
|
task emit(kind, data, tag)
|
|
397
494
|
trace.event(sid, kind, data, tag)
|
|
398
495
|
send {"kind": kind, "data": data, "tag": tag} as "event"
|
|
399
|
-
-- aprobación humana
|
|
496
|
+
-- aprobación humana: la cola de lib/approvals.syn (id + token) — la UI responde por SSE/POST /api/approve,
|
|
497
|
+
-- y si hay webhook/LAMPSON_PUBLIC_URL también llega un link al canal del usuario (aprobar desde el móvil)
|
|
400
498
|
task ask_web(name, args, why)
|
|
401
|
-
let id be
|
|
499
|
+
let id be approvals.open("chat", permission.describe_call(name, args), why, APPROVAL_TIMEOUT)
|
|
402
500
|
send {"kind": "approval_request", "data": {"id": id, "name": name, "args": args, "why": why, "timeout": APPROVAL_TIMEOUT}} as "event"
|
|
403
|
-
let
|
|
404
|
-
let
|
|
405
|
-
|
|
406
|
-
set answer to state_get("approval:" + id, nothing)
|
|
407
|
-
when answer == nothing
|
|
408
|
-
sleep(0.5)
|
|
409
|
-
set waited to waited + 0.5
|
|
410
|
-
state_delete("approval:" + id)
|
|
411
|
-
let ok be answer == "yes"
|
|
412
|
-
send {"kind": "approval_result", "data": {"id": id, "approved": ok, "timeout": answer == nothing}} as "event"
|
|
501
|
+
let t0 be now()
|
|
502
|
+
let ok be approvals.wait(id, APPROVAL_TIMEOUT)
|
|
503
|
+
send {"kind": "approval_result", "data": {"id": id, "approved": ok, "timeout": (not ok) and now() - t0 >= APPROVAL_TIMEOUT - 1}} as "event"
|
|
413
504
|
give ok
|
|
414
505
|
let ctx be boot(want, mode, ask_web, pname, pmodel)
|
|
415
506
|
let messages be [ctx["system"]]
|