@love-moon/conductor-cli 0.11.0 → 0.11.2
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/CHANGELOG.md +97 -0
- package/bin/conductor-config.js +4 -2
- package/bin/conductor-daemon-update.js +55 -0
- package/package.json +5 -5
- package/src/daemon-update.js +614 -0
- package/src/daemon.js +676 -29
- package/src/fire-session-registry.js +136 -0
- package/src/guest-daemon.js +8 -1
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
// On-disk hand-off records that let a *fresh* daemon adopt the tmux Fire
|
|
5
|
+
// sessions its predecessor deliberately left running.
|
|
6
|
+
//
|
|
7
|
+
// In `fire_tmux_mode` daemon shutdown intentionally keeps Fires alive
|
|
8
|
+
// ("Daemon shutting down: leaving tmux-detached Fire task … running"), but
|
|
9
|
+
// everything the liveness reaper needs to classify their eventual exit —
|
|
10
|
+
// `logPath`, `logStartOffset`, `exitMarkerToken`, `spawnedAtMs` — lived only
|
|
11
|
+
// in the old process's memory. `exitMarkerToken` in particular is a
|
|
12
|
+
// per-spawn random nonce that cannot be recomputed, so without persisting it
|
|
13
|
+
// a successor daemon can never read the exit marker the predecessor's
|
|
14
|
+
// wrapper shell writes, and an adopted task could only ever be reported as
|
|
15
|
+
// "died hard". These files close that gap.
|
|
16
|
+
//
|
|
17
|
+
// Records are keyed by tmux session name because that is the only identifier
|
|
18
|
+
// a successor can enumerate (`tmux list-sessions`). The task id is NOT
|
|
19
|
+
// recoverable from a session name: `buildFireTmuxSessionPrefix` truncates it
|
|
20
|
+
// to 32 characters, which is shorter than a UUID.
|
|
21
|
+
|
|
22
|
+
export const FIRE_SESSION_RECORD_VERSION = 1;
|
|
23
|
+
|
|
24
|
+
export function resolveFireSessionRegistryDir(conductorHome) {
|
|
25
|
+
return path.join(String(conductorHome || ""), "daemon", "fire-sessions");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Session names are built from a sanitized task id plus a base36 nonce, so in
|
|
29
|
+
// practice they are already `[A-Za-z0-9_-]+`. Re-check anyway: this value
|
|
30
|
+
// decides a filename, and a name carrying `/` or `..` would let a crafted
|
|
31
|
+
// tmux session read or delete files outside the registry directory.
|
|
32
|
+
function isSafeSessionName(sessionName) {
|
|
33
|
+
return typeof sessionName === "string" && /^[A-Za-z0-9_-]+$/.test(sessionName);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function recordPath(dir, sessionName) {
|
|
37
|
+
return path.join(dir, `${sessionName}.json`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function normalizeRecord(record) {
|
|
41
|
+
return {
|
|
42
|
+
version: FIRE_SESSION_RECORD_VERSION,
|
|
43
|
+
taskId: String(record?.taskId || ""),
|
|
44
|
+
projectId: String(record?.projectId || ""),
|
|
45
|
+
tmuxSession: String(record?.tmuxSession || ""),
|
|
46
|
+
logPath: String(record?.logPath || ""),
|
|
47
|
+
logStartOffset: Number(record?.logStartOffset) || 0,
|
|
48
|
+
exitMarkerToken: String(record?.exitMarkerToken || ""),
|
|
49
|
+
spawnedAtMs: Number(record?.spawnedAtMs) || 0,
|
|
50
|
+
daemonName: String(record?.daemonName || ""),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Write is atomic (tmp + rename) so a daemon killed mid-write cannot leave a
|
|
55
|
+
// truncated record that the successor would parse as "no metadata".
|
|
56
|
+
export function writeFireSessionRecord(dir, record, { fsImpl = fs } = {}) {
|
|
57
|
+
const normalized = normalizeRecord(record);
|
|
58
|
+
if (!normalized.tmuxSession || !normalized.taskId) return false;
|
|
59
|
+
if (!isSafeSessionName(normalized.tmuxSession)) return false;
|
|
60
|
+
fsImpl.mkdirSync(dir, { recursive: true });
|
|
61
|
+
const finalPath = recordPath(dir, normalized.tmuxSession);
|
|
62
|
+
const tmpPath = `${finalPath}.${process.pid}.tmp`;
|
|
63
|
+
fsImpl.writeFileSync(tmpPath, `${JSON.stringify(normalized)}\n`, "utf8");
|
|
64
|
+
fsImpl.renameSync(tmpPath, finalPath);
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Returns `null` for anything we cannot fully trust: missing file, unparsable
|
|
69
|
+
// JSON, unknown version, or a record whose own `tmuxSession` disagrees with
|
|
70
|
+
// the name it was filed under. Callers treat `null` as "adopt in degraded
|
|
71
|
+
// mode", never as "the session is dead".
|
|
72
|
+
export function readFireSessionRecord(dir, sessionName, { fsImpl = fs } = {}) {
|
|
73
|
+
if (!isSafeSessionName(sessionName)) return null;
|
|
74
|
+
let raw;
|
|
75
|
+
try {
|
|
76
|
+
raw = fsImpl.readFileSync(recordPath(dir, sessionName), "utf8");
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
let parsed;
|
|
81
|
+
try {
|
|
82
|
+
parsed = JSON.parse(raw);
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
87
|
+
if (Number(parsed.version) !== FIRE_SESSION_RECORD_VERSION) return null;
|
|
88
|
+
if (String(parsed.tmuxSession || "") !== sessionName) return null;
|
|
89
|
+
if (!parsed.taskId) return null;
|
|
90
|
+
return normalizeRecord(parsed);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function deleteFireSessionRecord(dir, sessionName, { fsImpl = fs } = {}) {
|
|
94
|
+
if (!isSafeSessionName(sessionName)) return false;
|
|
95
|
+
try {
|
|
96
|
+
fsImpl.rmSync(recordPath(dir, sessionName), { force: true });
|
|
97
|
+
return true;
|
|
98
|
+
} catch {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Drop records whose tmux session no longer exists. Without this the registry
|
|
104
|
+
// would grow one file per Fire ever spawned: the targeted deletes on the
|
|
105
|
+
// reap/stop/delete paths cover the common cases, but a daemon that is
|
|
106
|
+
// SIGKILLed leaves its records behind with nothing to remove them.
|
|
107
|
+
export function pruneFireSessionRecords(dir, liveSessionNames, { fsImpl = fs } = {}) {
|
|
108
|
+
const live = new Set(Array.isArray(liveSessionNames) ? liveSessionNames : []);
|
|
109
|
+
let entries;
|
|
110
|
+
try {
|
|
111
|
+
entries = fsImpl.readdirSync(dir);
|
|
112
|
+
} catch {
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
let pruned = 0;
|
|
116
|
+
for (const entry of entries) {
|
|
117
|
+
// A `.tmp` file only exists between writeFileSync and renameSync, so one
|
|
118
|
+
// that survives to a prune is debris from a crashed write.
|
|
119
|
+
if (entry.endsWith(".tmp")) {
|
|
120
|
+
try {
|
|
121
|
+
fsImpl.rmSync(path.join(dir, entry), { force: true });
|
|
122
|
+
pruned += 1;
|
|
123
|
+
} catch {
|
|
124
|
+
// best effort
|
|
125
|
+
}
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (!entry.endsWith(".json")) continue;
|
|
129
|
+
const sessionName = entry.slice(0, -".json".length);
|
|
130
|
+
if (live.has(sessionName)) continue;
|
|
131
|
+
if (deleteFireSessionRecord(dir, sessionName, { fsImpl })) {
|
|
132
|
+
pruned += 1;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return pruned;
|
|
136
|
+
}
|
package/src/guest-daemon.js
CHANGED
|
@@ -2,6 +2,8 @@ import fs from "node:fs";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
|
|
5
|
+
import { UPDATE_DAEMON_CAPABILITY } from "./daemon-update.js";
|
|
6
|
+
|
|
5
7
|
/**
|
|
6
8
|
* RFC 0035 — guest daemon supervision.
|
|
7
9
|
*
|
|
@@ -200,7 +202,12 @@ export function nextRestartDelayMs(failureCount) {
|
|
|
200
202
|
* That is a different axis, handled per-action below, not by withholding
|
|
201
203
|
* capabilities.
|
|
202
204
|
*/
|
|
203
|
-
export const GUEST_BLOCKED_CAPABILITIES = new Set(
|
|
205
|
+
export const GUEST_BLOCKED_CAPABILITIES = new Set([
|
|
206
|
+
// `update_daemon` is the one capability that is *only* owner-state mutation:
|
|
207
|
+
// a global `npm install -g` plus a restart of the owner's daemon. There is no
|
|
208
|
+
// read-only half to keep, so it is withheld rather than refused per-action.
|
|
209
|
+
UPDATE_DAEMON_CAPABILITY,
|
|
210
|
+
]);
|
|
204
211
|
|
|
205
212
|
export function filterGuestCapabilities(capabilities) {
|
|
206
213
|
return (capabilities || []).filter((cap) => !GUEST_BLOCKED_CAPABILITIES.has(cap));
|