@coulb/crux-cli 0.1.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 +405 -0
- package/crux.js +2697 -0
- package/leads.config.json +62 -0
- package/lib/backends.js +617 -0
- package/lib/cancel.js +125 -0
- package/lib/cursor.js +51 -0
- package/lib/discover.js +422 -0
- package/lib/leads.js +292 -0
- package/lib/orchestrator.js +237 -0
- package/lib/spawn.js +436 -0
- package/lib/store.js +119 -0
- package/lib/swarm-state.js +66 -0
- package/lib/swarm.js +628 -0
- package/package.json +40 -0
package/lib/leads.js
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Reconciliation for the standing lead fleet: does every Solo project have exactly one live
|
|
5
|
+
* lead tailing the right crux stream?
|
|
6
|
+
*
|
|
7
|
+
* The fleet was once down for hours and the only symptom was silence — indistinguishable from
|
|
8
|
+
* "no items to route". Everything here exists to make that ambiguity impossible, so each check
|
|
9
|
+
* is written to be *loud* rather than convenient.
|
|
10
|
+
*
|
|
11
|
+
* ## Why coverage is read off the OS process table, not `solo processes list`
|
|
12
|
+
*
|
|
13
|
+
* A lead's Solo command is `cd /path/to/repo && claude … '<brief>'`. The slug it tails appears
|
|
14
|
+
* nowhere in that string: `crux tail --project <slug>` is started later, by the agent, as a
|
|
15
|
+
* grandchild of the Solo process. So Solo can tell us a lead *process* is running; only the OS
|
|
16
|
+
* can tell us which stream it is actually tailing — and that is the thing we care about.
|
|
17
|
+
*
|
|
18
|
+
* Names are worthless for this. The stopped `kind: agent` rows are named exactly like the new
|
|
19
|
+
* `kind: command` rows, and one of them (`lead-beacon`) is still running and still tailing.
|
|
20
|
+
* A reconciler that trusted names would call `beacon` covered by the command that is in fact
|
|
21
|
+
* stopped, and would happily start a second tail on top of the agent that is really doing the
|
|
22
|
+
* work. So: match on the running command line, attribute it to its owning Solo process by
|
|
23
|
+
* walking the process tree, and never look at a name.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** A tail is the node process running the `crux` script — never a `timeout`/`zsh -c` wrapper. */
|
|
27
|
+
const TAIL_COMMAND = /^(?:\S*\/)?node(?:\.exe)?\s+(?:\S*\/)?crux(?:\.js)?\s+(.*)$/;
|
|
28
|
+
|
|
29
|
+
const ERROR_STATES = ['unmapped', 'uncovered', 'duplicate', 'orphan'];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Parse `ps -axo pid=,ppid=,command=` into a pid -> {pid, ppid, command} map.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} output
|
|
35
|
+
* @returns {Map<number, {pid: number, ppid: number, command: string}>}
|
|
36
|
+
*/
|
|
37
|
+
function parseProcessTable(output) {
|
|
38
|
+
const table = new Map();
|
|
39
|
+
for (const line of output.split('\n')) {
|
|
40
|
+
const match = /^\s*(\d+)\s+(\d+)\s+(.*\S)\s*$/.exec(line);
|
|
41
|
+
if (!match) continue;
|
|
42
|
+
const pid = Number(match[1]);
|
|
43
|
+
table.set(pid, { pid, ppid: Number(match[2]), command: match[3] });
|
|
44
|
+
}
|
|
45
|
+
return table;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Pull `--project x` / `--interval n` / `--unrouted` out of a `crux tail` argument string. */
|
|
49
|
+
function parseTailArgs(argString) {
|
|
50
|
+
const argv = argString.trim().split(/\s+/);
|
|
51
|
+
if (argv[0] !== 'tail') return null;
|
|
52
|
+
|
|
53
|
+
const flag = (name) => {
|
|
54
|
+
const i = argv.indexOf(`--${name}`);
|
|
55
|
+
if (i === -1) return undefined;
|
|
56
|
+
const next = argv[i + 1];
|
|
57
|
+
return next !== undefined && !next.startsWith('--') ? next : true;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const project = flag('project');
|
|
61
|
+
return {
|
|
62
|
+
slug: typeof project === 'string' ? project : null,
|
|
63
|
+
unrouted: flag('unrouted') === true,
|
|
64
|
+
interval: typeof flag('interval') === 'string' ? Number(flag('interval')) : 60,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Every running `crux tail`, one entry per real tail.
|
|
70
|
+
*
|
|
71
|
+
* `timeout 570 crux tail …` and `zsh -c "… eval 'crux tail …'"` both *contain* the command
|
|
72
|
+
* line, and both really are ancestors of one tail. Matching only the node process is what
|
|
73
|
+
* keeps a wrapped tail from being miscounted as two — which would report a phantom duplicate.
|
|
74
|
+
*
|
|
75
|
+
* @param {Map<number, {pid: number, ppid: number, command: string}>} table
|
|
76
|
+
*/
|
|
77
|
+
function findTails(table) {
|
|
78
|
+
const tails = [];
|
|
79
|
+
for (const { pid, command } of table.values()) {
|
|
80
|
+
const match = TAIL_COMMAND.exec(command);
|
|
81
|
+
if (!match) continue;
|
|
82
|
+
const args = parseTailArgs(match[1]);
|
|
83
|
+
if (!args) continue;
|
|
84
|
+
tails.push({ pid, ...args, command });
|
|
85
|
+
}
|
|
86
|
+
return tails.sort((a, b) => a.pid - b.pid);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Attribute each tail to the Solo process that owns it by walking up the process tree.
|
|
91
|
+
*
|
|
92
|
+
* A tail with no Solo ancestor is running outside the supervisor — it covers the stream right
|
|
93
|
+
* now but nothing will restart it, so it is reported rather than counted as healthy coverage.
|
|
94
|
+
*
|
|
95
|
+
* @param {Array<object>} tails
|
|
96
|
+
* @param {Map<number, {pid: number, ppid: number, command: string}>} table
|
|
97
|
+
* @param {Map<number, object>} soloByPid
|
|
98
|
+
*/
|
|
99
|
+
function attributeOwners(tails, table, soloByPid) {
|
|
100
|
+
return tails.map((tail) => {
|
|
101
|
+
const seen = new Set();
|
|
102
|
+
let pid = tail.pid;
|
|
103
|
+
while (pid && pid !== 1 && table.has(pid) && !seen.has(pid)) {
|
|
104
|
+
seen.add(pid);
|
|
105
|
+
if (soloByPid.has(pid)) return { ...tail, owner: soloByPid.get(pid) };
|
|
106
|
+
pid = table.get(pid).ppid;
|
|
107
|
+
}
|
|
108
|
+
return { ...tail, owner: null };
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Classify every slug into exactly one state. Pure: no processes, no I/O, no clock.
|
|
114
|
+
*
|
|
115
|
+
* States:
|
|
116
|
+
* covered — one running tail, owned by a Solo process. The happy path.
|
|
117
|
+
* uncovered — Solo project expects a lead; nothing is tailing. (failure class 1)
|
|
118
|
+
* orphan — something tails a slug that has no Solo project. (failure class 2)
|
|
119
|
+
* duplicate — two or more tails on one slug; the stream is double-read. (failure class 3)
|
|
120
|
+
* unmapped — a Solo project absent from the mapping. Never skipped silently.
|
|
121
|
+
* leadless — configured to expect no lead (e.g. `personal`). Expected, not an error.
|
|
122
|
+
* unmanaged — a crux slug with no Solo project and no tail. Registry drift; a warning.
|
|
123
|
+
*
|
|
124
|
+
* @param {{soloProjects: Array<{id: number, name: string}>, cruxSlugs: string[],
|
|
125
|
+
* tails: Array<object>, config: object}} input
|
|
126
|
+
*/
|
|
127
|
+
function reconcile({ soloProjects, cruxSlugs, tails, config }) {
|
|
128
|
+
const entries = [];
|
|
129
|
+
const slugToSolo = new Map();
|
|
130
|
+
|
|
131
|
+
for (const project of soloProjects) {
|
|
132
|
+
const slug = config.map[project.name];
|
|
133
|
+
if (typeof slug !== 'string') {
|
|
134
|
+
entries.push({
|
|
135
|
+
state: 'unmapped',
|
|
136
|
+
slug: null,
|
|
137
|
+
soloProject: project,
|
|
138
|
+
tails: [],
|
|
139
|
+
detail: `Solo project "${project.name}" (id ${project.id}) is not in the name→slug map. Add it to leads.config.json.`,
|
|
140
|
+
});
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
slugToSolo.set(slug, project);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const projectTails = tails.filter((t) => typeof t.slug === 'string' && t.slug !== '');
|
|
147
|
+
const bySlug = new Map();
|
|
148
|
+
for (const tail of projectTails) {
|
|
149
|
+
if (!bySlug.has(tail.slug)) bySlug.set(tail.slug, []);
|
|
150
|
+
bySlug.get(tail.slug).push(tail);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const slugs = [...new Set([...slugToSolo.keys(), ...cruxSlugs, ...bySlug.keys()])].sort();
|
|
154
|
+
|
|
155
|
+
for (const slug of slugs) {
|
|
156
|
+
const own = bySlug.get(slug) ?? [];
|
|
157
|
+
const solo = slugToSolo.get(slug) ?? null;
|
|
158
|
+
const leadlessReason = config.leadless[slug];
|
|
159
|
+
|
|
160
|
+
if (own.length > 1) {
|
|
161
|
+
entries.push({
|
|
162
|
+
state: 'duplicate',
|
|
163
|
+
slug,
|
|
164
|
+
soloProject: solo,
|
|
165
|
+
tails: own,
|
|
166
|
+
detail: `${own.length} tails on "${slug}" (pids ${own.map((t) => t.pid).join(', ')}). The stream is being double-processed.`,
|
|
167
|
+
});
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (own.length === 1) {
|
|
172
|
+
const tail = own[0];
|
|
173
|
+
if (!solo) {
|
|
174
|
+
entries.push({
|
|
175
|
+
state: 'orphan',
|
|
176
|
+
slug,
|
|
177
|
+
soloProject: null,
|
|
178
|
+
tails: own,
|
|
179
|
+
detail: `pid ${tail.pid} tails "${slug}", which has no Solo project. Nothing will restart it.`,
|
|
180
|
+
});
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
entries.push({
|
|
184
|
+
state: 'covered',
|
|
185
|
+
slug,
|
|
186
|
+
soloProject: solo,
|
|
187
|
+
tails: own,
|
|
188
|
+
detail: tail.owner
|
|
189
|
+
? `pid ${tail.pid} under Solo process ${tail.owner.id} (${tail.owner.name}).`
|
|
190
|
+
: `pid ${tail.pid} — running outside Solo; nothing will restart it if it dies.`,
|
|
191
|
+
unsupervised: !tail.owner,
|
|
192
|
+
});
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (typeof leadlessReason === 'string') {
|
|
197
|
+
entries.push({ state: 'leadless', slug, soloProject: solo, tails: [], detail: leadlessReason });
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (solo) {
|
|
202
|
+
entries.push({
|
|
203
|
+
state: 'uncovered',
|
|
204
|
+
slug,
|
|
205
|
+
soloProject: solo,
|
|
206
|
+
tails: [],
|
|
207
|
+
detail: `Solo project "${solo.name}" (id ${solo.id}) has no process tailing "${slug}".`,
|
|
208
|
+
});
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
entries.push({
|
|
213
|
+
state: 'unmanaged',
|
|
214
|
+
slug,
|
|
215
|
+
soloProject: null,
|
|
216
|
+
tails: [],
|
|
217
|
+
detail: `crux slug "${slug}" has no Solo project and no lead. Registry drift — remove it, or create the project.`,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const errors = entries.filter((e) => ERROR_STATES.includes(e.state));
|
|
222
|
+
return { entries, errors, ok: errors.length === 0, exitCode: errors.length === 0 ? 0 : 1 };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** The Solo process name that starts a slug's lead. `lead-<slug>` unless overridden. */
|
|
226
|
+
function leadProcessName(slug, config) {
|
|
227
|
+
return config.leadProcess[slug] ?? `lead-${slug}`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Decide what `--fix` would start, and what it must refuse.
|
|
232
|
+
*
|
|
233
|
+
* The refusals are the point. A slug can be covered by a *stopped-looking* `kind:
|
|
234
|
+
* agent` row while its `kind: command` twin sits idle — starting the command would put two
|
|
235
|
+
* tails on one stream. So the plan is built only from `uncovered` entries, and every other
|
|
236
|
+
* state is an explicit, explained refusal rather than a silent no-op.
|
|
237
|
+
*
|
|
238
|
+
* @param {{entries: Array<object>}} report
|
|
239
|
+
* @param {Array<{id: number, name: string, kind: string, status: string, projectName: string}>} soloProcesses
|
|
240
|
+
* @param {object} config
|
|
241
|
+
*/
|
|
242
|
+
function planFix(report, soloProcesses, config) {
|
|
243
|
+
const starts = [];
|
|
244
|
+
const refusals = [];
|
|
245
|
+
|
|
246
|
+
for (const entry of report.entries) {
|
|
247
|
+
if (entry.state === 'covered' || entry.state === 'leadless' || entry.state === 'unmanaged') continue;
|
|
248
|
+
|
|
249
|
+
if (entry.state !== 'uncovered') {
|
|
250
|
+
refusals.push({
|
|
251
|
+
slug: entry.slug,
|
|
252
|
+
reason: `${entry.state}: ${entry.detail} --fix only starts leads for uncovered projects.`,
|
|
253
|
+
});
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const wanted = leadProcessName(entry.slug, config);
|
|
258
|
+
const candidates = soloProcesses.filter((p) => p.projectName === config.fleetProject && p.name === wanted);
|
|
259
|
+
|
|
260
|
+
const command = candidates.find((p) => p.kind === 'command');
|
|
261
|
+
if (!command) {
|
|
262
|
+
const why = candidates.length
|
|
263
|
+
? `only non-command rows exist (${candidates.map((p) => `${p.id}:${p.kind}`).join(', ')}); solo.yml declares no such command`
|
|
264
|
+
: `no process named "${wanted}" in Solo project "${config.fleetProject}"`;
|
|
265
|
+
refusals.push({ slug: entry.slug, reason: `cannot start ${wanted} — ${why}.` });
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (command.status === 'running') {
|
|
270
|
+
refusals.push({
|
|
271
|
+
slug: entry.slug,
|
|
272
|
+
reason: `${wanted} (id ${command.id}) is already running but is not tailing "${entry.slug}" — restarting it blindly would hide a real fault. Investigate.`,
|
|
273
|
+
});
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
starts.push({ slug: entry.slug, process: command });
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return { starts, refusals };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
module.exports = {
|
|
284
|
+
ERROR_STATES,
|
|
285
|
+
attributeOwners,
|
|
286
|
+
findTails,
|
|
287
|
+
leadProcessName,
|
|
288
|
+
parseProcessTable,
|
|
289
|
+
parseTailArgs,
|
|
290
|
+
planFix,
|
|
291
|
+
reconcile,
|
|
292
|
+
};
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* WHICH orchestrator runs the leads on this machine, and — the question nobody was asking — CAN it.
|
|
5
|
+
*
|
|
6
|
+
* `lib/backends.js` knows how to drive three orchestrators. It does not know whether any of them is
|
|
7
|
+
* installed. Nothing did, and that is the whole bug this file exists to close:
|
|
8
|
+
*
|
|
9
|
+
* $ crux discover ~/src/mercury-billing --yes
|
|
10
|
+
* ✓ mercury-billing is a crux project — created.
|
|
11
|
+
* Next: crux swarm up --project mercury-billing
|
|
12
|
+
*
|
|
13
|
+
* $ crux swarm up --project mercury-billing
|
|
14
|
+
* crux: the exec backend needs a command template — set `swarm.exec.command`, …
|
|
15
|
+
*
|
|
16
|
+
* The error is a good error. It names its fix. It is still a **failed first run**, because the tool
|
|
17
|
+
* printed a command it could have known would not work. Step zero exists to stop exactly that, and a
|
|
18
|
+
* five-minute quickstart that stalls at step two is not saved by the politeness of the message it
|
|
19
|
+
* stalls with.
|
|
20
|
+
*
|
|
21
|
+
* Everything here is PURE. Whether a binary exists is I/O, so it arrives as `has(bin)` — injected,
|
|
22
|
+
* exactly as `planWrite` takes `samePath` and `planUp` takes `now`. The refusals are the product, and
|
|
23
|
+
* a refusal you cannot unit-test is a refusal that rots.
|
|
24
|
+
*
|
|
25
|
+
* ## The three answers, in order
|
|
26
|
+
*
|
|
27
|
+
* 1. **What the user SAID.** `--backend`, then `CRUX_SWARM_BACKEND`, then `swarm.backend` in the
|
|
28
|
+
* config. An explicit choice is never second-guessed — only checked.
|
|
29
|
+
* 2. **What is on the machine.** Nothing said? Then look: Solo, herdr, tmux. This is why the shipped
|
|
30
|
+
* config no longer hardcodes `"backend": "solo"`. That line meant every reader with no Solo — the
|
|
31
|
+
* overwhelmingly common case — got `solo` by default and hit `could not
|
|
32
|
+
* run \`solo projects list\`` on their first run. A default that is wrong for almost everyone who
|
|
33
|
+
* reads it is not a default, it is a trap.
|
|
34
|
+
* 3. **A refusal that names the fix.** No orchestrator at all is a real state, and the honest thing
|
|
35
|
+
* is to say so at the moment discover would otherwise print `Next:` — with the line to add and the
|
|
36
|
+
* file to add it to — rather than one command later.
|
|
37
|
+
*
|
|
38
|
+
* ## Why tmux gets to be a default at all, when exec is generic by design
|
|
39
|
+
*
|
|
40
|
+
* `exec` is the "bring your own orchestration" backend: it cannot know whether you run tmux, screen,
|
|
41
|
+
* systemd or nohup, and it must not pretend to. So the default is not "assume tmux" — it is **"tmux,
|
|
42
|
+
* if and only if tmux is actually installed"**. A default that assumes a binary and then fails is the
|
|
43
|
+
* same bug one layer down. {@see resolveBackend}: the tmux templates are only ever handed back when
|
|
44
|
+
* `has('tmux')` is true, and when it is false the exec backend refuses with the one line to write.
|
|
45
|
+
*
|
|
46
|
+
* And when crux does derive them, `crux discover` WRITES them into the config rather than keeping
|
|
47
|
+
* them in its head. A derived default that lives only in code is invisible and un-editable: the user
|
|
48
|
+
* cannot see what their leads are being started with, cannot change it, and cannot tell crux's
|
|
49
|
+
* opinion from their own. Written down, the derivation is a starting point they own — which is what
|
|
50
|
+
* "ship our own opinions" is supposed to mean.
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The exec templates crux derives when it finds tmux and nothing else has been said.
|
|
55
|
+
*
|
|
56
|
+
* This is the pair from the error message that this whole thread is about, promoted from advice to
|
|
57
|
+
* behaviour — and it is a *pair*, which is the part that matters. `tmux new-session -d` FORKS: the
|
|
58
|
+
* process crux spawns exits immediately while the lead lives on inside the tmux server, so a `command`
|
|
59
|
+
* without a `stop` leaves crux holding a dead pid and `swarm down` correctly refuses to claim a stop
|
|
60
|
+
* it cannot make {@see ../lib/swarm.planDown}. Shipping `command` alone would have been shipping that
|
|
61
|
+
* refusal to every first-time user. With `stop`, the lead is addressable by NAME forever
|
|
62
|
+
* (`addresses: 'handle'`), and `up`/`down` are a clean round trip.
|
|
63
|
+
*
|
|
64
|
+
* `{repo}` is deliberately absent. The exec backend already runs the line with `cwd: lead.repo`, so
|
|
65
|
+
* tmux inherits the right directory from its client — and a `-c {repo}` would put an unquoted path
|
|
66
|
+
* into a shell line, which breaks the first user whose code lives in `~/My Projects`. The safest
|
|
67
|
+
* interpolation is the one you do not do.
|
|
68
|
+
*/
|
|
69
|
+
const TMUX_EXEC = Object.freeze({
|
|
70
|
+
command: "tmux new-session -d -s {name} '{command}'",
|
|
71
|
+
stop: 'tmux kill-session -t {name}',
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
/** The backends `makeBackend` can build. Kept here so a typo is refused with the list, as before. */
|
|
75
|
+
const BACKENDS = ['solo', 'exec', 'herdr'];
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The binary each backend cannot run without.
|
|
79
|
+
*
|
|
80
|
+
* `exec` is absent on purpose — it is the one backend with no binary of its own, because its binary
|
|
81
|
+
* is whatever the user's template names. It can only be checked through the template. {@see execFor}
|
|
82
|
+
*/
|
|
83
|
+
const NEEDS = { solo: 'solo', herdr: 'herdr' };
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* What is on this machine, in the order crux would pick it.
|
|
87
|
+
*
|
|
88
|
+
* Solo first because it is the opinionated default and the richest: it spawns workers, so a Solo lead
|
|
89
|
+
* gets `crux spawn` and every other lead has to do its own work inline {@see ../lib/swarm.renderLeadBrief}.
|
|
90
|
+
* herdr next — a real multiplexer for agents, with named panes you can attach to. tmux last, and it
|
|
91
|
+
* is last precisely because it is the lowest common denominator: it hosts the lead and nothing more.
|
|
92
|
+
*
|
|
93
|
+
* Null means no orchestrator, which is a fact, not an error — and it is a fact worth stating out loud
|
|
94
|
+
* at step zero rather than discovering at step two.
|
|
95
|
+
*/
|
|
96
|
+
function detectBackend(has) {
|
|
97
|
+
if (has('solo')) return 'solo';
|
|
98
|
+
if (has('herdr')) return 'herdr';
|
|
99
|
+
if (has('tmux')) return 'exec';
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The exec templates in force: what the user wrote, else tmux if tmux is here, else nothing. */
|
|
104
|
+
function execFor(exec, has) {
|
|
105
|
+
const command = typeof exec?.command === 'string' ? exec.command.trim() : '';
|
|
106
|
+
if (command !== '') {
|
|
107
|
+
return {
|
|
108
|
+
exec: { command, stop: typeof exec.stop === 'string' && exec.stop.trim() !== '' ? exec.stop.trim() : null },
|
|
109
|
+
execSource: 'config',
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
if (has('tmux')) return { exec: { ...TMUX_EXEC }, execSource: 'tmux' };
|
|
113
|
+
return { exec: null, execSource: null };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The config block that would make the current derivation explicit. `null` when nothing is derived. */
|
|
117
|
+
function derivedWrites({ name, source, exec, execSource }) {
|
|
118
|
+
const writes = {};
|
|
119
|
+
// Only a DETECTED name is written down. A `--backend` flag or a `CRUX_SWARM_BACKEND` env var is a
|
|
120
|
+
// per-run override — persisting it would turn "just this once" into "forever", silently.
|
|
121
|
+
if (source === 'detected') writes.backend = name;
|
|
122
|
+
if (execSource === 'tmux') writes.exec = { ...exec };
|
|
123
|
+
return Object.keys(writes).length ? writes : null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Which backend, can it run, and if not — what is the one line that fixes it.
|
|
128
|
+
*
|
|
129
|
+
* @param {object} input
|
|
130
|
+
* @param {string|null} input.requested `--backend` (a per-run override)
|
|
131
|
+
* @param {string|null} input.env `CRUX_SWARM_BACKEND` (a per-shell override)
|
|
132
|
+
* @param {string|null} input.configured `swarm.backend` (may be absent — then crux looks)
|
|
133
|
+
* @param {object} input.exec `swarm.exec` — `{command, stop}`, either or both possibly absent
|
|
134
|
+
* @param {(bin: string) => boolean} input.has injected: "is this binary on PATH?" is I/O
|
|
135
|
+
* @param {string} [input.file] the config file, so a fix can say WHERE to write it
|
|
136
|
+
*
|
|
137
|
+
* @returns {{
|
|
138
|
+
* name: string|null, source: 'flag'|'env'|'config'|'detected'|'none',
|
|
139
|
+
* exec: {command: string, stop: string|null}|null, execSource: 'config'|'tmux'|null,
|
|
140
|
+
* ok: boolean, problem: {reason: string, fix: string[]}|null,
|
|
141
|
+
* writes: {backend?: string, exec?: object}|null,
|
|
142
|
+
* }}
|
|
143
|
+
*/
|
|
144
|
+
function resolveBackend({ requested = null, env = null, configured = null, exec = {}, has, file = 'the leads config' }) {
|
|
145
|
+
const said = (value, source) => (typeof value === 'string' && value.trim() !== '' ? { name: value.trim(), source } : null);
|
|
146
|
+
|
|
147
|
+
const chosen =
|
|
148
|
+
said(requested, 'flag') ??
|
|
149
|
+
said(env, 'env') ??
|
|
150
|
+
said(configured, 'config') ??
|
|
151
|
+
said(detectBackend(has), 'detected') ?? { name: null, source: 'none' };
|
|
152
|
+
|
|
153
|
+
const base = { ...chosen, exec: null, execSource: null, ok: false, problem: null, writes: null };
|
|
154
|
+
|
|
155
|
+
// Nothing said, and nothing found. Not a crash and not a crux bug — this machine has no way to run
|
|
156
|
+
// a long-lived agent yet, and that is a sentence a human can act on.
|
|
157
|
+
if (chosen.name === null) {
|
|
158
|
+
return {
|
|
159
|
+
...base,
|
|
160
|
+
problem: {
|
|
161
|
+
reason: 'crux found no orchestrator on this machine — a lead is a long-running agent, so something has to host it.',
|
|
162
|
+
fix: [
|
|
163
|
+
'Install one, and crux will find it — no configuration:',
|
|
164
|
+
' brew install tmux # the simplest: each lead becomes a tmux session',
|
|
165
|
+
' (or Solo, or herdr — see `crux swarm --help`)',
|
|
166
|
+
`Or bring your own, by naming the command that starts a lead in ${file}:`,
|
|
167
|
+
` "swarm": { "backend": "exec", "exec": { "command": "${TMUX_EXEC.command}" } }`,
|
|
168
|
+
],
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// A typo in a config file is not a backend. Refuse with the list, exactly as `makeBackend` does —
|
|
174
|
+
// and refuse it HERE, before discover has printed a `Next:` that could never have worked.
|
|
175
|
+
if (!BACKENDS.includes(chosen.name)) {
|
|
176
|
+
return {
|
|
177
|
+
...base,
|
|
178
|
+
problem: {
|
|
179
|
+
reason: `unknown backend "${chosen.name}" (available: ${BACKENDS.join(', ')})`,
|
|
180
|
+
fix: [`Set \`swarm.backend\` in ${file} to one of: ${BACKENDS.join(', ')}.`],
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// An explicit choice crux cannot honour. Never silently fall back to a different one: a user who
|
|
186
|
+
// wrote `"backend": "solo"` and gets a tmux lead has been lied to about where their agents are.
|
|
187
|
+
const needs = NEEDS[chosen.name];
|
|
188
|
+
if (needs && !has(needs)) {
|
|
189
|
+
return {
|
|
190
|
+
...base,
|
|
191
|
+
problem: {
|
|
192
|
+
reason:
|
|
193
|
+
`the ${chosen.name} backend needs \`${needs}\` on your PATH, and it is not installed` +
|
|
194
|
+
`${chosen.source === 'config' ? ` (\`swarm.backend\` in ${file} asks for it)` : ''}.`,
|
|
195
|
+
fix: [
|
|
196
|
+
`Install ${needs}, or pick a backend this machine has:`,
|
|
197
|
+
' crux swarm up --backend exec # any command you like — tmux, screen, nohup, systemd',
|
|
198
|
+
`Or set \`swarm.backend\` in ${file}.`,
|
|
199
|
+
],
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (chosen.name !== 'exec') return { ...base, ok: true, writes: derivedWrites({ ...chosen, exec: null, execSource: null }) };
|
|
205
|
+
|
|
206
|
+
const { exec: resolved, execSource } = execFor(exec, has);
|
|
207
|
+
|
|
208
|
+
// exec with no template and no tmux to derive one from. This is the message that was the whole
|
|
209
|
+
// thread — kept, because it was never the wrong message, only the wrong MOMENT to say it.
|
|
210
|
+
if (!resolved) {
|
|
211
|
+
return {
|
|
212
|
+
...base,
|
|
213
|
+
problem: {
|
|
214
|
+
reason:
|
|
215
|
+
'the exec backend needs a command template — it runs whatever you tell it to, and nothing ' +
|
|
216
|
+
'has told it. tmux, the obvious default, is not installed either, so crux has nothing to derive one from.',
|
|
217
|
+
fix: [
|
|
218
|
+
'Install tmux and crux will write the template for you:',
|
|
219
|
+
' brew install tmux',
|
|
220
|
+
`Or say how YOUR orchestrator starts a lead, in ${file}:`,
|
|
221
|
+
' "swarm": {',
|
|
222
|
+
' "backend": "exec",',
|
|
223
|
+
` "exec": { "command": "${TMUX_EXEC.command}",`,
|
|
224
|
+
` "stop": "${TMUX_EXEC.stop}" }`,
|
|
225
|
+
' }',
|
|
226
|
+
'Placeholders: {slug} {name} {repo} {brief_path} {command}. Set `stop` whenever your command',
|
|
227
|
+
'forks (tmux, nohup) — without it crux is left holding a dead pid and cannot stop the lead.',
|
|
228
|
+
],
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const out = { ...base, exec: resolved, execSource, ok: true };
|
|
234
|
+
return { ...out, writes: derivedWrites(out) };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
module.exports = { BACKENDS, TMUX_EXEC, detectBackend, execFor, resolveBackend };
|