@geohar/un-bien 0.7.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/LICENSE +21 -0
- package/README.md +726 -0
- package/dist/actions/handlers.d.ts +77 -0
- package/dist/actions/handlers.js +26 -0
- package/dist/actions/handlers.js.map +1 -0
- package/dist/actions/registry.d.ts +39 -0
- package/dist/actions/registry.js +62 -0
- package/dist/actions/registry.js.map +1 -0
- package/dist/bin/supervisord.d.ts +2 -0
- package/dist/bin/supervisord.js +86 -0
- package/dist/bin/supervisord.js.map +1 -0
- package/dist/config.d.ts +84 -0
- package/dist/config.js +115 -0
- package/dist/config.js.map +1 -0
- package/dist/daemon/client.d.ts +20 -0
- package/dist/daemon/client.js +131 -0
- package/dist/daemon/client.js.map +1 -0
- package/dist/daemon/control_protocol.d.ts +176 -0
- package/dist/daemon/control_protocol.js +63 -0
- package/dist/daemon/control_protocol.js.map +1 -0
- package/dist/daemon/cron_log.d.ts +45 -0
- package/dist/daemon/cron_log.js +71 -0
- package/dist/daemon/cron_log.js.map +1 -0
- package/dist/daemon/cron_registry.d.ts +80 -0
- package/dist/daemon/cron_registry.js +198 -0
- package/dist/daemon/cron_registry.js.map +1 -0
- package/dist/daemon/id.d.ts +24 -0
- package/dist/daemon/id.js +36 -0
- package/dist/daemon/id.js.map +1 -0
- package/dist/daemon/install.d.ts +170 -0
- package/dist/daemon/install.js +603 -0
- package/dist/daemon/install.js.map +1 -0
- package/dist/daemon/registry.d.ts +63 -0
- package/dist/daemon/registry.js +153 -0
- package/dist/daemon/registry.js.map +1 -0
- package/dist/daemon/rpc_child.d.ts +194 -0
- package/dist/daemon/rpc_child.js +419 -0
- package/dist/daemon/rpc_child.js.map +1 -0
- package/dist/daemon/supervisor.d.ts +93 -0
- package/dist/daemon/supervisor.js +666 -0
- package/dist/daemon/supervisor.js.map +1 -0
- package/dist/extension_ui_bridge.d.ts +26 -0
- package/dist/extension_ui_bridge.js +341 -0
- package/dist/extension_ui_bridge.js.map +1 -0
- package/dist/index.d.ts +197 -0
- package/dist/index.js +5750 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp/mesh_result.d.ts +10 -0
- package/dist/mcp/mesh_result.js +42 -0
- package/dist/mcp/mesh_result.js.map +1 -0
- package/dist/mcp/mesh_server.d.ts +16 -0
- package/dist/mcp/mesh_server.js +373 -0
- package/dist/mcp/mesh_server.js.map +1 -0
- package/dist/mesh/canonical.d.ts +30 -0
- package/dist/mesh/canonical.js +63 -0
- package/dist/mesh/canonical.js.map +1 -0
- package/dist/mesh/client.d.ts +18 -0
- package/dist/mesh/client.js +127 -0
- package/dist/mesh/client.js.map +1 -0
- package/dist/mesh/encoding.d.ts +38 -0
- package/dist/mesh/encoding.js +217 -0
- package/dist/mesh/encoding.js.map +1 -0
- package/dist/mesh/self_revoke.d.ts +73 -0
- package/dist/mesh/self_revoke.js +407 -0
- package/dist/mesh/self_revoke.js.map +1 -0
- package/dist/mesh/siblings.d.ts +49 -0
- package/dist/mesh/siblings.js +159 -0
- package/dist/mesh/siblings.js.map +1 -0
- package/dist/mesh/types.d.ts +34 -0
- package/dist/mesh/types.js +11 -0
- package/dist/mesh/types.js.map +1 -0
- package/dist/mesh/verify.d.ts +17 -0
- package/dist/mesh/verify.js +86 -0
- package/dist/mesh/verify.js.map +1 -0
- package/dist/pairing/crypto.d.ts +8 -0
- package/dist/pairing/crypto.js +22 -0
- package/dist/pairing/crypto.js.map +1 -0
- package/dist/pairing/qr.d.ts +59 -0
- package/dist/pairing/qr.js +127 -0
- package/dist/pairing/qr.js.map +1 -0
- package/dist/pairing/storage.d.ts +120 -0
- package/dist/pairing/storage.js +614 -0
- package/dist/pairing/storage.js.map +1 -0
- package/dist/panel_bridge.d.ts +15 -0
- package/dist/panel_bridge.js +409 -0
- package/dist/panel_bridge.js.map +1 -0
- package/dist/paths.d.ts +39 -0
- package/dist/paths.js +51 -0
- package/dist/paths.js.map +1 -0
- package/dist/protocol/codec.d.ts +7 -0
- package/dist/protocol/codec.js +48 -0
- package/dist/protocol/codec.js.map +1 -0
- package/dist/protocol/types.d.ts +404 -0
- package/dist/protocol/types.js +2 -0
- package/dist/protocol/types.js.map +1 -0
- package/dist/rooms.d.ts +42 -0
- package/dist/rooms.js +78 -0
- package/dist/rooms.js.map +1 -0
- package/dist/session/bridge.d.ts +37 -0
- package/dist/session/bridge.js +134 -0
- package/dist/session/bridge.js.map +1 -0
- package/dist/session/broker.d.ts +189 -0
- package/dist/session/broker.js +527 -0
- package/dist/session/broker.js.map +1 -0
- package/dist/session/broker_remote.d.ts +74 -0
- package/dist/session/broker_remote.js +581 -0
- package/dist/session/broker_remote.js.map +1 -0
- package/dist/session/cwd_lock.d.ts +33 -0
- package/dist/session/cwd_lock.js +127 -0
- package/dist/session/cwd_lock.js.map +1 -0
- package/dist/session/debug_log.d.ts +3 -0
- package/dist/session/debug_log.js +32 -0
- package/dist/session/debug_log.js.map +1 -0
- package/dist/session/envelope.d.ts +33 -0
- package/dist/session/envelope.js +117 -0
- package/dist/session/envelope.js.map +1 -0
- package/dist/session/global_config.d.ts +33 -0
- package/dist/session/global_config.js +73 -0
- package/dist/session/global_config.js.map +1 -0
- package/dist/session/ipc.d.ts +27 -0
- package/dist/session/ipc.js +22 -0
- package/dist/session/ipc.js.map +1 -0
- package/dist/session/leader_election.d.ts +32 -0
- package/dist/session/leader_election.js +108 -0
- package/dist/session/leader_election.js.map +1 -0
- package/dist/session/local_config.d.ts +67 -0
- package/dist/session/local_config.js +193 -0
- package/dist/session/local_config.js.map +1 -0
- package/dist/session/mesh_node.d.ts +116 -0
- package/dist/session/mesh_node.js +596 -0
- package/dist/session/mesh_node.js.map +1 -0
- package/dist/session/peer.d.ts +139 -0
- package/dist/session/peer.js +474 -0
- package/dist/session/peer.js.map +1 -0
- package/dist/session/peer_inventory.d.ts +13 -0
- package/dist/session/peer_inventory.js +48 -0
- package/dist/session/peer_inventory.js.map +1 -0
- package/dist/session/peer_limits.d.ts +9 -0
- package/dist/session/peer_limits.js +24 -0
- package/dist/session/peer_limits.js.map +1 -0
- package/dist/session/rpc_envelope.d.ts +108 -0
- package/dist/session/rpc_envelope.js +221 -0
- package/dist/session/rpc_envelope.js.map +1 -0
- package/dist/session/rpc_inbound.d.ts +55 -0
- package/dist/session/rpc_inbound.js +136 -0
- package/dist/session/rpc_inbound.js.map +1 -0
- package/dist/session/setup_wizard.d.ts +39 -0
- package/dist/session/setup_wizard.js +63 -0
- package/dist/session/setup_wizard.js.map +1 -0
- package/dist/session/tools.d.ts +24 -0
- package/dist/session/tools.js +272 -0
- package/dist/session/tools.js.map +1 -0
- package/dist/session/wizard.d.ts +13 -0
- package/dist/session/wizard.js +20 -0
- package/dist/session/wizard.js.map +1 -0
- package/dist/transport/peer_channel.d.ts +52 -0
- package/dist/transport/peer_channel.js +145 -0
- package/dist/transport/peer_channel.js.map +1 -0
- package/dist/transport/pi_forward_client.d.ts +29 -0
- package/dist/transport/pi_forward_client.js +68 -0
- package/dist/transport/pi_forward_client.js.map +1 -0
- package/dist/transport/relay_client.d.ts +96 -0
- package/dist/transport/relay_client.js +210 -0
- package/dist/transport/relay_client.js.map +1 -0
- package/dist/ui/footer.d.ts +32 -0
- package/dist/ui/footer.js +34 -0
- package/dist/ui/footer.js.map +1 -0
- package/docs/daemon.md +289 -0
- package/package.json +92 -0
- package/service-templates/launchd.plist.template +35 -0
- package/service-templates/systemd.service.template +19 -0
- package/service-templates/task-launcher.vbs.template +10 -0
- package/service-templates/task-scheduler.xml.template +38 -0
- package/skills/agent-network/SKILL.md +310 -0
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { createConnection, createServer, } from "node:net";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { unbienStateHome } from "../paths.js";
|
|
5
|
+
import { addDaemon, listDaemons, migrateRegistryNames, removeDaemon, } from "./registry.js";
|
|
6
|
+
import { daemonIdForCwd } from "./id.js";
|
|
7
|
+
import { defaultAgentName } from "../session/local_config.js";
|
|
8
|
+
import { ipcAddress, usesNamedPipe } from "../session/ipc.js";
|
|
9
|
+
import { EXIT_DAEMON_FRESH_SESSION, RpcChild, } from "./rpc_child.js";
|
|
10
|
+
import { encodeReply, parseRequest, } from "./control_protocol.js";
|
|
11
|
+
import { Cron } from "croner";
|
|
12
|
+
import { addJob as addCronJob, getJob as getCronJob, listJobs as listCronJobs, nextRunFor, recordRun, removeJob as removeCronJob, setJobEnabled, validateSchedule, } from "./cron_registry.js";
|
|
13
|
+
import { appendCronLog, readCronLog } from "./cron_log.js";
|
|
14
|
+
/**
|
|
15
|
+
* Central process that owns the daemon fleet (plan/26).
|
|
16
|
+
*
|
|
17
|
+
* Responsibilities:
|
|
18
|
+
* - Spawn one `pi --mode rpc` child per registry entry. Track them in
|
|
19
|
+
* `_children: Map<id, RpcChild>`.
|
|
20
|
+
* - Auto-restart crashed children with exponential backoff
|
|
21
|
+
* (1s, 5s, 30s, 5min). Give up after 4 attempts to avoid log spam
|
|
22
|
+
* when the agent is misconfigured.
|
|
23
|
+
* - Listen on `~/.pi/un-bien/supervisor.sock` for `ControlRequest`s from
|
|
24
|
+
* the `un-bien` CLI. Each connection: 1 request → 1 reply → close.
|
|
25
|
+
* - Graceful shutdown on SIGTERM/SIGINT: stop all children + unlink
|
|
26
|
+
* the UDS file so a next supervisor can bind cleanly.
|
|
27
|
+
*
|
|
28
|
+
* The supervisor itself is the only long-running process the user
|
|
29
|
+
* installs as a system service (plan/26 W3 will generate the unit/plist).
|
|
30
|
+
* If it crashes, systemd/launchd restarts it; on restart it re-reads
|
|
31
|
+
* the registry and re-spawns everything.
|
|
32
|
+
*/
|
|
33
|
+
const SUPERVISOR_SOCK_NAME = "supervisor.sock";
|
|
34
|
+
/** Backoff schedule for auto-restart after a crash. After exhausting, the
|
|
35
|
+
* child stays in `crashed` state until manual `restart_all` or fresh
|
|
36
|
+
* registry add. Keeps logs sane when the agent dies on every boot. */
|
|
37
|
+
const RESTART_BACKOFFS_MS = [1_000, 5_000, 30_000, 5 * 60_000];
|
|
38
|
+
function supervisorSockPath() {
|
|
39
|
+
// POSIX → ~/.pi/un-bien/supervisor.sock; Windows → per-user named pipe (plan/40).
|
|
40
|
+
return ipcAddress("supervisor", join(unbienStateHome(), SUPERVISOR_SOCK_NAME));
|
|
41
|
+
}
|
|
42
|
+
/** Thrown by `start()` when another live supervisor already holds the UDS.
|
|
43
|
+
* Prevents a second supervisor from orphaning the first's children. */
|
|
44
|
+
export class SupervisorAlreadyRunningError extends Error {
|
|
45
|
+
sockPath;
|
|
46
|
+
constructor(sockPath) {
|
|
47
|
+
super(`Another pi-supervisord is already running (UDS held at ${sockPath}). ` +
|
|
48
|
+
"Refusing to start a second instance. Use `unbien daemon …` to control it, " +
|
|
49
|
+
"or stop the running one first.");
|
|
50
|
+
this.sockPath = sockPath;
|
|
51
|
+
this.name = "SupervisorAlreadyRunningError";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Probes whether a live supervisor is accepting connections on `path`.
|
|
55
|
+
* Resolves true if the connect succeeds (a listener is there), false on
|
|
56
|
+
* ECONNREFUSED / ENOENT (stale socket file from a crashed supervisor). */
|
|
57
|
+
function _probeSupervisor(path) {
|
|
58
|
+
return new Promise((resolve) => {
|
|
59
|
+
const sock = createConnection({ path });
|
|
60
|
+
const done = (alive) => {
|
|
61
|
+
sock.removeAllListeners();
|
|
62
|
+
sock.destroy();
|
|
63
|
+
resolve(alive);
|
|
64
|
+
};
|
|
65
|
+
const timer = setTimeout(() => done(false), 1_000);
|
|
66
|
+
sock.once("connect", () => {
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
done(true);
|
|
69
|
+
});
|
|
70
|
+
sock.once("error", () => {
|
|
71
|
+
clearTimeout(timer);
|
|
72
|
+
done(false);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
export function decideFireAction(o) {
|
|
77
|
+
if (!o.running)
|
|
78
|
+
return o.wake ? "wake_and_send" : "skip_down";
|
|
79
|
+
if (o.skipIfBusy && o.busy)
|
|
80
|
+
return "skip_busy";
|
|
81
|
+
return "send";
|
|
82
|
+
}
|
|
83
|
+
export class Supervisor {
|
|
84
|
+
opts;
|
|
85
|
+
server = null;
|
|
86
|
+
children = new Map();
|
|
87
|
+
/** Live croner schedules, keyed by cron job id (plan/39). */
|
|
88
|
+
cronJobs = new Map();
|
|
89
|
+
shuttingDown = false;
|
|
90
|
+
constructor(opts) {
|
|
91
|
+
this.opts = opts;
|
|
92
|
+
}
|
|
93
|
+
/** Bind the control UDS + spawn all registered daemons. */
|
|
94
|
+
async start() {
|
|
95
|
+
this._mkdirParent();
|
|
96
|
+
// Backfill folder-derived names into legacy registry entries (pre-name
|
|
97
|
+
// field) so every daemon has a stable name to inject via env.
|
|
98
|
+
migrateRegistryNames();
|
|
99
|
+
await this._bindUds();
|
|
100
|
+
this._spawnAllFromRegistry();
|
|
101
|
+
// Cron (plan/39): schedule all enabled jobs, then run any missed catchup.
|
|
102
|
+
this._reconcileCron();
|
|
103
|
+
this._runCatchup();
|
|
104
|
+
}
|
|
105
|
+
/** Graceful shutdown: stop all children, close UDS. */
|
|
106
|
+
async stop() {
|
|
107
|
+
this.shuttingDown = true;
|
|
108
|
+
// Stop all cron schedules (plan/39) so no fire races with teardown.
|
|
109
|
+
for (const c of this.cronJobs.values())
|
|
110
|
+
c.stop();
|
|
111
|
+
this.cronJobs.clear();
|
|
112
|
+
// Cancel pending restart timers first so they don't race with stop().
|
|
113
|
+
for (const slot of this.children.values()) {
|
|
114
|
+
if (slot.restartTimer !== null) {
|
|
115
|
+
clearTimeout(slot.restartTimer);
|
|
116
|
+
slot.restartTimer = null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
await Promise.all([...this.children.values()].map((s) => s.child.stop()));
|
|
120
|
+
this.children.clear();
|
|
121
|
+
await new Promise((resolve) => {
|
|
122
|
+
if (!this.server)
|
|
123
|
+
return resolve();
|
|
124
|
+
this.server.close(() => resolve());
|
|
125
|
+
});
|
|
126
|
+
this.server = null;
|
|
127
|
+
// Best-effort: clear the socket file so a next supervisor bind succeeds.
|
|
128
|
+
// Windows named pipes have no file (auto-removed on exit) → nothing to do.
|
|
129
|
+
if (!usesNamedPipe()) {
|
|
130
|
+
try {
|
|
131
|
+
unlinkSync(supervisorSockPath());
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
/* ignored */
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// ── UDS binding ──────────────────────────────────────────────────────────
|
|
139
|
+
_mkdirParent() {
|
|
140
|
+
// A named pipe has no parent directory to create (the addr is `\\.\pipe\…`).
|
|
141
|
+
if (usesNamedPipe())
|
|
142
|
+
return;
|
|
143
|
+
mkdirSync(dirname(supervisorSockPath()), { recursive: true });
|
|
144
|
+
}
|
|
145
|
+
async _bindUds() {
|
|
146
|
+
const path = supervisorSockPath();
|
|
147
|
+
const pipe = usesNamedPipe();
|
|
148
|
+
// Single-instance guard. PROBE first: a live supervisor answering the
|
|
149
|
+
// connect means we must NOT start a second one. Stealing the socket
|
|
150
|
+
// (unlink + bind) would orphan the running supervisor's children — they'd
|
|
151
|
+
// keep running, unreachable by the CLI. Only on POSIX, when the probe
|
|
152
|
+
// fails (stale socket from a crash), do we unlink + bind. On Windows there
|
|
153
|
+
// is no file: always probe, never unlink (the pipe self-cleans on exit).
|
|
154
|
+
if (pipe || existsSync(path)) {
|
|
155
|
+
const alive = await _probeSupervisor(path);
|
|
156
|
+
if (alive) {
|
|
157
|
+
throw new SupervisorAlreadyRunningError(path);
|
|
158
|
+
}
|
|
159
|
+
if (!pipe) {
|
|
160
|
+
try {
|
|
161
|
+
unlinkSync(path);
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
/* will throw on bind if still held */
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const server = createServer((socket) => this._onConnection(socket));
|
|
169
|
+
await new Promise((resolve, reject) => {
|
|
170
|
+
server.once("error", reject);
|
|
171
|
+
server.listen(path, () => resolve());
|
|
172
|
+
});
|
|
173
|
+
this.server = server;
|
|
174
|
+
}
|
|
175
|
+
_onConnection(socket) {
|
|
176
|
+
let buf = "";
|
|
177
|
+
socket.setEncoding("utf8");
|
|
178
|
+
socket.on("data", (chunk) => {
|
|
179
|
+
buf += chunk;
|
|
180
|
+
const nl = buf.indexOf("\n");
|
|
181
|
+
if (nl < 0)
|
|
182
|
+
return;
|
|
183
|
+
const line = buf.slice(0, nl);
|
|
184
|
+
// Single request per connection; ignore anything past the newline.
|
|
185
|
+
void this._handleRequest(line)
|
|
186
|
+
.then((reply) => socket.end(encodeReply(reply)))
|
|
187
|
+
.catch((err) => socket.end(encodeReply({ ok: false, error: String(err) })));
|
|
188
|
+
});
|
|
189
|
+
socket.on("error", () => {
|
|
190
|
+
/* client hung up; nothing to do */
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
// ── Request dispatch ─────────────────────────────────────────────────────
|
|
194
|
+
async _handleRequest(line) {
|
|
195
|
+
let req;
|
|
196
|
+
try {
|
|
197
|
+
req = parseRequest(line);
|
|
198
|
+
}
|
|
199
|
+
catch (e) {
|
|
200
|
+
return { ok: false, error: e.message };
|
|
201
|
+
}
|
|
202
|
+
switch (req.op) {
|
|
203
|
+
case "list":
|
|
204
|
+
return { ok: true, data: { daemons: this._listInfo() } };
|
|
205
|
+
case "status":
|
|
206
|
+
return { ok: true, data: { daemons: this._listInfo() } };
|
|
207
|
+
case "start_all":
|
|
208
|
+
return this._opStartAll();
|
|
209
|
+
case "start":
|
|
210
|
+
return this._opStart(req.id);
|
|
211
|
+
case "stop_all":
|
|
212
|
+
return this._opStopAll();
|
|
213
|
+
case "stop":
|
|
214
|
+
return this._opStop(req.id);
|
|
215
|
+
case "restart_all":
|
|
216
|
+
return this._opRestartAll();
|
|
217
|
+
case "restart":
|
|
218
|
+
return this._opRestart(req.id);
|
|
219
|
+
case "send":
|
|
220
|
+
return this._opSend(req.id, req.text);
|
|
221
|
+
case "register":
|
|
222
|
+
return this._opRegister(req.cwd);
|
|
223
|
+
case "unregister":
|
|
224
|
+
return this._opUnregister(req.id);
|
|
225
|
+
case "cron_add":
|
|
226
|
+
return this._opCronAdd(req);
|
|
227
|
+
case "cron_list":
|
|
228
|
+
return this._opCronList();
|
|
229
|
+
case "cron_remove":
|
|
230
|
+
return this._opCronRemove(req.job_id);
|
|
231
|
+
case "cron_enable":
|
|
232
|
+
return this._opCronEnable(req.job_id, req.enabled);
|
|
233
|
+
case "cron_run":
|
|
234
|
+
return this._opCronRun(req.job_id);
|
|
235
|
+
case "cron_log":
|
|
236
|
+
return this._opCronLog(req.job_id, req.tail);
|
|
237
|
+
default: {
|
|
238
|
+
const unknown = req.op;
|
|
239
|
+
return { ok: false, error: `unknown op: ${unknown}` };
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
// ── Op handlers ──────────────────────────────────────────────────────────
|
|
244
|
+
_listInfo() {
|
|
245
|
+
const registry = listDaemons();
|
|
246
|
+
return registry.map((entry) => {
|
|
247
|
+
const slot = this.children.get(entry.id);
|
|
248
|
+
const name = entry.name ?? defaultAgentName(entry.cwd);
|
|
249
|
+
const info = {
|
|
250
|
+
id: entry.id,
|
|
251
|
+
cwd: entry.cwd,
|
|
252
|
+
name,
|
|
253
|
+
state: slot?.child.state ?? "stopped",
|
|
254
|
+
};
|
|
255
|
+
if (slot) {
|
|
256
|
+
if (slot.child.pid !== undefined)
|
|
257
|
+
info.pid = slot.child.pid;
|
|
258
|
+
if (slot.child.uptimeMs !== undefined)
|
|
259
|
+
info.uptime_s = Math.floor(slot.child.uptimeMs / 1000);
|
|
260
|
+
info.restart_count = slot.child.restartCount;
|
|
261
|
+
}
|
|
262
|
+
return info;
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
_opStartAll() {
|
|
266
|
+
const started = [];
|
|
267
|
+
const already = [];
|
|
268
|
+
for (const entry of listDaemons()) {
|
|
269
|
+
const slot = this.children.get(entry.id);
|
|
270
|
+
if (slot && slot.child.state === "running") {
|
|
271
|
+
already.push(entry.id);
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
this._spawnEntry(entry.id, entry.cwd);
|
|
275
|
+
started.push(entry.id);
|
|
276
|
+
}
|
|
277
|
+
return { ok: true, data: { started, already_running: already } };
|
|
278
|
+
}
|
|
279
|
+
/** Spawn a single registered daemon by id. Idempotent: a daemon already
|
|
280
|
+
* running returns `started: false`. Unknown id → ok:false. This is what
|
|
281
|
+
* `/unbien create` calls so a freshly-registered folder boots its Pi
|
|
282
|
+
* immediately instead of waiting for the next supervisor restart. */
|
|
283
|
+
_opStart(id) {
|
|
284
|
+
const entry = listDaemons().find((d) => d.id === id);
|
|
285
|
+
if (!entry)
|
|
286
|
+
return { ok: false, error: `no daemon with id ${id}` };
|
|
287
|
+
const slot = this.children.get(id);
|
|
288
|
+
if (slot && slot.child.state === "running") {
|
|
289
|
+
return {
|
|
290
|
+
ok: true,
|
|
291
|
+
data: { id, state: slot.child.state, started: false },
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
this._spawnEntry(entry.id, entry.cwd, entry.name);
|
|
295
|
+
const state = this.children.get(id)?.child.state ?? "starting";
|
|
296
|
+
return { ok: true, data: { id, state, started: true } };
|
|
297
|
+
}
|
|
298
|
+
async _opStopAll() {
|
|
299
|
+
const stopped = [];
|
|
300
|
+
const already = [];
|
|
301
|
+
for (const [id, slot] of this.children) {
|
|
302
|
+
if (slot.child.state !== "running") {
|
|
303
|
+
already.push(id);
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
if (slot.restartTimer !== null) {
|
|
307
|
+
clearTimeout(slot.restartTimer);
|
|
308
|
+
slot.restartTimer = null;
|
|
309
|
+
}
|
|
310
|
+
await slot.child.stop();
|
|
311
|
+
stopped.push(id);
|
|
312
|
+
}
|
|
313
|
+
return { ok: true, data: { stopped, already_stopped: already } };
|
|
314
|
+
}
|
|
315
|
+
/** Stop a single registered daemon by id. Idempotent: a daemon that isn't
|
|
316
|
+
* running returns `stopped: false`. Unknown id → ok:false. Mirrors the
|
|
317
|
+
* per-id semantics of `_opStart`. Cancels any pending restart backoff so a
|
|
318
|
+
* deliberate stop stays stopped. */
|
|
319
|
+
async _opStop(id) {
|
|
320
|
+
const entry = listDaemons().find((d) => d.id === id);
|
|
321
|
+
if (!entry)
|
|
322
|
+
return { ok: false, error: `no daemon with id ${id}` };
|
|
323
|
+
const slot = this.children.get(id);
|
|
324
|
+
if (!slot || slot.child.state !== "running") {
|
|
325
|
+
return {
|
|
326
|
+
ok: true,
|
|
327
|
+
data: { id, state: slot?.child.state ?? "stopped", stopped: false },
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
if (slot.restartTimer !== null) {
|
|
331
|
+
clearTimeout(slot.restartTimer);
|
|
332
|
+
slot.restartTimer = null;
|
|
333
|
+
}
|
|
334
|
+
await slot.child.stop();
|
|
335
|
+
return { ok: true, data: { id, state: slot.child.state, stopped: true } };
|
|
336
|
+
}
|
|
337
|
+
/** Restart a single registered daemon by id (stop-if-running, then spawn).
|
|
338
|
+
* Unknown id → ok:false. Resets the crash backoff. */
|
|
339
|
+
async _opRestart(id) {
|
|
340
|
+
const entry = listDaemons().find((d) => d.id === id);
|
|
341
|
+
if (!entry)
|
|
342
|
+
return { ok: false, error: `no daemon with id ${id}` };
|
|
343
|
+
const slot = this.children.get(id);
|
|
344
|
+
if (slot && slot.child.state === "running") {
|
|
345
|
+
if (slot.restartTimer !== null) {
|
|
346
|
+
clearTimeout(slot.restartTimer);
|
|
347
|
+
slot.restartTimer = null;
|
|
348
|
+
}
|
|
349
|
+
await slot.child.stop();
|
|
350
|
+
}
|
|
351
|
+
this._spawnEntry(entry.id, entry.cwd, entry.name);
|
|
352
|
+
const state = this.children.get(id)?.child.state ?? "starting";
|
|
353
|
+
return { ok: true, data: { id, state, restarted: true } };
|
|
354
|
+
}
|
|
355
|
+
async _opRestartAll() {
|
|
356
|
+
const stopReply = await this._opStopAll();
|
|
357
|
+
if (!stopReply.ok)
|
|
358
|
+
return stopReply;
|
|
359
|
+
const startReply = this._opStartAll();
|
|
360
|
+
if (!startReply.ok)
|
|
361
|
+
return startReply;
|
|
362
|
+
const restarted = startReply.data.started;
|
|
363
|
+
return { ok: true, data: { restarted } };
|
|
364
|
+
}
|
|
365
|
+
_opSend(id, text) {
|
|
366
|
+
const slot = this.children.get(id);
|
|
367
|
+
if (!slot)
|
|
368
|
+
return { ok: false, error: `daemon ${id} not running` };
|
|
369
|
+
if (slot.child.state !== "running") {
|
|
370
|
+
return { ok: false, error: `daemon ${id} state is ${slot.child.state}` };
|
|
371
|
+
}
|
|
372
|
+
const ok = slot.child.sendPrompt(text);
|
|
373
|
+
return { ok: true, data: { id, delivered: ok } };
|
|
374
|
+
}
|
|
375
|
+
_opRegister(rawCwd) {
|
|
376
|
+
try {
|
|
377
|
+
const { id, cwd } = addDaemon(rawCwd);
|
|
378
|
+
return { ok: true, data: { id, cwd } };
|
|
379
|
+
}
|
|
380
|
+
catch (e) {
|
|
381
|
+
return { ok: false, error: e.message };
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
async _opUnregister(id) {
|
|
385
|
+
// Stop the child first so we don't leave an orphan when the registry
|
|
386
|
+
// entry is gone.
|
|
387
|
+
const slot = this.children.get(id);
|
|
388
|
+
if (slot) {
|
|
389
|
+
if (slot.restartTimer !== null) {
|
|
390
|
+
clearTimeout(slot.restartTimer);
|
|
391
|
+
slot.restartTimer = null;
|
|
392
|
+
}
|
|
393
|
+
await slot.child.stop();
|
|
394
|
+
this.children.delete(id);
|
|
395
|
+
}
|
|
396
|
+
try {
|
|
397
|
+
const result = removeDaemon(id);
|
|
398
|
+
return { ok: true, data: result };
|
|
399
|
+
}
|
|
400
|
+
catch (e) {
|
|
401
|
+
return { ok: false, error: e.message };
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
// ── Cron ops + engine (plan/39) ────────────────────────────────────────────
|
|
405
|
+
_opCronAdd(req) {
|
|
406
|
+
const v = validateSchedule(req.schedule, req.tz);
|
|
407
|
+
if (!v.ok)
|
|
408
|
+
return { ok: false, error: v.error ?? "invalid schedule" };
|
|
409
|
+
const input = {
|
|
410
|
+
daemon_id: req.daemon_id,
|
|
411
|
+
schedule: req.schedule,
|
|
412
|
+
prompt: req.prompt,
|
|
413
|
+
};
|
|
414
|
+
if (req.tz !== undefined)
|
|
415
|
+
input.tz = req.tz;
|
|
416
|
+
if (req.skip_if_busy !== undefined)
|
|
417
|
+
input.skip_if_busy = req.skip_if_busy;
|
|
418
|
+
if (req.wake !== undefined)
|
|
419
|
+
input.wake = req.wake;
|
|
420
|
+
if (req.catchup !== undefined)
|
|
421
|
+
input.catchup = req.catchup;
|
|
422
|
+
const job = addCronJob(input);
|
|
423
|
+
this._scheduleCron(job);
|
|
424
|
+
return { ok: true, data: { job: this._jobView(job) } };
|
|
425
|
+
}
|
|
426
|
+
_opCronList() {
|
|
427
|
+
const jobs = listCronJobs().map((j) => this._jobView(j));
|
|
428
|
+
return { ok: true, data: { jobs } };
|
|
429
|
+
}
|
|
430
|
+
_opCronRemove(jobId) {
|
|
431
|
+
const removed = removeCronJob(jobId);
|
|
432
|
+
this._stopCron(jobId);
|
|
433
|
+
return { ok: true, data: { removed } };
|
|
434
|
+
}
|
|
435
|
+
_opCronEnable(jobId, enabled) {
|
|
436
|
+
const updated = setJobEnabled(jobId, enabled);
|
|
437
|
+
if (updated) {
|
|
438
|
+
this._stopCron(jobId);
|
|
439
|
+
const job = getCronJob(jobId);
|
|
440
|
+
if (enabled && job)
|
|
441
|
+
this._scheduleCron(job);
|
|
442
|
+
}
|
|
443
|
+
return { ok: true, data: { job_id: jobId, enabled, updated } };
|
|
444
|
+
}
|
|
445
|
+
async _opCronRun(jobId) {
|
|
446
|
+
if (!getCronJob(jobId))
|
|
447
|
+
return { ok: false, error: `no cron job with id ${jobId}` };
|
|
448
|
+
const result = await this.fireJob(jobId, { manual: true });
|
|
449
|
+
return { ok: true, data: { job_id: jobId, result } };
|
|
450
|
+
}
|
|
451
|
+
_opCronLog(jobId, tail) {
|
|
452
|
+
const opts = {};
|
|
453
|
+
if (jobId !== undefined)
|
|
454
|
+
opts.jobId = jobId;
|
|
455
|
+
if (tail !== undefined)
|
|
456
|
+
opts.tail = tail;
|
|
457
|
+
return { ok: true, data: { entries: readCronLog(opts) } };
|
|
458
|
+
}
|
|
459
|
+
_jobView(job) {
|
|
460
|
+
const next = nextRunFor(job);
|
|
461
|
+
return { ...job, next_run: next ? next.toISOString() : null };
|
|
462
|
+
}
|
|
463
|
+
/** Rebuild all live `Cron` schedules from the registry (enabled jobs only).
|
|
464
|
+
* Called on start; mutations reconcile incrementally via _scheduleCron/_stopCron. */
|
|
465
|
+
_reconcileCron() {
|
|
466
|
+
for (const c of this.cronJobs.values())
|
|
467
|
+
c.stop();
|
|
468
|
+
this.cronJobs.clear();
|
|
469
|
+
for (const job of listCronJobs()) {
|
|
470
|
+
if (job.enabled)
|
|
471
|
+
this._scheduleCron(job);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
_scheduleCron(job) {
|
|
475
|
+
this._stopCron(job.id);
|
|
476
|
+
try {
|
|
477
|
+
const opts = job.tz
|
|
478
|
+
? { timezone: job.tz, name: job.id }
|
|
479
|
+
: { name: job.id };
|
|
480
|
+
const cron = new Cron(job.schedule, opts, () => {
|
|
481
|
+
void this.fireJob(job.id);
|
|
482
|
+
});
|
|
483
|
+
this.cronJobs.set(job.id, cron);
|
|
484
|
+
}
|
|
485
|
+
catch (e) {
|
|
486
|
+
process.stderr.write(`[unbien-supervisord] cron schedule failed for ${job.id}: ${String(e)}\n`);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
_stopCron(jobId) {
|
|
490
|
+
const c = this.cronJobs.get(jobId);
|
|
491
|
+
if (c) {
|
|
492
|
+
c.stop();
|
|
493
|
+
this.cronJobs.delete(jobId);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
/** Detail 2: on start, run a catchup job once if its previous scheduled run
|
|
497
|
+
* was missed while the supervisor was down. Opt-in (`catchup`), at most 1×. */
|
|
498
|
+
_runCatchup() {
|
|
499
|
+
for (const job of listCronJobs()) {
|
|
500
|
+
if (!job.enabled || !job.catchup)
|
|
501
|
+
continue;
|
|
502
|
+
try {
|
|
503
|
+
const cron = new Cron(job.schedule, job.tz ? { timezone: job.tz } : {});
|
|
504
|
+
const prev = cron.previousRun();
|
|
505
|
+
cron.stop();
|
|
506
|
+
if (!prev)
|
|
507
|
+
continue;
|
|
508
|
+
const lastRunMs = job.last_run ? Date.parse(job.last_run) : 0;
|
|
509
|
+
if (prev.getTime() > lastRunMs)
|
|
510
|
+
void this.fireJob(job.id, { manual: true });
|
|
511
|
+
}
|
|
512
|
+
catch {
|
|
513
|
+
/* skip a malformed schedule */
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Fires a cron job: resolves the daemon, decides the action (decideFireAction),
|
|
519
|
+
* acts, and records the outcome — ALWAYS one `last_status` update + one JSONL
|
|
520
|
+
* line, for both fires and skips. Returns the result. `manual` bypasses the
|
|
521
|
+
* disabled-skip (used by `cron run` + catchup).
|
|
522
|
+
*/
|
|
523
|
+
async fireJob(jobId, opts = {}) {
|
|
524
|
+
const job = getCronJob(jobId);
|
|
525
|
+
if (!job)
|
|
526
|
+
return "missing";
|
|
527
|
+
let result;
|
|
528
|
+
if (!job.enabled && !opts.manual) {
|
|
529
|
+
result = "skipped_disabled";
|
|
530
|
+
}
|
|
531
|
+
else {
|
|
532
|
+
const slot = this.children.get(job.daemon_id);
|
|
533
|
+
const running = !!slot && slot.child.state === "running";
|
|
534
|
+
let busy = false;
|
|
535
|
+
if (running && job.skip_if_busy)
|
|
536
|
+
busy = await slot.child.refreshBusy();
|
|
537
|
+
const action = decideFireAction({
|
|
538
|
+
running,
|
|
539
|
+
busy,
|
|
540
|
+
wake: job.wake,
|
|
541
|
+
skipIfBusy: job.skip_if_busy,
|
|
542
|
+
});
|
|
543
|
+
if (action === "skip_down") {
|
|
544
|
+
result = "skipped_down";
|
|
545
|
+
}
|
|
546
|
+
else if (action === "skip_busy") {
|
|
547
|
+
result = "skipped_busy";
|
|
548
|
+
}
|
|
549
|
+
else if (action === "wake_and_send") {
|
|
550
|
+
const entry = listDaemons().find((d) => d.id === job.daemon_id);
|
|
551
|
+
if (entry) {
|
|
552
|
+
this._spawnEntry(entry.id, entry.cwd, entry.name);
|
|
553
|
+
const woke = this.children.get(job.daemon_id);
|
|
554
|
+
result =
|
|
555
|
+
woke && woke.child.sendPrompt(job.prompt)
|
|
556
|
+
? "woke_and_delivered"
|
|
557
|
+
: "deliver_failed";
|
|
558
|
+
}
|
|
559
|
+
else {
|
|
560
|
+
result = "skipped_down";
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
else {
|
|
564
|
+
result = slot.child.sendPrompt(job.prompt)
|
|
565
|
+
? "delivered"
|
|
566
|
+
: "deliver_failed";
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
const at = new Date().toISOString();
|
|
570
|
+
recordRun(job.id, at, result);
|
|
571
|
+
appendCronLog({
|
|
572
|
+
job_id: job.id,
|
|
573
|
+
daemon_id: job.daemon_id,
|
|
574
|
+
schedule: job.schedule,
|
|
575
|
+
result,
|
|
576
|
+
prompt: job.prompt,
|
|
577
|
+
});
|
|
578
|
+
return result;
|
|
579
|
+
}
|
|
580
|
+
// ── Child lifecycle ──────────────────────────────────────────────────────
|
|
581
|
+
_spawnAllFromRegistry() {
|
|
582
|
+
for (const entry of listDaemons()) {
|
|
583
|
+
this._spawnEntry(entry.id, entry.cwd, entry.name);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
_spawnEntry(id, cwd, name) {
|
|
587
|
+
// Clean up any prior slot (e.g. crashed + waiting for backoff).
|
|
588
|
+
const existing = this.children.get(id);
|
|
589
|
+
if (existing) {
|
|
590
|
+
if (existing.restartTimer !== null)
|
|
591
|
+
clearTimeout(existing.restartTimer);
|
|
592
|
+
// If somehow the child is still alive, stop it first so we don't
|
|
593
|
+
// leak. Fire-and-forget — caller doesn't await.
|
|
594
|
+
if (existing.child.state === "running")
|
|
595
|
+
void existing.child.stop();
|
|
596
|
+
}
|
|
597
|
+
// Build the daemon's config and inject it via UNBIEN_DIRECT_CONFIG —
|
|
598
|
+
// no per-cwd config file needed. The daemon scopes by (cwd, name) like any
|
|
599
|
+
// agent (plan/38); relay on.
|
|
600
|
+
const config = {
|
|
601
|
+
agent_name: name ?? defaultAgentName(cwd),
|
|
602
|
+
auto_start_relay: true,
|
|
603
|
+
};
|
|
604
|
+
const childOpts = {
|
|
605
|
+
extensionPath: this.opts.extensionPath,
|
|
606
|
+
cwd,
|
|
607
|
+
config,
|
|
608
|
+
};
|
|
609
|
+
if (this.opts.piBin !== undefined)
|
|
610
|
+
childOpts.piBin = this.opts.piBin;
|
|
611
|
+
const child = new RpcChild(childOpts);
|
|
612
|
+
const slot = {
|
|
613
|
+
id,
|
|
614
|
+
cwd,
|
|
615
|
+
child,
|
|
616
|
+
restartTimer: null,
|
|
617
|
+
restartAttempt: 0,
|
|
618
|
+
};
|
|
619
|
+
this.children.set(id, slot);
|
|
620
|
+
child.on("exit", (evt) => this._onChildExit(id, evt));
|
|
621
|
+
child.spawn();
|
|
622
|
+
}
|
|
623
|
+
_onChildExit(id, evt) {
|
|
624
|
+
if (this.shuttingDown)
|
|
625
|
+
return;
|
|
626
|
+
const slot = this.children.get(id);
|
|
627
|
+
if (!slot)
|
|
628
|
+
return;
|
|
629
|
+
if (!evt.isCrash) {
|
|
630
|
+
// Clean shutdown (e.g. via `stop_all`). Don't auto-restart.
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
if (evt.code === EXIT_DAEMON_FRESH_SESSION) {
|
|
634
|
+
// App-triggered daemon `/new`: this is an intentional recycle, not a
|
|
635
|
+
// crash. Restart immediately and don't burn the crash backoff budget.
|
|
636
|
+
slot.restartAttempt = 0;
|
|
637
|
+
slot.child.noteRestart();
|
|
638
|
+
slot.child.spawn();
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
// Crash: schedule restart with backoff. After exhausting the schedule
|
|
642
|
+
// we give up and stay in `crashed`.
|
|
643
|
+
if (slot.restartAttempt >= RESTART_BACKOFFS_MS.length) {
|
|
644
|
+
process.stderr.write(`[unbien-supervisord] giving up restart for ${id} after ${slot.restartAttempt} attempts\n`);
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
const delay = RESTART_BACKOFFS_MS[slot.restartAttempt];
|
|
648
|
+
process.stderr.write(`[unbien-supervisord] scheduling restart of ${id} in ${delay}ms (attempt ${slot.restartAttempt + 1})\n`);
|
|
649
|
+
slot.restartTimer = setTimeout(() => {
|
|
650
|
+
slot.restartTimer = null;
|
|
651
|
+
slot.restartAttempt += 1;
|
|
652
|
+
slot.child.noteRestart();
|
|
653
|
+
slot.child.spawn();
|
|
654
|
+
}, delay);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
/** Test helper: derive id from cwd without going through the registry. */
|
|
658
|
+
export function _idForCwdForTest(cwd) {
|
|
659
|
+
return daemonIdForCwd(cwd);
|
|
660
|
+
}
|
|
661
|
+
/** Exported for the bin/supervisord entry + tests to know where the
|
|
662
|
+
* supervisor will bind. */
|
|
663
|
+
export function getSupervisorSockPath() {
|
|
664
|
+
return supervisorSockPath();
|
|
665
|
+
}
|
|
666
|
+
//# sourceMappingURL=supervisor.js.map
|