lampson 0.1.4 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/approvals.syn CHANGED
@@ -82,11 +82,14 @@ export task open(source, message, why, timeout_s)
82
82
  notify(meta, links)
83
83
  give id
84
84
 
85
+ -- bajo el hub cada workspace vive en /w/<slug> (LAMPSON_WS); el link público lleva ese prefijo
85
86
  task links_for(id, tok)
87
+ let slug be env("LAMPSON_WS", "")
88
+ let prefix be when slug == "" then "" otherwise "/w/" + slug
86
89
  let base be public_url()
87
90
  when base == ""
88
- give {"yes": "", "no": "", "path": "/approve/" + id + "/" + tok}
89
- give {"yes": base + "/approve/" + id + "/" + tok + "?d=yes", "no": base + "/approve/" + id + "/" + tok + "?d=no", "path": "/approve/" + id + "/" + tok}
91
+ give {"yes": "", "no": "", "path": prefix + "/approve/" + id + "/" + tok}
92
+ give {"yes": base + prefix + "/approve/" + id + "/" + tok + "?d=yes", "no": base + prefix + "/approve/" + id + "/" + tok + "?d=no", "path": prefix + "/approve/" + id + "/" + tok}
90
93
 
91
94
  -- webhook firmado (fire-and-forget)
92
95
  task notify(meta, links)
package/lib/lsp.syn CHANGED
@@ -22,7 +22,16 @@
22
22
 
23
23
  use "./tools/common.syn" as c
24
24
 
25
- export let GLOBAL_CONFIG be ".lampson/lsp.json"
25
+ -- global = compartido por todos los workspaces: .lampson/global/ (junction al .lampson de la instalación) cuando
26
+ -- el proceso corre bajo el hub; .lampson/ en una instalación directa o en los tests
27
+ task global_config()
28
+ try
29
+ when file_exists(".lampson/global")
30
+ give ".lampson/global/lsp.json"
31
+ recover err
32
+ give ".lampson/lsp.json"
33
+ give ".lampson/lsp.json"
34
+ export let GLOBAL_CONFIG be global_config()
26
35
  export let PROJECT_CONFIG be "workspace/.lampson/lsp.json"
27
36
  let CALL_TIMEOUT be 60
28
37
  let START_WAIT be 25
package/lib/mcp.syn CHANGED
@@ -23,7 +23,16 @@
23
23
 
24
24
  use "./tools/common.syn" as c
25
25
 
26
- export let GLOBAL_CONFIG be ".lampson/mcp.json"
26
+ -- global = compartido por todos los workspaces: .lampson/global/ (junction al .lampson de la instalación) cuando
27
+ -- el proceso corre bajo el hub; .lampson/ en una instalación directa o en los tests
28
+ task global_config()
29
+ try
30
+ when file_exists(".lampson/global")
31
+ give ".lampson/global/mcp.json"
32
+ recover err
33
+ give ".lampson/mcp.json"
34
+ give ".lampson/mcp.json"
35
+ export let GLOBAL_CONFIG be global_config()
27
36
  export let PROJECT_CONFIG be "workspace/.lampson/mcp.json"
28
37
  let CALL_TIMEOUT be 120
29
38
 
package/lib/settings.syn CHANGED
@@ -7,7 +7,15 @@
7
7
  --
8
8
  -- Forma: {"provider": "deepseek", "model": "deepseek-chat", "keys": {"deepseek": "sk-…"}}
9
9
 
10
- let PATH be ".lampson/config.json"
10
+ -- bajo el hub cada workspace corre con su propio .lampson/ y una junction .lampson/global → <home>/.lampson:
11
+ -- la config (keys, ⚙) es una sola para todos. Sin junction (instalación directa, tests) es .lampson/config.json
12
+ task cfg_path()
13
+ try
14
+ when file_exists(".lampson/global/config.json") or file_exists(".lampson/global")
15
+ give ".lampson/global/config.json"
16
+ recover err
17
+ give ".lampson/config.json"
18
+ give ".lampson/config.json"
11
19
 
12
20
  export task load()
13
21
  require file.read(".lampson")
@@ -18,7 +26,7 @@ export task load()
18
26
  when env("LAMPSON_NO_CONFIG", "") == "1"
19
27
  give {"provider": "", "model": "", "keys": {}}
20
28
  try
21
- let doc be json_decode(read_file(PATH))
29
+ let doc be json_decode(read_file(cfg_path()))
22
30
  -- claves siempre presentes: quien lee no tiene que chequear (y `and` no cortocircuita)
23
31
  when not contains(doc, "keys")
24
32
  set doc["keys"] to {}
@@ -33,7 +41,7 @@ export task load()
33
41
  export task save(doc)
34
42
  require file(".lampson")
35
43
  require file(".lampson/*")
36
- write_file(PATH, json_encode(doc))
44
+ write_file(cfg_path(), json_encode(doc))
37
45
  give true
38
46
 
39
47
  -- key guardada para un proveedor, como TEXTO ("" si no hay). Sellarla con as_secret antes de usarla.
@@ -71,13 +79,13 @@ export task set_default(name, model)
71
79
 
72
80
  -- valores de configuración generales (rueda de la UI): tz, public_url, webhook_url, webhook_secret…
73
81
  -- Vacío = borrar la clave. Nunca se devuelven secretos al navegador: `values()` marca webhook_secret como has_*.
74
- export let KEYS be ["tz", "public_url", "webhook_url", "webhook_secret"]
82
+ export let KEYS be ["tz", "public_url", "webhook_url", "webhook_secret", "idle_hours"]
75
83
 
76
84
  -- el archivo REAL, ignorando LAMPSON_NO_CONFIG: escribir con el doc en blanco de los tests pisaría provider/keys
77
85
  -- (pasó el 2026-08-29 probando la rueda con un web de test)
78
86
  task load_raw()
79
87
  try
80
- let doc be json_decode(read_file(PATH))
88
+ let doc be json_decode(read_file(cfg_path()))
81
89
  when not contains(doc, "keys")
82
90
  set doc["keys"] to {}
83
91
  when not contains(doc, "provider")
package/lib/skills.syn CHANGED
@@ -28,6 +28,8 @@
28
28
  let ROOTS be [
29
29
  {"dir": ".lampson/skills-global", "source": "global"},
30
30
  {"dir": ".lampson/skills-claude", "source": "global"},
31
+ {"dir": ".lampson/global/skills-global", "source": "global"},
32
+ {"dir": ".lampson/global/skills-claude", "source": "global"},
31
33
  {"dir": "skills", "source": "harness"},
32
34
  {"dir": "workspace/.claude/skills", "source": "project"},
33
35
  {"dir": "workspace/.agents/skills", "source": "project"},
@@ -0,0 +1,557 @@
1
+ -- lib/workspaces.syn — varios workspaces vivos a la vez: registro, un directorio por workspace, procesos y supervisor
2
+ --
3
+ -- Por qué así (ver ../SPEC-WORKSPACES.md): en Synsema las capabilities de archivo Y los `use "./lib/…"` se resuelven
4
+ -- relativos al CWD del proceso, y `proxy to` es estático (se evalúa al arrancar y anexa el path entero). Entonces:
5
+ -- * cada workspace tiene su propio directorio .lampson/ws/<slug>/ que ES el cwd de su proceso:
6
+ -- workspace → junction al proyecto lib public skills lamps memory → junctions a la instalación
7
+ -- web.syn chat.syn → copias (refrescadas al arrancar) .lampson/ → estado propio (sesiones, tareas…)
8
+ -- .lampson/global → junction a <home>/.lampson (config.json con keys, mcp/lsp globales, skills-*)
9
+ -- con eso `file("workspace/*")`, `use "./lib/x.syn"` y todo el código de siempre funcionan SIN cambios.
10
+ -- * cada workspace corre `synsema serve web.syn --port 808N --bind 127.0.0.1` DESACOPLADO del hub (start /b,
11
+ -- process_group:false): sobrevive a reinicios del hub y a cerrar terminales. Solo loopback.
12
+ -- * el hub (hub.syn, :8080) se GENERA desde hub.tpl.syn con una ruta proxy por workspace y corre con
13
+ -- `synsema serve hub.syn` desacoplado: al registrar/quitar un workspace se reescribe y un helper lo reinicia.
14
+ -- * el hub es supervisor: cada 15 s arranca lo que debe estar vivo (política) y apaga lo inactivo.
15
+ --
16
+ -- Este módulo corre SIEMPRE con cwd = instalación (hub.syn y cli.syn); los procesos de workspace no lo usan.
17
+
18
+ use "./tools/bash.syn" as t_bash
19
+ use "./tools/proc.syn" as proc
20
+ use "./settings.syn" as settings
21
+
22
+ export let ROOT be ".lampson/ws"
23
+ export let REGISTRY be ".lampson/workspaces.json"
24
+ export let HUB_PORT be 8080
25
+ export let HUB_FILE be "hub.syn"
26
+ export let HUB_TEMPLATE be "hub.tpl.syn"
27
+ let FIRST_PORT be 8081
28
+ let LINKS be ["lib", "public", "skills", "lamps", "memory"]
29
+ let COPIES be ["web.syn", "chat.syn"]
30
+
31
+ task is_win()
32
+ give env("OS", "") == "Windows_NT"
33
+
34
+ task sh(cmd, t)
35
+ let sc be t_bash.shell_config()
36
+ give run(sc["shell"], [sc["flag"], cmd], t)
37
+
38
+ -- ruta absoluta de la instalación (cwd), en forma Windows si corresponde
39
+ export task home()
40
+ require exec
41
+ require env("LAMPSON_*")
42
+ require env("OS")
43
+ let h be env("LAMPSON_HOME", "")
44
+ when h != ""
45
+ give h
46
+ let r be sh("pwd -W 2>/dev/null || pwd -P", 10)
47
+ let p be trim(text(r["stdout"]))
48
+ when is_win()
49
+ give replace_text(p, "/", "\\")
50
+ give p
51
+
52
+ export task norm(p)
53
+ let s be lower(replace_text(trim(text(p)), "\\", "/"))
54
+ when matches(s, "/[a-z]/.*")
55
+ set s to slice(s, 1, 2) + ":" + slice(s, 2, length(s))
56
+ while length(s) > 1 and slice(s, length(s) - 1, length(s)) == "/"
57
+ set s to slice(s, 0, length(s) - 1)
58
+ give s
59
+
60
+ -- el mismo slug que memo.slug(): nombre limpio + 8 hex del hash de la ruta normalizada → las sesiones y la memoria
61
+ -- guardadas hasta hoy siguen asociadas al mismo workspace
62
+ export task slug_of(path)
63
+ let n be norm(path)
64
+ let parts be where(split(n, "/"), (x) => x != "")
65
+ let base be when length(parts) > 0 then parts[length(parts) - 1] otherwise "workspace"
66
+ let runs be find_all(base, "[a-z0-9]+")
67
+ let clean be when length(runs) == 0 then "project" otherwise join(runs, "-")
68
+ give clean + "-" + slice(decode(sha256(n), "hex"), 0, 8)
69
+
70
+ -- ---------- registro ----------
71
+ task load_reg()
72
+ try
73
+ let doc be json_decode(read_file(REGISTRY))
74
+ when not contains(doc, "workspaces")
75
+ set doc["workspaces"] to {}
76
+ when not contains(doc, "next_port")
77
+ set doc["next_port"] to FIRST_PORT
78
+ give doc
79
+ recover err
80
+ give {"workspaces": {}, "next_port": FIRST_PORT}
81
+
82
+ task save_reg(doc)
83
+ write_file(REGISTRY, json_encode(doc))
84
+
85
+ export task all()
86
+ require file(".lampson")
87
+ require file(".lampson/*")
88
+ let doc be load_reg()
89
+ let out be []
90
+ each k in sort_by(keys(doc["workspaces"]), (x) => 0 - doc["workspaces"][x]["last_used"])
91
+ set out to append(out, doc["workspaces"][k])
92
+ give out
93
+
94
+ export task get(slug)
95
+ require file(".lampson")
96
+ require file(".lampson/*")
97
+ let doc be load_reg()
98
+ when contains(doc["workspaces"], slug)
99
+ give doc["workspaces"][slug]
100
+ give nothing
101
+
102
+ export task by_path(path)
103
+ require file(".lampson")
104
+ require file(".lampson/*")
105
+ let n be norm(path)
106
+ each w in all()
107
+ when norm(w["path"]) == n
108
+ give w
109
+ give nothing
110
+
111
+ export task put(w)
112
+ require file(".lampson")
113
+ require file(".lampson/*")
114
+ require time
115
+ let doc be load_reg()
116
+ set doc["workspaces"][w["slug"]] to w
117
+ save_reg(doc)
118
+ give w
119
+
120
+ export task touch(slug)
121
+ require file(".lampson")
122
+ require file(".lampson/*")
123
+ require time
124
+ let w be get(slug)
125
+ when w == nothing
126
+ give false
127
+ set w["last_used"] to now()
128
+ put(w)
129
+ give true
130
+
131
+ -- ---------- directorio del workspace ----------
132
+ export task dir_of(slug)
133
+ give ROOT + "/" + slug
134
+
135
+ task link(where_, target)
136
+ when is_win()
137
+ let r be run("cmd", ["/c", "mklink", "/J", replace_text(where_, "/", "\\"), replace_text(target, "/", "\\")], 15)
138
+ give r["exit_code"] == 0
139
+ let r be run("ln", ["-s", target, where_], 10)
140
+ give r["exit_code"] == 0
141
+
142
+ task unlink(where_)
143
+ when is_win()
144
+ run("cmd", ["/c", "rmdir", replace_text(where_, "/", "\\")], 10)
145
+ otherwise
146
+ run("rm", ["-f", where_], 10)
147
+ give true
148
+
149
+ task is_link_dir(p)
150
+ give file_exists(p)
151
+
152
+ -- crea/repara .lampson/ws/<slug>: junctions al proyecto y a la instalación, copias de los entries
153
+ export task prepare(w)
154
+ require exec
155
+ require time
156
+ require env("LAMPSON_*")
157
+ require env("OS")
158
+ require file(".lampson")
159
+ require file(".lampson/*")
160
+ require file.read("web.syn")
161
+ require file.read("chat.syn")
162
+ let d be dir_of(w["slug"])
163
+ let h be home()
164
+ let sep be when is_win() then "\\" otherwise "/"
165
+ let fresh be not file_exists(d + "/.lampson/.keep")
166
+ write_file(d + "/.lampson/.keep", "")
167
+ when fresh
168
+ migrate(w, d)
169
+ -- el proyecto (si la junction apunta a otro lado — carpeta movida — se rehace)
170
+ let ws_link be d + "/workspace"
171
+ when file_exists(ws_link)
172
+ let target be current_target(ws_link)
173
+ when target != "" and norm(target) != norm(w["path"])
174
+ unlink(ws_link)
175
+ when not file_exists(ws_link)
176
+ when not link(ws_link, w["path"])
177
+ raise("could not link " + ws_link + " → " + w["path"])
178
+ each n in LINKS
179
+ when not file_exists(d + "/" + n)
180
+ link(d + "/" + n, h + sep + n)
181
+ when not file_exists(d + "/.lampson/global")
182
+ link(d + "/.lampson/global", h + sep + ".lampson")
183
+ each f in COPIES
184
+ write_file(d + "/" + f, read_file(f))
185
+ give d
186
+
187
+ -- estado guardado ANTES de los workspaces (en <home>/.lampson, separado por slug en cada registro): sesiones y trazas
188
+ -- del proyecto, sus tareas programadas y el encendido de lámparas se mueven a <ws>/.lampson la primera vez
189
+ task migrate(w, d)
190
+ let slug be w["slug"]
191
+ let moved be 0
192
+ try
193
+ each e in list_dir(".lampson/sessions")
194
+ when not e["is_dir"]
195
+ let doc be nothing
196
+ try
197
+ set doc to json_decode(read_file(".lampson/sessions/" + e["name"]))
198
+ recover err
199
+ set doc to nothing
200
+ when doc != nothing and contains(doc, "meta") and contains(doc["meta"], "project") and doc["meta"]["project"] == slug
201
+ let id be replace_text(e["name"], ".json", "")
202
+ sh("mkdir -p '" + d + "/.lampson/sessions' '" + d + "/.lampson/trace' && mv -f '.lampson/sessions/" + e["name"] + "' '" + d + "/.lampson/sessions/' && mv -f .lampson/trace/" + id + ".* '" + d + "/.lampson/trace/' 2>/dev/null; true", 20)
203
+ set moved to moved + 1
204
+ recover err
205
+ set moved to moved
206
+ try
207
+ let sdoc be json_decode(read_file(".lampson/schedules.json"))
208
+ let mine be where(sdoc["tasks"], (t) => contains(t, "project") and t["project"] == slug)
209
+ when length(mine) > 0
210
+ write_file(d + "/.lampson/schedules.json", json_encode({"tasks": mine, "seq": when contains(sdoc, "seq") then sdoc["seq"] otherwise length(mine)}))
211
+ set sdoc["tasks"] to where(sdoc["tasks"], (t) => not (contains(t, "project") and t["project"] == slug))
212
+ write_file(".lampson/schedules.json", json_encode(sdoc))
213
+ each t in mine
214
+ sh("mkdir -p '" + d + "/.lampson/schedules' && mv -f '.lampson/schedules/" + t["id"] + ".log' '" + d + "/.lampson/schedules/' 2>/dev/null; true", 10)
215
+ recover err
216
+ set moved to moved
217
+ try
218
+ when file_exists(".lampson/lamps.json")
219
+ write_file(d + "/.lampson/lamps.json", read_file(".lampson/lamps.json"))
220
+ recover err
221
+ set moved to moved
222
+ give moved
223
+
224
+ -- destino real de una junction/symlink (Git Bash: pwd -W dentro)
225
+ task current_target(p)
226
+ try
227
+ let r be sh("cd '" + p + "' && (pwd -W 2>/dev/null || pwd -P)", 10)
228
+ give trim(text(r["stdout"]))
229
+ recover err
230
+ give ""
231
+
232
+ -- registra (si hace falta) y prepara el workspace de una carpeta → la entrada del registro
233
+ export task ensure(path)
234
+ require exec
235
+ require time
236
+ require env("LAMPSON_*")
237
+ require env("OS")
238
+ require file(".lampson")
239
+ require file(".lampson/*")
240
+ require file.read("web.syn")
241
+ require file.read("chat.syn")
242
+ require file.read("hub.tpl.syn")
243
+ require file("hub.syn")
244
+ let existing be by_path(path)
245
+ let w be existing
246
+ when w == nothing
247
+ let doc be load_reg()
248
+ let slug be slug_of(path)
249
+ when contains(doc["workspaces"], slug)
250
+ -- mismo slug, otra ruta (mayúsculas/minúsculas, junction): reasociar
251
+ set w to doc["workspaces"][slug]
252
+ set w["path"] to text(path)
253
+ otherwise
254
+ let port be doc["next_port"]
255
+ set doc["next_port"] to port + 1
256
+ save_reg(doc)
257
+ let parts be where(split(replace_text(text(path), "\\", "/"), "/"), (x) => x != "")
258
+ set w to {"slug": slug, "name": parts[length(parts) - 1], "path": text(path), "port": port, "policy": "auto", "created": now(), "last_used": now()}
259
+ put(w)
260
+ when regen_hub() and hub_alive()
261
+ restart_hub_later()
262
+ prepare(w)
263
+ give w
264
+
265
+ export task remove(slug)
266
+ require exec
267
+ require time
268
+ require env("LAMPSON_*")
269
+ require env("OS")
270
+ require file(".lampson")
271
+ require file(".lampson/*")
272
+ require file.read("hub.tpl.syn")
273
+ require file("hub.syn")
274
+ require net("127.0.0.1")
275
+ let w be get(slug)
276
+ when w == nothing
277
+ raise("no workspace '" + text(slug) + "'")
278
+ stop_ws(w)
279
+ let doc be load_reg()
280
+ let keep be {}
281
+ each k in keys(doc["workspaces"])
282
+ when k != slug
283
+ set keep[k] to doc["workspaces"][k]
284
+ set doc["workspaces"] to keep
285
+ save_reg(doc)
286
+ when regen_hub() and hub_alive()
287
+ restart_hub_later()
288
+ give "workspace '" + slug + "' quitado del registro (la carpeta " + dir_of(slug) + " y el proyecto quedan en disco)"
289
+
290
+ -- ---------- hub generado ----------
291
+ task hub_routes(w)
292
+ let base be "http://127.0.0.1:" + text(w["port"])
293
+ let lines be []
294
+ each m in ["GET", "POST"]
295
+ set lines to append(lines, " route \"" + m + " /w/" + w["slug"] + "/api/*path\"\n proxy to \"" + base + "\"")
296
+ set lines to append(lines, " route \"GET /w/" + w["slug"] + "/approve/*path\"\n proxy to \"" + base + "\"")
297
+ give join(lines, "\n")
298
+
299
+ -- reescribe hub.syn desde la plantilla si cambió (bajo --watch, el hub se reinicia solo)
300
+ export task regen_hub()
301
+ require file(".lampson")
302
+ require file(".lampson/*")
303
+ require file.read("hub.tpl.syn")
304
+ require file("hub.syn")
305
+ let tpl be read_file(HUB_TEMPLATE)
306
+ let blocks be []
307
+ each w in all()
308
+ set blocks to append(blocks, hub_routes(w))
309
+ let body be replace_text(tpl, " -- @workspaces", when length(blocks) == 0 then " -- (sin workspaces registrados todavía)" otherwise join(blocks, "\n"))
310
+ let cur be ""
311
+ try
312
+ set cur to read_file(HUB_FILE)
313
+ recover err
314
+ set cur to ""
315
+ when cur != body
316
+ write_file(HUB_FILE, body)
317
+ give true
318
+ give false
319
+
320
+ -- ---------- procesos ----------
321
+ -- lanzar DESACOPLADO (sobrevive al request y al proceso que lo lanzó); log en <ws>/.lampson/web.log
322
+ -- Windows: `start /b` hereda los handles de stdout → quien capture la salida del lanzador (PowerShell, un pipe)
323
+ -- se queda esperando al nieto. Start-Process con redirección crea handles nuevos: el nieto queda suelto de verdad.
324
+ task spawn_detached(cwd, cmdline, envs)
325
+ when is_win()
326
+ -- WorkingDirectory absoluto: el relativo se resolvería contra el cwd del PowerShell (que ya es `cwd`)
327
+ let h be home()
328
+ let abs be when cwd == "." then h otherwise h + "\\" + replace_text(cwd, "/", "\\")
329
+ let ps be "Start-Process -FilePath cmd -ArgumentList '/c', '" + replace_text(cmdline, "'", "''") + "' -WorkingDirectory '" + replace_text(abs, "'", "''") + "' -WindowStyle Hidden"
330
+ let p be proc_spawn("powershell", ["-NoProfile", "-NonInteractive", "-Command", ps], {"cwd": cwd, "env": envs, "process_group": false})
331
+ proc_wait(p, 15)
332
+ proc_close(p)
333
+ otherwise
334
+ let p be proc_spawn("sh", ["-c", "nohup " + cmdline + " >/dev/null 2>&1 &"], {"cwd": cwd, "env": envs, "process_group": false})
335
+ proc_wait(p, 5)
336
+ proc_close(p)
337
+ give true
338
+
339
+ export task start_ws(w)
340
+ require exec
341
+ require time
342
+ require env("LAMPSON_*")
343
+ require env("OS")
344
+ require file(".lampson")
345
+ require file(".lampson/*")
346
+ require file.read("web.syn")
347
+ require file.read("chat.syn")
348
+ require file.read(".env")
349
+ let d be prepare(w)
350
+ let h be home()
351
+ let sep be when is_win() then "\\" otherwise "/"
352
+ let envf be h + sep + ".env"
353
+ let envs be {"LAMPSON_WS": w["slug"], "LAMPSON_WORKSPACE": w["path"], "LAMPSON_PORT": text(w["port"]), "LAMPSON_HOME": h}
354
+ let exe be env("LAMPSON_SYNSEMA", "synsema")
355
+ -- el .env de la instalación (cwd = home; la capability es relativa, la ruta que se pasa al hijo es absoluta)
356
+ let cmd be exe + " serve web.syn --port " + text(w["port"]) + " --bind 127.0.0.1" + (when file_exists(".env") then " --env-file \"" + envf + "\"" otherwise "") + " > .lampson/web.log 2>&1"
357
+ spawn_detached(d, cmd, envs)
358
+ give true
359
+
360
+ -- ¿responde? → {alive, last_used, schedules_on, version} | nothing
361
+ export task health(w)
362
+ require net("127.0.0.1")
363
+ require time
364
+ try
365
+ let r be http_get("http://127.0.0.1:" + text(w["port"]) + "/w/" + w["slug"] + "/api/health", {"timeout": 2})
366
+ when r["ok"]
367
+ give json_decode(body of r)
368
+ give nothing
369
+ recover err
370
+ give nothing
371
+
372
+ -- esperar hasta que responda (arranque)
373
+ export task wait_ready(w, secs)
374
+ require net("127.0.0.1")
375
+ require time
376
+ let deadline be now() + secs
377
+ while now() < deadline
378
+ when health(w) != nothing
379
+ give true
380
+ sleep(0.4)
381
+ give false
382
+
383
+ export task stop_ws(w)
384
+ require exec
385
+ require time
386
+ require env("LAMPSON_*")
387
+ require env("OS")
388
+ require net("127.0.0.1")
389
+ let victims be where(proc.listeners(), (l) => l["port"] == w["port"])
390
+ each l in victims
391
+ proc.kill_pid(l["pid"])
392
+ give length(victims) > 0
393
+
394
+ -- ---------- hub ----------
395
+ export task hub_alive()
396
+ require net("127.0.0.1")
397
+ require time
398
+ try
399
+ let r be http_get("http://127.0.0.1:" + text(HUB_PORT) + "/api/hub", {"timeout": 2})
400
+ give r["ok"]
401
+ recover err
402
+ give false
403
+
404
+ export task start_hub()
405
+ require exec
406
+ require time
407
+ require env("LAMPSON_*")
408
+ require env("OS")
409
+ require file(".lampson")
410
+ require file(".lampson/*")
411
+ require file.read("hub.tpl.syn")
412
+ require file("hub.syn")
413
+ regen_hub()
414
+ -- desacoplado y con cwd = instalación (synsema daemon arranca el serve con otro cwd y los `use "./lib/…"`,
415
+ -- que son relativos al cwd, no se encuentran). Sin --watch: en esta carpeta el watcher recorre las junctions de
416
+ -- los workspaces (proyectos enteros) y el server nunca llega a escuchar; el reinicio lo hace restart_hub_later.
417
+ let exe be env("LAMPSON_SYNSEMA", "synsema")
418
+ spawn_detached(".", exe + " serve " + HUB_FILE + " > .lampson/hub.log 2>&1", {"LAMPSON_HOME": home()})
419
+ give true
420
+
421
+ -- hub.syn cambió (workspace nuevo/quitado): un helper DESACOPLADO (cli.syn hub-restart) para el hub y lo vuelve a
422
+ -- arrancar — puede llamarse desde el propio hub (que va a morir en el medio) o desde cli.syn
423
+ export task restart_hub_later()
424
+ require exec
425
+ require time
426
+ require env("LAMPSON_*")
427
+ require env("OS")
428
+ let exe be env("LAMPSON_SYNSEMA", "synsema")
429
+ spawn_detached(".", exe + " run cli.syn > .lampson/hub-restart.log 2>&1", {"LAMPSON_HOME": home(), "LAMPSON_CMD": "hub-restart"})
430
+ give true
431
+
432
+ export task stop_hub()
433
+ require exec
434
+ require time
435
+ require env("LAMPSON_*")
436
+ require env("OS")
437
+ let victims be where(proc.listeners(), (l) => l["port"] == HUB_PORT)
438
+ each l in victims
439
+ proc.kill_pid(l["pid"])
440
+ give length(victims) > 0
441
+
442
+ -- ---------- política y supervisor (tick del hub) ----------
443
+ export task idle_hours()
444
+ require file.read(".lampson")
445
+ require file.read(".lampson/*")
446
+ require env("LAMPSON_*")
447
+ let doc be settings.load()
448
+ when contains(doc, "idle_hours")
449
+ let v be number(text(doc["idle_hours"]))
450
+ give when v < 0 then 0 otherwise v
451
+ give 4
452
+
453
+ -- ¿debe estar vivo? always | auto (usado hace < idle_hours, o con tareas encendidas) | off
454
+ export task should_live(w, hc)
455
+ require file.read(".lampson")
456
+ require file.read(".lampson/*")
457
+ require env("LAMPSON_*")
458
+ require time
459
+ let pol be when contains(w, "policy") then w["policy"] otherwise "auto"
460
+ when pol == "always"
461
+ give true
462
+ when pol == "off"
463
+ give false
464
+ when hc != nothing and contains(hc, "schedules_on") and hc["schedules_on"] > 0
465
+ give true
466
+ when schedules_on_disk(w) > 0
467
+ give true
468
+ let ih be idle_hours()
469
+ when ih == 0
470
+ give true
471
+ let last be when hc != nothing and contains(hc, "last_used") and hc["last_used"] != nothing then hc["last_used"] otherwise w["last_used"]
472
+ give now() - last < ih * 3600
473
+
474
+ -- tareas encendidas según el json del workspace (sirve aunque el proceso esté apagado)
475
+ task schedules_on_disk(w)
476
+ try
477
+ let doc be json_decode(read_file(dir_of(w["slug"]) + "/.lampson/schedules.json"))
478
+ give length(where(doc["tasks"], (t) => t["enabled"] == true))
479
+ recover err
480
+ give 0
481
+
482
+ -- un tick: arranca lo que debe vivir y no responde; apaga lo que no debe y responde
483
+ export task tick()
484
+ require exec
485
+ require time
486
+ require env("LAMPSON_*")
487
+ require env("OS")
488
+ require net("127.0.0.1")
489
+ require file(".lampson")
490
+ require file(".lampson/*")
491
+ require file.read("web.syn")
492
+ require file.read("chat.syn")
493
+ let started be []
494
+ let stopped be []
495
+ each w in all()
496
+ let hc be health(w)
497
+ let want be should_live(w, hc)
498
+ when want and hc == nothing
499
+ start_ws(w)
500
+ set started to append(started, w["slug"])
501
+ otherwise when not want and hc != nothing
502
+ stop_ws(w)
503
+ set stopped to append(stopped, w["slug"])
504
+ give {"started": started, "stopped": stopped}
505
+
506
+ -- resumen para la UI del hub
507
+ export task summary()
508
+ require exec
509
+ require time
510
+ require env("LAMPSON_*")
511
+ require env("OS")
512
+ require net("127.0.0.1")
513
+ require file(".lampson")
514
+ require file(".lampson/*")
515
+ let out be []
516
+ each w in all()
517
+ let hc be health(w)
518
+ set out to append(out, {"slug": w["slug"], "name": w["name"], "path": w["path"], "port": w["port"], "policy": when contains(w, "policy") then w["policy"] otherwise "auto", "created": w["created"], "last_used": when hc != nothing and contains(hc, "last_used") and hc["last_used"] != nothing then hc["last_used"] otherwise w["last_used"], "alive": hc != nothing, "schedules_on": when hc != nothing and contains(hc, "schedules_on") then hc["schedules_on"] otherwise schedules_on_disk(w), "url": "/w/" + w["slug"]})
519
+ give out
520
+
521
+ -- ---------- explorador de carpetas ----------
522
+ -- diálogo nativo del SO (PC con escritorio). Devuelve la ruta elegida, "" si canceló, nothing si no hay diálogo.
523
+ export task pick_folder()
524
+ require exec
525
+ require env("LAMPSON_*")
526
+ require env("OS")
527
+ try
528
+ when is_win()
529
+ -- Shell.Application.BrowseForFolder: el diálogo del Explorador; sale al frente aunque este proceso no tenga
530
+ -- ventana (FolderBrowserDialog de WinForms quedaba detrás desde un proceso oculto). 0x40 = con campo de ruta, 0x1 = solo carpetas
531
+ let ps be "$s = New-Object -ComObject Shell.Application; $f = $s.BrowseForFolder(0, 'Elegí la carpeta del proyecto (workspace de Lampson)', 0x41, 0); if ($f) { Write-Output $f.Self.Path }"
532
+ let r be run("powershell", ["-NoProfile", "-STA", "-Command", ps], 300)
533
+ give trim(text(r["stdout"]))
534
+ let r be run("sh", ["-c", "if command -v osascript >/dev/null; then osascript -e 'POSIX path of (choose folder with prompt \"Carpeta del proyecto\")'; elif command -v zenity >/dev/null; then zenity --file-selection --directory --title='Carpeta del proyecto'; else exit 3; fi"], 300)
535
+ when r["exit_code"] == 3
536
+ give nothing
537
+ give trim(text(r["stdout"]))
538
+ recover err
539
+ give nothing
540
+
541
+ -- listado de subcarpetas por shell (VPS sin escritorio): sin capabilities de archivo sobre el disco
542
+ export task browse(path)
543
+ require exec
544
+ require env("LAMPSON_*")
545
+ require env("OS")
546
+ let p be when path == nothing or trim(text(path)) == "" then env("HOME", env("USERPROFILE", "/")) otherwise text(path)
547
+ let r be sh("cd \"" + replace_text(p, "\"", "") + "\" 2>/dev/null && (pwd -W 2>/dev/null || pwd -P) && ls -1 -d -- */ 2>/dev/null", 10)
548
+ let lines be where(split(replace_text(text(r["stdout"]), "\r", ""), "\n"), (x) => trim(x) != "")
549
+ when length(lines) == 0
550
+ give {"path": p, "dirs": [], "error": "no existe o no se puede leer"}
551
+ let here be lines[0]
552
+ when is_win()
553
+ set here to replace_text(here, "/", "\\")
554
+ let dirs be apply(slice(lines, 1, length(lines)), (d) => replace_text(d, "/", ""))
555
+ let parts be where(split(replace_text(here, "\\", "/"), "/"), (x) => x != "")
556
+ let parent be when length(parts) > 1 then join(slice(parts, 0, length(parts) - 1), when is_win() then "\\" otherwise "/") otherwise (when is_win() then parts[0] + "\\" otherwise "/")
557
+ give {"path": here, "parent": parent, "dirs": where(dirs, (d) => not starts_with(d, "."))}