lampson 0.1.3 → 0.2.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/.env.example +9 -0
- package/README.md +87 -4
- package/bin/lampson.js +1 -1
- package/chat.syn +133 -0
- package/cli.syn +68 -0
- package/hub.tpl.syn +127 -0
- package/lampson.ps1 +77 -51
- package/lampson.sh +68 -22
- package/lib/agents.syn +1 -1
- package/lib/approvals.syn +179 -0
- package/lib/lsp.syn +10 -1
- package/lib/mcp.syn +10 -1
- package/lib/permission.syn +18 -0
- package/lib/sched_run.syn +161 -0
- package/lib/schedule.syn +660 -0
- package/lib/session.syn +38 -1
- package/lib/settings.syn +66 -3
- package/lib/skills.syn +2 -0
- package/lib/tools.syn +94 -2
- package/lib/workspaces.syn +555 -0
- package/package.json +3 -1
- package/public/css/chat.css +56 -0
- package/public/css/hub.css +12 -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/hub.html +36 -0
- package/public/index.html +51 -1197
- 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 +100 -0
- package/public/js/core.js +91 -0
- package/public/js/events.js +31 -0
- package/public/js/hub.js +40 -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/public/js/workspaces.js +83 -0
- package/skills/lampson/SKILL.md +21 -0
- package/skills/synsema/SKILL.md +4 -1
- package/web.syn +165 -55
package/lib/schedule.syn
ADDED
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
-- lib/schedule.syn — tareas programadas: "cada 6 h", "todos los días a las 9", "lunes 8:30"
|
|
2
|
+
--
|
|
3
|
+
-- Qué se programa (action.type):
|
|
4
|
+
-- lamp → una tool de una lámpara ENCENDIDA: {lamp, tool, args}. Encenderla ya fue la autorización.
|
|
5
|
+
-- bash → un comando fijo desde el workspace: {command, timeout}. Aprobado UNA vez, al crear la tarea.
|
|
6
|
+
-- prompt → una corrida completa del agente con un texto: {prompt, agent}. Sin humano al lado: corre con el
|
|
7
|
+
-- sobre de permisos que se fijó al crearla (permission: strict | ask | yolo). En `ask`, cuando el
|
|
8
|
+
-- agente quiere algo peligroso la aprobación sale por la cola de lib/approvals.syn (UI web, webhook,
|
|
9
|
+
-- link de un solo uso) y espera hasta approval_timeout; sin respuesta = denegado.
|
|
10
|
+
--
|
|
11
|
+
-- Horarios (campo `when`, texto libre y corto):
|
|
12
|
+
-- every 6h · every 30m · every 2d · cada 6h → intervalo (desde el fin de la corrida anterior)
|
|
13
|
+
-- daily 09:00 · diario 09:00 · 09:00 → todos los días a esa hora LOCAL
|
|
14
|
+
-- mon 08:30 · lun,mie,vie 08:30 · weekdays 09:00 · weekend 10:00 → esos días a esa hora local
|
|
15
|
+
-- Synsema trae `cron_every` (intervalo puro, sin reloj — doc 34-cron): la traducción a hora de reloj vive acá:
|
|
16
|
+
-- un tick cada 30 s compara `now()` con `next_run` de cada tarea. Persistido en .lampson/schedules.json: una
|
|
17
|
+
-- corrida perdida (daemon apagado) se ejecuta al volver y queda marcada como atrasada en el historial.
|
|
18
|
+
--
|
|
19
|
+
-- Quién ejecuta: el proceso residente (`lampson --daemon start` = `synsema daemon start web.syn`, o el web
|
|
20
|
+
-- abierto) — chat.syn en terminal NO tiene tick (el hilo principal está bloqueado en el teclado): desde ahí se
|
|
21
|
+
-- crean, listan y corren a mano (/schedule). Historial: .lampson/schedules/<id>.log + últimas corridas en el json.
|
|
22
|
+
--
|
|
23
|
+
-- Este módulo no importa agents/loop (ciclo con tools.syn): las corridas `prompt` las hace lib/sched_run.syn.
|
|
24
|
+
|
|
25
|
+
use "./tools/bash.syn" as t_bash
|
|
26
|
+
use "./lamps.syn" as lamps
|
|
27
|
+
use "./permission.syn" as permission
|
|
28
|
+
use "./tools/common.syn" as c
|
|
29
|
+
use "./tools/memo.syn" as memo
|
|
30
|
+
use "./settings.syn" as settings
|
|
31
|
+
|
|
32
|
+
export let FILE be ".lampson/schedules.json"
|
|
33
|
+
export let LOG_DIR be ".lampson/schedules"
|
|
34
|
+
export let HEARTBEAT be ".lampson/schedules.heartbeat"
|
|
35
|
+
export let TICK_SECONDS be 30
|
|
36
|
+
let KEEP_HISTORY be 20
|
|
37
|
+
let DAY be 86400
|
|
38
|
+
let DAYS_EN be ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
|
|
39
|
+
let DAYS_ES be ["lun", "mar", "mie", "jue", "vie", "sab", "dom"]
|
|
40
|
+
let DAY_NAMES be ["lunes", "martes", "miércoles", "jueves", "viernes", "sábado", "domingo"]
|
|
41
|
+
|
|
42
|
+
-- ---------- zona horaria ----------
|
|
43
|
+
-- format_time/parse_time trabajan en UTC; el usuario piensa en hora local. LAMPSON_TZ ("-03:00" | "+0200")
|
|
44
|
+
-- gana; si no, se le pregunta al shell (`date +%z`, Git Bash en Windows) una vez por corrida (blackboard).
|
|
45
|
+
export task tz_offset()
|
|
46
|
+
require exec
|
|
47
|
+
require time
|
|
48
|
+
require env("LAMPSON_*")
|
|
49
|
+
require env("OS")
|
|
50
|
+
require file.read(".lampson")
|
|
51
|
+
require file.read(".lampson/*")
|
|
52
|
+
observe "lampson:tz" as cached
|
|
53
|
+
when cached != nothing
|
|
54
|
+
give cached
|
|
55
|
+
let raw be env("LAMPSON_TZ", "")
|
|
56
|
+
when raw == ""
|
|
57
|
+
let doc be settings.load()
|
|
58
|
+
when contains(doc, "tz")
|
|
59
|
+
set raw to text(doc["tz"])
|
|
60
|
+
when raw == ""
|
|
61
|
+
try
|
|
62
|
+
let sc be t_bash.shell_config()
|
|
63
|
+
let r be run(sc["shell"], [sc["flag"], "date +%z"], 10)
|
|
64
|
+
set raw to trim(text(r["stdout"]))
|
|
65
|
+
recover err
|
|
66
|
+
set raw to "+0000"
|
|
67
|
+
let off be parse_offset(raw)
|
|
68
|
+
share off as "lampson:tz"
|
|
69
|
+
give off
|
|
70
|
+
|
|
71
|
+
-- "+0200" | "-03:00" | "-3" → segundos
|
|
72
|
+
export task parse_offset(raw)
|
|
73
|
+
let s be replace_text(trim(raw), ":", "")
|
|
74
|
+
when s == ""
|
|
75
|
+
give 0
|
|
76
|
+
let sign be when starts_with(s, "-") then -1 otherwise 1
|
|
77
|
+
when starts_with(s, "-") or starts_with(s, "+")
|
|
78
|
+
set s to slice(s, 1, length(s))
|
|
79
|
+
when not matches(s, "[0-9]{1,4}")
|
|
80
|
+
give 0
|
|
81
|
+
when length(s) <= 2
|
|
82
|
+
give sign * floor(number(s)) * 3600
|
|
83
|
+
while length(s) < 4
|
|
84
|
+
set s to "0" + s
|
|
85
|
+
give sign * (floor(number(slice(s, 0, 2))) * 3600 + floor(number(slice(s, 2, 4))) * 60)
|
|
86
|
+
|
|
87
|
+
task fmt_offset(off)
|
|
88
|
+
let a be when off < 0 then 0 - off otherwise off
|
|
89
|
+
let h be floor(a / 3600)
|
|
90
|
+
let m be floor((a - h * 3600) / 60)
|
|
91
|
+
give (when off < 0 then "-" otherwise "+") + pad2(h) + ":" + pad2(m)
|
|
92
|
+
|
|
93
|
+
task pad2(n)
|
|
94
|
+
let t be text(floor(n))
|
|
95
|
+
when length(t) < 2
|
|
96
|
+
give "0" + t
|
|
97
|
+
give t
|
|
98
|
+
|
|
99
|
+
-- ---------- parseo del horario ----------
|
|
100
|
+
task day_index(tok)
|
|
101
|
+
let t be lower(tok)
|
|
102
|
+
each e in enumerate(DAYS_EN)
|
|
103
|
+
when starts_with(t, e["item"])
|
|
104
|
+
give e["index"] + 1
|
|
105
|
+
each e in enumerate(DAYS_ES)
|
|
106
|
+
when starts_with(t, e["item"])
|
|
107
|
+
give e["index"] + 1
|
|
108
|
+
when starts_with(t, "mié")
|
|
109
|
+
give 3
|
|
110
|
+
when starts_with(t, "sáb")
|
|
111
|
+
give 6
|
|
112
|
+
give 0
|
|
113
|
+
|
|
114
|
+
task parse_hhmm(tok)
|
|
115
|
+
when not matches(tok, "[0-9]{1,2}(:[0-9]{2})?")
|
|
116
|
+
give nothing
|
|
117
|
+
let parts be split(tok, ":")
|
|
118
|
+
let h be floor(number(parts[0]))
|
|
119
|
+
let m be when length(parts) > 1 then floor(number(parts[1])) otherwise 0
|
|
120
|
+
when h > 23 or m > 59
|
|
121
|
+
give nothing
|
|
122
|
+
give {"hh": h, "mm": m}
|
|
123
|
+
|
|
124
|
+
-- "6h" | "30m" | "2d" | "90s" → segundos
|
|
125
|
+
task parse_span(tok)
|
|
126
|
+
let t be lower(tok)
|
|
127
|
+
when not matches(t, "[0-9]+(\.[0-9]+)?(s|m|h|d|min|h?ora?s?|d[ií]as?|seg(undos)?|minutos?)?")
|
|
128
|
+
give nothing
|
|
129
|
+
let num be number(capture(t, "[0-9]+(?:\.[0-9]+)?"))
|
|
130
|
+
let unit be replace_re(t, "^[0-9.]+", "")
|
|
131
|
+
when unit == "s" or starts_with(unit, "seg")
|
|
132
|
+
give floor(num)
|
|
133
|
+
when unit == "m" or starts_with(unit, "min")
|
|
134
|
+
give floor(num * 60)
|
|
135
|
+
when unit == "" or unit == "h" or starts_with(unit, "hora")
|
|
136
|
+
give floor(num * 3600)
|
|
137
|
+
give floor(num * DAY)
|
|
138
|
+
|
|
139
|
+
-- → {ok, plan: {kind: every|daily|weekly, seconds?, hh?, mm?, days?}, error}
|
|
140
|
+
export task parse_when(spec)
|
|
141
|
+
let s be lower(trim(text(spec)))
|
|
142
|
+
set s to replace_text(replace_text(replace_text(s, " a las ", " "), " at ", " "), "todos los días", "daily")
|
|
143
|
+
set s to replace_text(replace_text(s, "todos los dias", "daily"), "every day", "daily")
|
|
144
|
+
let toks be where(split(replace_text(s, ",", " "), " "), (x) => x != "")
|
|
145
|
+
when length(toks) == 0
|
|
146
|
+
give {"ok": false, "error": "empty schedule"}
|
|
147
|
+
let head be toks[0]
|
|
148
|
+
-- una sola vez: once 2026-08-29 15:14 · today 15:14 · hoy 15:14 · tomorrow 09:00 · mañana 09:00 · in 2h · en 30m
|
|
149
|
+
when head == "in" or head == "en"
|
|
150
|
+
when length(toks) < 2
|
|
151
|
+
give {"ok": false, "error": "in needs a span: in 2h, in 30m"}
|
|
152
|
+
let secs be parse_span(when length(toks) >= 3 and matches(toks[1], "[0-9.]+") then toks[1] + toks[2] otherwise toks[1])
|
|
153
|
+
when secs == nothing or secs < 60
|
|
154
|
+
give {"ok": false, "error": "bad delay '" + join(slice(toks, 1, length(toks)), " ") + "' (min 60 s; e.g. in 2h, in 30m)"}
|
|
155
|
+
give {"ok": true, "plan": {"kind": "once", "delay": secs, "date": "", "rel": "", "hh": 0, "mm": 0}}
|
|
156
|
+
when head == "today" or head == "hoy" or head == "tomorrow" or head == "mañana" or head == "manana"
|
|
157
|
+
let hm be when length(toks) > 1 then parse_hhmm(toks[1]) otherwise nothing
|
|
158
|
+
when hm == nothing
|
|
159
|
+
give {"ok": false, "error": head + " needs a time: " + head + " 15:14"}
|
|
160
|
+
give {"ok": true, "plan": {"kind": "once", "delay": 0, "date": "", "rel": when head == "today" or head == "hoy" then "today" otherwise "tomorrow", "hh": hm["hh"], "mm": hm["mm"]}}
|
|
161
|
+
when head == "once"
|
|
162
|
+
let rest be slice(toks, 1, length(toks))
|
|
163
|
+
when length(rest) < 2 or not matches(rest[0], "[0-9]{4}-[0-9]{2}-[0-9]{2}")
|
|
164
|
+
give {"ok": false, "error": "once needs a date and time: once 2026-08-29 15:14"}
|
|
165
|
+
let hm be parse_hhmm(rest[1])
|
|
166
|
+
when hm == nothing
|
|
167
|
+
give {"ok": false, "error": "bad time '" + rest[1] + "' (HH:MM, 24 h)"}
|
|
168
|
+
give {"ok": true, "plan": {"kind": "once", "delay": 0, "date": rest[0], "rel": "", "hh": hm["hh"], "mm": hm["mm"]}}
|
|
169
|
+
when matches(head, "[0-9]{4}-[0-9]{2}-[0-9]{2}") and length(toks) >= 2
|
|
170
|
+
let hm be parse_hhmm(toks[1])
|
|
171
|
+
when hm == nothing
|
|
172
|
+
give {"ok": false, "error": "bad time '" + toks[1] + "' (HH:MM, 24 h)"}
|
|
173
|
+
give {"ok": true, "plan": {"kind": "once", "delay": 0, "date": head, "rel": "", "hh": hm["hh"], "mm": hm["mm"]}}
|
|
174
|
+
when head == "every" or head == "cada"
|
|
175
|
+
when length(toks) < 2
|
|
176
|
+
give {"ok": false, "error": "every needs a span: every 6h, every 30m, every 2d"}
|
|
177
|
+
let secs be parse_span(when length(toks) >= 3 and matches(toks[1], "[0-9.]+") then toks[1] + toks[2] otherwise toks[1])
|
|
178
|
+
when secs == nothing or secs < 60
|
|
179
|
+
give {"ok": false, "error": "bad interval '" + join(slice(toks, 1, length(toks)), " ") + "' (min 60 s; e.g. every 6h, every 30m, every 2d)"}
|
|
180
|
+
give {"ok": true, "plan": {"kind": "every", "seconds": secs}}
|
|
181
|
+
when head == "daily" or head == "diario" or head == "diaria"
|
|
182
|
+
let hm be when length(toks) > 1 then parse_hhmm(toks[1]) otherwise {"hh": 9, "mm": 0}
|
|
183
|
+
when hm == nothing
|
|
184
|
+
give {"ok": false, "error": "bad time '" + toks[1] + "' (HH:MM, 24 h)"}
|
|
185
|
+
give {"ok": true, "plan": {"kind": "daily", "hh": hm["hh"], "mm": hm["mm"]}}
|
|
186
|
+
let solo be parse_hhmm(head)
|
|
187
|
+
when solo != nothing and length(toks) == 1
|
|
188
|
+
give {"ok": true, "plan": {"kind": "daily", "hh": solo["hh"], "mm": solo["mm"]}}
|
|
189
|
+
-- días de la semana + hora
|
|
190
|
+
let days be []
|
|
191
|
+
let time_tok be nothing
|
|
192
|
+
let i be 0
|
|
193
|
+
each tok in toks
|
|
194
|
+
when tok == "weekly" or tok == "semanal" or tok == "on" or tok == "los"
|
|
195
|
+
set days to days
|
|
196
|
+
otherwise when tok == "weekdays" or tok == "laborables"
|
|
197
|
+
set days to [1, 2, 3, 4, 5]
|
|
198
|
+
otherwise when tok == "weekend" or tok == "finde"
|
|
199
|
+
set days to [6, 7]
|
|
200
|
+
otherwise when day_index(tok) > 0
|
|
201
|
+
set days to append(days, day_index(tok))
|
|
202
|
+
otherwise when parse_hhmm(tok) != nothing
|
|
203
|
+
set time_tok to parse_hhmm(tok)
|
|
204
|
+
otherwise
|
|
205
|
+
give {"ok": false, "error": "cannot understand '" + tok + "' — use: every 6h | daily 09:00 | mon,wed 08:30 | weekdays 09:00"}
|
|
206
|
+
when length(days) == 0
|
|
207
|
+
give {"ok": false, "error": "no day found in '" + spec + "' (mon…sun / lun…dom, weekdays, weekend)"}
|
|
208
|
+
when time_tok == nothing
|
|
209
|
+
set time_tok to {"hh": 9, "mm": 0}
|
|
210
|
+
give {"ok": true, "plan": {"kind": "weekly", "days": sort_by(days, (d) => d), "hh": time_tok["hh"], "mm": time_tok["mm"]}}
|
|
211
|
+
|
|
212
|
+
export task describe_plan(plan)
|
|
213
|
+
when plan["kind"] == "once"
|
|
214
|
+
when plan["delay"] > 0
|
|
215
|
+
give "una vez, en " + (when plan["delay"] >= 3600 then text(floor(plan["delay"] / 3600)) + " h" otherwise text(floor(plan["delay"] / 60)) + " min")
|
|
216
|
+
let hhmm0 be pad2(plan["hh"]) + ":" + pad2(plan["mm"])
|
|
217
|
+
when plan["rel"] != ""
|
|
218
|
+
give "una vez, " + (when plan["rel"] == "today" then "hoy" otherwise "mañana") + " a las " + hhmm0
|
|
219
|
+
give "una vez, el " + plan["date"] + " a las " + hhmm0
|
|
220
|
+
when plan["kind"] == "every"
|
|
221
|
+
let s be plan["seconds"]
|
|
222
|
+
when s >= DAY and s - floor(s / DAY) * DAY == 0
|
|
223
|
+
give "cada " + text(floor(s / DAY)) + (when s == DAY then " día" otherwise " días")
|
|
224
|
+
when s >= 3600 and s - floor(s / 3600) * 3600 == 0
|
|
225
|
+
give "cada " + text(floor(s / 3600)) + " h"
|
|
226
|
+
give "cada " + text(floor(s / 60)) + " min"
|
|
227
|
+
let hhmm be pad2(plan["hh"]) + ":" + pad2(plan["mm"])
|
|
228
|
+
when plan["kind"] == "daily"
|
|
229
|
+
give "todos los días a las " + hhmm
|
|
230
|
+
let names be apply(plan["days"], (d) => DAY_NAMES[d - 1])
|
|
231
|
+
give join(names, ", ") + " a las " + hhmm
|
|
232
|
+
|
|
233
|
+
-- ---------- próxima corrida ----------
|
|
234
|
+
-- (parse_time exige fecha Y hora: solo "%Y-%m-%d" da "not enough for unique date and time")
|
|
235
|
+
task local_day_start(local_ts)
|
|
236
|
+
give parse_time(format_time(local_ts, "%Y-%m-%d") + " 00:00", "%Y-%m-%d %H:%M")
|
|
237
|
+
|
|
238
|
+
-- próxima corrida (timestamp UTC) después de `from`, con `last` = fin de la corrida anterior (o nothing)
|
|
239
|
+
export task next_run(plan, from, last, off)
|
|
240
|
+
require time
|
|
241
|
+
when plan["kind"] == "every"
|
|
242
|
+
give (when last == nothing then from otherwise last) + plan["seconds"]
|
|
243
|
+
-- una sola vez: después de correr no hay próxima (record la apaga)
|
|
244
|
+
when plan["kind"] == "once" and last != nothing
|
|
245
|
+
give nothing
|
|
246
|
+
let local be from + off
|
|
247
|
+
let day0 be local_day_start(local)
|
|
248
|
+
let at be plan["hh"] * 3600 + plan["mm"] * 60
|
|
249
|
+
when plan["kind"] == "once"
|
|
250
|
+
when plan["delay"] > 0
|
|
251
|
+
give from + plan["delay"]
|
|
252
|
+
when plan["date"] != ""
|
|
253
|
+
give parse_time(plan["date"] + " " + pad2(plan["hh"]) + ":" + pad2(plan["mm"]), "%Y-%m-%d %H:%M") - off
|
|
254
|
+
give day0 + (when plan["rel"] == "tomorrow" then DAY otherwise 0) + at - off
|
|
255
|
+
when plan["kind"] == "daily"
|
|
256
|
+
let cand be day0 + at
|
|
257
|
+
when cand <= local
|
|
258
|
+
set cand to cand + DAY
|
|
259
|
+
give cand - off
|
|
260
|
+
let i be 0
|
|
261
|
+
while i < 8
|
|
262
|
+
let d be day0 + i * DAY
|
|
263
|
+
let wd be floor(number(format_time(d + 3600, "%u")))
|
|
264
|
+
when contains(plan["days"], wd) and d + at > local
|
|
265
|
+
give d + at - off
|
|
266
|
+
set i to i + 1
|
|
267
|
+
give from + DAY * 7
|
|
268
|
+
|
|
269
|
+
export task fmt_local(ts)
|
|
270
|
+
require exec
|
|
271
|
+
require time
|
|
272
|
+
require env("LAMPSON_*")
|
|
273
|
+
require env("OS")
|
|
274
|
+
when ts == nothing
|
|
275
|
+
give "—"
|
|
276
|
+
let off be tz_offset()
|
|
277
|
+
give format_time(ts + off, "%Y-%m-%d %H:%M") + " (" + fmt_offset(off) + ")"
|
|
278
|
+
|
|
279
|
+
-- ---------- persistencia ----------
|
|
280
|
+
task load_doc()
|
|
281
|
+
try
|
|
282
|
+
let doc be json_decode(read_file(FILE))
|
|
283
|
+
when not contains(doc, "tasks")
|
|
284
|
+
set doc["tasks"] to []
|
|
285
|
+
give doc
|
|
286
|
+
recover err
|
|
287
|
+
give {"tasks": [], "seq": 0}
|
|
288
|
+
|
|
289
|
+
task save_doc(doc)
|
|
290
|
+
write_file(FILE, json_encode(doc))
|
|
291
|
+
|
|
292
|
+
-- las tareas son DEL WORKSPACE (meta project = slug de la ruta, como las sesiones): en otro proyecto no se ven ni
|
|
293
|
+
-- corren. Una tarea vieja sin marca se adopta en el proyecto que la lee primero.
|
|
294
|
+
export task all()
|
|
295
|
+
require env("LAMPSON_*")
|
|
296
|
+
require file(".lampson")
|
|
297
|
+
require file(".lampson/*")
|
|
298
|
+
let mine be memo.slug()
|
|
299
|
+
let doc be load_doc()
|
|
300
|
+
let out be []
|
|
301
|
+
let fixed be false
|
|
302
|
+
let keep be []
|
|
303
|
+
each t in doc["tasks"]
|
|
304
|
+
when not contains(t, "project")
|
|
305
|
+
set t["project"] to mine
|
|
306
|
+
set fixed to true
|
|
307
|
+
set keep to append(keep, t)
|
|
308
|
+
when t["project"] == mine
|
|
309
|
+
set out to append(out, t)
|
|
310
|
+
when fixed
|
|
311
|
+
set doc["tasks"] to keep
|
|
312
|
+
save_doc(doc)
|
|
313
|
+
give out
|
|
314
|
+
|
|
315
|
+
-- ¿el workspace montado sigue siendo el de esta corrida? Solo hay UNA junction ./workspace: si el usuario abre
|
|
316
|
+
-- lampson en otra carpeta mientras el daemon corre, la junction apunta a otro proyecto y las tareas de este
|
|
317
|
+
-- workspace NO deben correr ahí (tick lo chequea y avisa). Se compara el destino real (readlink) con LAMPSON_WORKSPACE.
|
|
318
|
+
export task mount_ok()
|
|
319
|
+
require exec
|
|
320
|
+
require time
|
|
321
|
+
require env("LAMPSON_*")
|
|
322
|
+
require env("OS")
|
|
323
|
+
let want be norm_path(env("LAMPSON_WORKSPACE", ""))
|
|
324
|
+
when want == ""
|
|
325
|
+
give true
|
|
326
|
+
try
|
|
327
|
+
let sc be t_bash.shell_config()
|
|
328
|
+
-- Git Bash: `readlink -f` devuelve rutas POSIX y mapea %TEMP% a /tmp → `pwd -W` da la forma Windows real
|
|
329
|
+
let r be run(sc["shell"], [sc["flag"], "cd " + c.ROOT + " && (pwd -W 2>/dev/null || pwd -P)"], 10)
|
|
330
|
+
let got be norm_path(trim(text(r["stdout"])))
|
|
331
|
+
when got == ""
|
|
332
|
+
give true
|
|
333
|
+
give got == want
|
|
334
|
+
recover err
|
|
335
|
+
give true
|
|
336
|
+
|
|
337
|
+
task norm_path(p)
|
|
338
|
+
let s be lower(replace_text(trim(p), "\\", "/"))
|
|
339
|
+
-- Git Bash: /c/Users/x → c:/users/x
|
|
340
|
+
when matches(s, "/[a-z]/.*")
|
|
341
|
+
set s to slice(s, 1, 2) + ":" + slice(s, 2, length(s))
|
|
342
|
+
while length(s) > 0 and slice(s, length(s) - 1, length(s)) == "/"
|
|
343
|
+
set s to slice(s, 0, length(s) - 1)
|
|
344
|
+
give s
|
|
345
|
+
|
|
346
|
+
export task get(id)
|
|
347
|
+
require env("LAMPSON_*")
|
|
348
|
+
require file(".lampson")
|
|
349
|
+
require file(".lampson/*")
|
|
350
|
+
each t in all()
|
|
351
|
+
when t["id"] == id
|
|
352
|
+
give t
|
|
353
|
+
give nothing
|
|
354
|
+
|
|
355
|
+
task put(t)
|
|
356
|
+
let doc be load_doc()
|
|
357
|
+
let out be []
|
|
358
|
+
let found be false
|
|
359
|
+
each x in doc["tasks"]
|
|
360
|
+
when x["id"] == t["id"]
|
|
361
|
+
set out to append(out, t)
|
|
362
|
+
set found to true
|
|
363
|
+
otherwise
|
|
364
|
+
set out to append(out, x)
|
|
365
|
+
when not found
|
|
366
|
+
set out to append(out, t)
|
|
367
|
+
set doc["tasks"] to out
|
|
368
|
+
save_doc(doc)
|
|
369
|
+
give t
|
|
370
|
+
|
|
371
|
+
task slug(name)
|
|
372
|
+
let s be lower(replace_re(text(name), "[^A-Za-z0-9]+", "-"))
|
|
373
|
+
set s to replace_re(replace_re(s, "^-+", ""), "-+$", "")
|
|
374
|
+
when s == ""
|
|
375
|
+
give "task"
|
|
376
|
+
when length(s) > 24
|
|
377
|
+
give slice(s, 0, 24)
|
|
378
|
+
give s
|
|
379
|
+
|
|
380
|
+
-- valida y normaliza la acción → {ok, action, error}
|
|
381
|
+
task check_action(action)
|
|
382
|
+
when action == nothing or not contains(action, "type")
|
|
383
|
+
give {"ok": false, "error": "action needs a type: lamp | bash | prompt"}
|
|
384
|
+
let kind be lower(text(action["type"]))
|
|
385
|
+
when kind == "lamp"
|
|
386
|
+
when not contains(action, "lamp") or not contains(action, "tool")
|
|
387
|
+
give {"ok": false, "error": "action lamp needs lamp and tool"}
|
|
388
|
+
let full be lamps.tool_name(text(action["lamp"]), text(action["tool"]))
|
|
389
|
+
when not contains(lamps.names(false), full)
|
|
390
|
+
give {"ok": false, "error": "lamp tool " + full + " is not available (the lamp must exist and be ON)"}
|
|
391
|
+
give {"ok": true, "action": {"type": "lamp", "lamp": text(action["lamp"]), "tool": text(action["tool"]), "args": when contains(action, "args") then action["args"] otherwise {}}}
|
|
392
|
+
when kind == "bash"
|
|
393
|
+
when not contains(action, "command") or trim(text(action["command"])) == ""
|
|
394
|
+
give {"ok": false, "error": "action bash needs a command"}
|
|
395
|
+
let v be permission.evaluate("bash", {"command": text(action["command"])}, "yolo")
|
|
396
|
+
when v["decision"] == "deny"
|
|
397
|
+
give {"ok": false, "error": "command refused: " + v["reason"]}
|
|
398
|
+
when t_bash.looks_like_server(text(action["command"]))
|
|
399
|
+
give {"ok": false, "error": "that looks like a server/watcher; a scheduled command must finish on its own"}
|
|
400
|
+
give {"ok": true, "action": {"type": "bash", "command": text(action["command"]), "timeout": when contains(action, "timeout") then floor(number(action["timeout"])) otherwise 300}}
|
|
401
|
+
when kind == "prompt"
|
|
402
|
+
when not contains(action, "prompt") or trim(text(action["prompt"])) == ""
|
|
403
|
+
give {"ok": false, "error": "action prompt needs the prompt text"}
|
|
404
|
+
let agent be when contains(action, "agent") then lower(text(action["agent"])) otherwise "build"
|
|
405
|
+
when not contains(["build", "plan", "review", "explore"], agent)
|
|
406
|
+
give {"ok": false, "error": "agent must be build | plan | review | explore"}
|
|
407
|
+
give {"ok": true, "action": {"type": "prompt", "prompt": text(action["prompt"]), "agent": agent}}
|
|
408
|
+
give {"ok": false, "error": "unknown action type '" + kind + "' (lamp | bash | prompt)"}
|
|
409
|
+
|
|
410
|
+
-- crear: spec = {name, when, action, permission?, approval_timeout?, notify?} → la tarea (raise si es inválida)
|
|
411
|
+
export task add(spec)
|
|
412
|
+
require exec
|
|
413
|
+
require time
|
|
414
|
+
require env("LAMPSON_*")
|
|
415
|
+
require env("OS")
|
|
416
|
+
require file(".lampson")
|
|
417
|
+
require file(".lampson/*")
|
|
418
|
+
require file.read("lamps")
|
|
419
|
+
require file.read("lamps/*")
|
|
420
|
+
require file("workspace")
|
|
421
|
+
require file("workspace/*")
|
|
422
|
+
when spec == nothing or not contains(spec, "when")
|
|
423
|
+
raise("a schedule needs `when` (every 6h | daily 09:00 | mon,wed 08:30)")
|
|
424
|
+
let pw be parse_when(spec["when"])
|
|
425
|
+
when not pw["ok"]
|
|
426
|
+
raise(pw["error"])
|
|
427
|
+
let ca be check_action(when contains(spec, "action") then spec["action"] otherwise nothing)
|
|
428
|
+
when not ca["ok"]
|
|
429
|
+
raise(ca["error"])
|
|
430
|
+
let name be when contains(spec, "name") and trim(text(spec["name"])) != "" then trim(text(spec["name"])) otherwise default_name(ca["action"])
|
|
431
|
+
let perm be when contains(spec, "permission") then lower(text(spec["permission"])) otherwise "ask"
|
|
432
|
+
when not contains(["strict", "ask", "yolo"], perm)
|
|
433
|
+
raise("permission must be strict | ask | yolo")
|
|
434
|
+
let doc be load_doc()
|
|
435
|
+
let seq be (when contains(doc, "seq") then floor(number(doc["seq"])) otherwise 0) + 1
|
|
436
|
+
set doc["seq"] to seq
|
|
437
|
+
save_doc(doc)
|
|
438
|
+
let id be slug(name) + "-" + text(seq)
|
|
439
|
+
let first be next_run(pw["plan"], now(), nothing, tz_offset())
|
|
440
|
+
when pw["plan"]["kind"] == "once" and first < now()
|
|
441
|
+
raise("that moment has already passed (" + fmt_local(first) + "); one-time tasks need a future time")
|
|
442
|
+
let t be {
|
|
443
|
+
"id": id, "name": name, "project": memo.slug(), "when": trim(text(spec["when"])), "plan": pw["plan"], "action": ca["action"],
|
|
444
|
+
"permission": perm,
|
|
445
|
+
"approval_timeout": when contains(spec, "approval_timeout") then floor(number(spec["approval_timeout"])) otherwise 7200,
|
|
446
|
+
"notify": when contains(spec, "notify") then trim(text(spec["notify"])) otherwise "",
|
|
447
|
+
"enabled": true, "created": now(), "last_run": nothing, "last_status": "", "last_summary": "", "running": false, "run_now": false,
|
|
448
|
+
"next_run": first, "history": []
|
|
449
|
+
}
|
|
450
|
+
put(t)
|
|
451
|
+
give t
|
|
452
|
+
|
|
453
|
+
task default_name(action)
|
|
454
|
+
when action["type"] == "lamp"
|
|
455
|
+
give action["lamp"] + " " + action["tool"]
|
|
456
|
+
when action["type"] == "bash"
|
|
457
|
+
let cmd be action["command"]
|
|
458
|
+
give when length(cmd) > 40 then slice(cmd, 0, 40) + "…" otherwise cmd
|
|
459
|
+
let p be replace_text(action["prompt"], "\n", " ")
|
|
460
|
+
give when length(p) > 40 then slice(p, 0, 40) + "…" otherwise p
|
|
461
|
+
|
|
462
|
+
export task remove(id)
|
|
463
|
+
require env("LAMPSON_*")
|
|
464
|
+
require file(".lampson")
|
|
465
|
+
require file(".lampson/*")
|
|
466
|
+
when get(id) == nothing
|
|
467
|
+
raise("no scheduled task '" + text(id) + "' in this workspace")
|
|
468
|
+
let doc be load_doc()
|
|
469
|
+
let before be length(doc["tasks"])
|
|
470
|
+
set doc["tasks"] to where(doc["tasks"], (t) => t["id"] != id)
|
|
471
|
+
when length(doc["tasks"]) == before
|
|
472
|
+
raise("no scheduled task '" + text(id) + "'")
|
|
473
|
+
save_doc(doc)
|
|
474
|
+
give "scheduled task " + id + " removed"
|
|
475
|
+
|
|
476
|
+
export task set_enabled(id, on)
|
|
477
|
+
require exec
|
|
478
|
+
require time
|
|
479
|
+
require env("LAMPSON_*")
|
|
480
|
+
require env("OS")
|
|
481
|
+
require file(".lampson")
|
|
482
|
+
require file(".lampson/*")
|
|
483
|
+
let t be get(id)
|
|
484
|
+
when t == nothing
|
|
485
|
+
raise("no scheduled task '" + text(id) + "'")
|
|
486
|
+
set t["enabled"] to on == true
|
|
487
|
+
when on
|
|
488
|
+
set t["next_run"] to next_run(t["plan"], now(), nothing, tz_offset())
|
|
489
|
+
when t["plan"]["kind"] == "once" and t["next_run"] < now()
|
|
490
|
+
raise("'" + id + "' was a one-time task for " + describe_plan(t["plan"]) + " and that moment has passed; create a new one")
|
|
491
|
+
put(t)
|
|
492
|
+
give "scheduled task " + id + (when on then " ON · next: " + fmt_local(t["next_run"]) otherwise " OFF")
|
|
493
|
+
|
|
494
|
+
-- sesión de la corrida en curso (para verla en vivo desde la UI)
|
|
495
|
+
export task set_session(id, sid)
|
|
496
|
+
require env("LAMPSON_*")
|
|
497
|
+
require file(".lampson")
|
|
498
|
+
require file(".lampson/*")
|
|
499
|
+
let t be get(id)
|
|
500
|
+
when t == nothing
|
|
501
|
+
give false
|
|
502
|
+
set t["session"] to sid
|
|
503
|
+
put(t)
|
|
504
|
+
give true
|
|
505
|
+
|
|
506
|
+
-- pedir que corra en el próximo tick del daemon
|
|
507
|
+
export task request_run(id)
|
|
508
|
+
require env("LAMPSON_*")
|
|
509
|
+
require file(".lampson")
|
|
510
|
+
require file(".lampson/*")
|
|
511
|
+
let t be get(id)
|
|
512
|
+
when t == nothing
|
|
513
|
+
raise("no scheduled task '" + text(id) + "'")
|
|
514
|
+
set t["run_now"] to true
|
|
515
|
+
put(t)
|
|
516
|
+
give t
|
|
517
|
+
|
|
518
|
+
-- ¿hay un daemon (web.syn) haciendo tick? → segundos desde el último latido, o nothing
|
|
519
|
+
export task daemon_age()
|
|
520
|
+
require time
|
|
521
|
+
require file(".lampson")
|
|
522
|
+
require file(".lampson/*")
|
|
523
|
+
try
|
|
524
|
+
let ts be number(trim(read_file(HEARTBEAT)))
|
|
525
|
+
give now() - ts
|
|
526
|
+
recover err
|
|
527
|
+
give nothing
|
|
528
|
+
|
|
529
|
+
export task heartbeat()
|
|
530
|
+
require time
|
|
531
|
+
require file(".lampson")
|
|
532
|
+
require file(".lampson/*")
|
|
533
|
+
write_file(HEARTBEAT, text(now()))
|
|
534
|
+
give true
|
|
535
|
+
|
|
536
|
+
-- ---------- corridas: lamp y bash (prompt en sched_run.syn) ----------
|
|
537
|
+
export task run_simple(t)
|
|
538
|
+
require exec
|
|
539
|
+
require time
|
|
540
|
+
require env("LAMPSON_*")
|
|
541
|
+
require env("OS")
|
|
542
|
+
require file(".lampson")
|
|
543
|
+
require file(".lampson/*")
|
|
544
|
+
require file.read("lamps")
|
|
545
|
+
require file.read("lamps/*")
|
|
546
|
+
require file("workspace")
|
|
547
|
+
require file("workspace/*")
|
|
548
|
+
let a be t["action"]
|
|
549
|
+
when a["type"] == "lamp"
|
|
550
|
+
give lamps.call(lamps.tool_name(a["lamp"], a["tool"]), a["args"])
|
|
551
|
+
when a["type"] == "bash"
|
|
552
|
+
let v be permission.evaluate("bash", {"command": a["command"]}, "yolo")
|
|
553
|
+
when v["decision"] == "deny"
|
|
554
|
+
give "DENIED by policy (" + v["reason"] + ")"
|
|
555
|
+
give t_bash.tool(a["command"], a["timeout"])
|
|
556
|
+
give "ERROR: prompt tasks run through sched_run"
|
|
557
|
+
|
|
558
|
+
-- marca de inicio (para que la UI muestre «corriendo» y dos ticks no la repitan)
|
|
559
|
+
export task mark_started(t)
|
|
560
|
+
require file(".lampson")
|
|
561
|
+
require file(".lampson/*")
|
|
562
|
+
require time
|
|
563
|
+
set t["running"] to true
|
|
564
|
+
set t["run_now"] to false
|
|
565
|
+
put(t)
|
|
566
|
+
append_file(LOG_DIR + "/" + t["id"] + ".log", "\n══ " + format_time(now()) + " · " + t["name"] + " · " + t["action"]["type"] + "\n")
|
|
567
|
+
bus_publish("schedule.started", {"id": t["id"], "name": t["name"]})
|
|
568
|
+
give t
|
|
569
|
+
|
|
570
|
+
-- cierre de una corrida: historial (json + log), próxima corrida, webhook de resultado
|
|
571
|
+
export task record(t, started, status, output, extra)
|
|
572
|
+
require net
|
|
573
|
+
require time
|
|
574
|
+
require exec
|
|
575
|
+
require env("LAMPSON_*")
|
|
576
|
+
require env("OS")
|
|
577
|
+
require file(".lampson")
|
|
578
|
+
require file(".lampson/*")
|
|
579
|
+
let fresh be get(t["id"])
|
|
580
|
+
when fresh == nothing
|
|
581
|
+
set fresh to t
|
|
582
|
+
let fin be now()
|
|
583
|
+
let out be text(output)
|
|
584
|
+
let summary be one_line(out, 200)
|
|
585
|
+
let late be fresh["next_run"] != nothing and started - fresh["next_run"] > TICK_SECONDS * 4
|
|
586
|
+
let entry be {"started": started, "finished": fin, "status": status, "summary": summary, "late": late}
|
|
587
|
+
each k in keys(extra)
|
|
588
|
+
set entry[k] to extra[k]
|
|
589
|
+
let hist be append(fresh["history"], entry)
|
|
590
|
+
when length(hist) > KEEP_HISTORY
|
|
591
|
+
set hist to slice(hist, length(hist) - KEEP_HISTORY, length(hist))
|
|
592
|
+
set fresh["history"] to hist
|
|
593
|
+
set fresh["running"] to false
|
|
594
|
+
set fresh["last_run"] to fin
|
|
595
|
+
set fresh["last_status"] to status
|
|
596
|
+
set fresh["last_summary"] to summary
|
|
597
|
+
set fresh["next_run"] to next_run(fresh["plan"], fin, fin, tz_offset())
|
|
598
|
+
when fresh["plan"]["kind"] == "once"
|
|
599
|
+
set fresh["enabled"] to false
|
|
600
|
+
put(fresh)
|
|
601
|
+
append_file(LOG_DIR + "/" + t["id"] + ".log", out + "\n── " + status + " · " + text(floor(fin - started)) + " s" + (when late then " · ATRASADA (el daemon no estaba corriendo a la hora prevista)" otherwise "") + "\n")
|
|
602
|
+
bus_publish("schedule.done", {"id": t["id"], "name": t["name"], "status": status})
|
|
603
|
+
when fresh["notify"] != ""
|
|
604
|
+
let payload be {"type": "schedule.done", "id": fresh["id"], "name": fresh["name"], "status": status, "started": started, "finished": fin, "summary": summary, "output": c.truncate(out, 20000)}
|
|
605
|
+
each k in keys(extra)
|
|
606
|
+
set payload[k] to extra[k]
|
|
607
|
+
try
|
|
608
|
+
let r be http_post(fresh["notify"], json_encode(payload), {"Content-Type": "application/json", "User-Agent": "lampson"})
|
|
609
|
+
when not r["ok"]
|
|
610
|
+
append_file(LOG_DIR + "/" + t["id"] + ".log", "── notify " + fresh["notify"] + " → " + text(r["status"]) + "\n")
|
|
611
|
+
recover err
|
|
612
|
+
append_file(LOG_DIR + "/" + t["id"] + ".log", "── notify failed: " + text(err) + "\n")
|
|
613
|
+
give fresh
|
|
614
|
+
|
|
615
|
+
task one_line(s, max)
|
|
616
|
+
let t be trim(replace_text(replace_text(text(s), "\r", ""), "\n", " ⏎ "))
|
|
617
|
+
when length(t) > max
|
|
618
|
+
give slice(t, 0, max) + "…"
|
|
619
|
+
give t
|
|
620
|
+
|
|
621
|
+
-- tareas que deben correr ahora
|
|
622
|
+
export task due(ts)
|
|
623
|
+
require env("LAMPSON_*")
|
|
624
|
+
require file(".lampson")
|
|
625
|
+
require file(".lampson/*")
|
|
626
|
+
give where(all(), (t) => t["enabled"] and not t["running"] and (t["run_now"] or (t["next_run"] != nothing and t["next_run"] <= ts)))
|
|
627
|
+
|
|
628
|
+
export task log_tail(id, n)
|
|
629
|
+
require file(".lampson")
|
|
630
|
+
require file(".lampson/*")
|
|
631
|
+
when not matches(text(id), "[a-z0-9-]{1,40}")
|
|
632
|
+
give ""
|
|
633
|
+
try
|
|
634
|
+
let lines be split(replace_text(read_file(LOG_DIR + "/" + id + ".log"), "\r", ""), "\n")
|
|
635
|
+
when length(lines) > n
|
|
636
|
+
set lines to slice(lines, length(lines) - n, length(lines))
|
|
637
|
+
give join(lines, "\n")
|
|
638
|
+
recover err
|
|
639
|
+
give ""
|
|
640
|
+
|
|
641
|
+
-- descripción de la acción (una línea) para previews y listados
|
|
642
|
+
export task describe_action(a)
|
|
643
|
+
when a["type"] == "lamp"
|
|
644
|
+
give "lamp " + a["lamp"] + "." + a["tool"] + (when length(keys(a["args"])) > 0 then " " + one_line(json_encode(a["args"]), 80) otherwise "")
|
|
645
|
+
when a["type"] == "bash"
|
|
646
|
+
give "$ " + one_line(a["command"], 120)
|
|
647
|
+
give "agent " + a["agent"] + ": " + one_line(a["prompt"], 140)
|
|
648
|
+
|
|
649
|
+
-- resumen para UI/terminal/tool
|
|
650
|
+
export task summary()
|
|
651
|
+
require exec
|
|
652
|
+
require time
|
|
653
|
+
require env("LAMPSON_*")
|
|
654
|
+
require env("OS")
|
|
655
|
+
require file(".lampson")
|
|
656
|
+
require file(".lampson/*")
|
|
657
|
+
let out be []
|
|
658
|
+
each t in all()
|
|
659
|
+
set out to append(out, {"id": t["id"], "name": t["name"], "when": t["when"], "plan": describe_plan(t["plan"]), "action": t["action"], "action_text": describe_action(t["action"]), "permission": t["permission"], "approval_timeout": t["approval_timeout"], "notify": t["notify"], "enabled": t["enabled"], "running": t["running"], "run_now": t["run_now"], "session": when contains(t, "session") then t["session"] otherwise nothing, "last_run": t["last_run"], "last_status": t["last_status"], "last_summary": t["last_summary"], "next_run": t["next_run"], "next_local": fmt_local(when t["enabled"] then t["next_run"] otherwise nothing), "last_local": fmt_local(t["last_run"]), "history": t["history"]})
|
|
660
|
+
give out
|