@gaia-ai/conductor 0.4.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 +5 -0
- package/dist/src/cli/gaia.d.ts +19 -0
- package/dist/src/cli/gaia.js +644 -0
- package/dist/src/cli/init.d.ts +82 -0
- package/dist/src/cli/init.js +232 -0
- package/dist/src/cli/local-registry.d.ts +14 -0
- package/dist/src/cli/local-registry.js +56 -0
- package/dist/src/config.d.ts +23 -0
- package/dist/src/config.js +217 -0
- package/dist/src/core/conductor.d.ts +71 -0
- package/dist/src/core/conductor.js +410 -0
- package/dist/src/index.d.ts +8 -0
- package/dist/src/index.js +5 -0
- package/package.json +34 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 keytec GmbH
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# @gaia-ai/conductor
|
|
2
|
+
|
|
3
|
+
GAIA conductor engine + CLI: registers, claims tickets via JSON:API, dispatches agents.
|
|
4
|
+
|
|
5
|
+
Part of the GAIA conductor. Install the meta package `@gaia-ai/gaia` to get the `gaia` CLI with all plugins. Source: https://git.key-tec.de/keytec/gaia (conductor/).
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type ConductorFileConfig, type ConductorLogger, type GaiaAgent, type GaiaExecutor, type GaiaRemote, type GaiaWorkspace } from '@gaia-ai/core';
|
|
2
|
+
/** Test seam: inject any subset of dependencies. */
|
|
3
|
+
export interface GaiaCliDeps {
|
|
4
|
+
remote?: GaiaRemote;
|
|
5
|
+
executor?: GaiaExecutor;
|
|
6
|
+
workspace?: GaiaWorkspace;
|
|
7
|
+
agent?: GaiaAgent;
|
|
8
|
+
config?: ConductorFileConfig;
|
|
9
|
+
}
|
|
10
|
+
export declare function parseHerdrJson(output: string, command: string): unknown;
|
|
11
|
+
/**
|
|
12
|
+
* Start-time auth gate. Returns true if authenticated (session or session-less
|
|
13
|
+
* provider); otherwise logs a single clear line and returns false. Must run
|
|
14
|
+
* before remote resolution (resolveRemote), which calls resolveAuth and throws
|
|
15
|
+
* when unauthenticated.
|
|
16
|
+
*/
|
|
17
|
+
export declare function ensureAuthenticated(config: ConductorFileConfig, logger: ConductorLogger): Promise<boolean>;
|
|
18
|
+
export declare function runGaiaCli(argv: string[], deps?: GaiaCliDeps): Promise<void>;
|
|
19
|
+
export declare function main(argv: string[]): Promise<void>;
|
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { createInterface } from 'node:readline';
|
|
4
|
+
import { CommandRunner, conductorId, createLogger, exec, setDefaultCommandRunner, } from '@gaia-ai/core';
|
|
5
|
+
import { selectAgent, selectExecutor, selectRemote, selectWorkspace, } from '@gaia-ai/core/plugins';
|
|
6
|
+
import { Command } from 'commander';
|
|
7
|
+
import { authStatus, buildProgram as buildDropshProgram } from 'dropsh';
|
|
8
|
+
import { loadConductorConfig, resolveConfigPath } from '../config.js';
|
|
9
|
+
import { Conductor } from '../core/conductor.js';
|
|
10
|
+
import { machineContextPath, readMachineContext, scaffold, } from './init.js';
|
|
11
|
+
import * as registry from './local-registry.js';
|
|
12
|
+
/**
|
|
13
|
+
* Default config path. `--config` / `$GAIA_CONDUCTOR_CONFIG` win; otherwise walk
|
|
14
|
+
* up from cwd to the nearest `.gaia/conductor.config.js` (git/eslint style), so a
|
|
15
|
+
* `gaia` command works from any subdirectory of a project/worktree. Throws an
|
|
16
|
+
* actionable error when nothing is found up the tree (see resolveConfigPath).
|
|
17
|
+
*/
|
|
18
|
+
function defaultConfigPath(override) {
|
|
19
|
+
return resolveConfigPath(override);
|
|
20
|
+
}
|
|
21
|
+
async function resolveConfig(deps, configPathOverride) {
|
|
22
|
+
if (deps.config) {
|
|
23
|
+
return deps.config;
|
|
24
|
+
}
|
|
25
|
+
return loadConductorConfig(defaultConfigPath(configPathOverride));
|
|
26
|
+
}
|
|
27
|
+
async function resolveRemote(deps, config) {
|
|
28
|
+
return deps.remote ?? (await selectRemote(config));
|
|
29
|
+
}
|
|
30
|
+
function checkoutRootOf(config) {
|
|
31
|
+
return dirname(config.config_path);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The conductor's stable identity (gaia_conductor.machine_id). The loader
|
|
35
|
+
* always resolves this (config override or hostname+path hash), so lifecycle
|
|
36
|
+
* commands read it here instead of re-deriving the hash — otherwise a pinned
|
|
37
|
+
* machine_id and the CLI's id would diverge.
|
|
38
|
+
*/
|
|
39
|
+
function conductorIdOf(config) {
|
|
40
|
+
return config.machine_id ?? conductorId(checkoutRootOf(config));
|
|
41
|
+
}
|
|
42
|
+
/** Read the `conductor`-level --log-level / --log-sink flags from a subcommand. */
|
|
43
|
+
function logOptsOf(cmd) {
|
|
44
|
+
const opts = cmd.optsWithGlobals();
|
|
45
|
+
return {
|
|
46
|
+
...(opts.logLevel !== undefined ? { level: opts.logLevel } : {}),
|
|
47
|
+
...(opts.logSink !== undefined ? { sink: opts.logSink } : {}),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/** Create the conductor logger (with CLI log overrides) and route all exec logging through it. */
|
|
51
|
+
function loggerFor(checkoutRoot, log = {}) {
|
|
52
|
+
const logger = createLogger({ checkoutRoot, ...log });
|
|
53
|
+
setDefaultCommandRunner(new CommandRunner(logger));
|
|
54
|
+
return logger;
|
|
55
|
+
}
|
|
56
|
+
// --- herdr host (untested: shells out to herdr) ---------------------------
|
|
57
|
+
export function parseHerdrJson(output, command) {
|
|
58
|
+
try {
|
|
59
|
+
return JSON.parse(output);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
throw new Error(`herdr ${command} returned invalid JSON`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** Spawn a detached herdr pane running the given command in cwd. */
|
|
66
|
+
async function spawnViaHerdr(label, cwd, cmd) {
|
|
67
|
+
const created = await exec('herdr', [
|
|
68
|
+
'workspace',
|
|
69
|
+
'create',
|
|
70
|
+
'--cwd',
|
|
71
|
+
cwd,
|
|
72
|
+
'--label',
|
|
73
|
+
label,
|
|
74
|
+
'--no-focus',
|
|
75
|
+
]);
|
|
76
|
+
const parsed = parseHerdrJson(created, 'workspace create');
|
|
77
|
+
const paneId = parsed.result?.root_pane?.pane_id;
|
|
78
|
+
if (!paneId) {
|
|
79
|
+
throw new Error('herdr workspace create returned no pane id');
|
|
80
|
+
}
|
|
81
|
+
await exec('herdr', ['pane', 'run', paneId, cmd]);
|
|
82
|
+
}
|
|
83
|
+
/** Hard-kill a herdr-hosted conductor by its pane label. */
|
|
84
|
+
async function killViaHerdr(label) {
|
|
85
|
+
const listed = await exec('herdr', ['workspace', 'list']);
|
|
86
|
+
const parsed = parseHerdrJson(listed, 'workspace list');
|
|
87
|
+
const ws = (parsed.result?.workspaces ?? []).find((w) => w.label === label);
|
|
88
|
+
if (ws?.workspace_id) {
|
|
89
|
+
await exec('herdr', ['workspace', 'close', ws.workspace_id]);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// --- ls/status freshness ----------------------------------------------------
|
|
93
|
+
const DEFAULT_FRESH_S = 120;
|
|
94
|
+
function freshnessThresholdS(config) {
|
|
95
|
+
return config
|
|
96
|
+
? Math.max(DEFAULT_FRESH_S, config.lease_seconds * 2)
|
|
97
|
+
: DEFAULT_FRESH_S;
|
|
98
|
+
}
|
|
99
|
+
function classify(hub, freshS) {
|
|
100
|
+
// No host probe here (host calls are untested) → can't tell host-missing
|
|
101
|
+
// from wedged. registry+no-hub = registry-only; stale-hub = wedged.
|
|
102
|
+
if (!hub) {
|
|
103
|
+
return 'registry-only';
|
|
104
|
+
}
|
|
105
|
+
if (hub.status === 'offline') {
|
|
106
|
+
return 'stopped';
|
|
107
|
+
}
|
|
108
|
+
const nowS = Math.floor(Date.now() / 1000);
|
|
109
|
+
const fresh = hub.lastSeen > 0 && nowS - hub.lastSeen <= freshS;
|
|
110
|
+
return fresh ? 'running' : 'wedged';
|
|
111
|
+
}
|
|
112
|
+
async function buildLsRows(remote, config, onlyId) {
|
|
113
|
+
const entries = await registry.list();
|
|
114
|
+
let hub = [];
|
|
115
|
+
try {
|
|
116
|
+
hub = await remote.listConductors('me');
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
hub = [];
|
|
120
|
+
}
|
|
121
|
+
const hubById = new Map(hub.map((c) => [c.id, c]));
|
|
122
|
+
const freshS = freshnessThresholdS(config);
|
|
123
|
+
const ids = new Set();
|
|
124
|
+
for (const e of entries) {
|
|
125
|
+
ids.add(e.id);
|
|
126
|
+
}
|
|
127
|
+
for (const c of hub) {
|
|
128
|
+
ids.add(c.id);
|
|
129
|
+
}
|
|
130
|
+
const rows = [];
|
|
131
|
+
for (const id of ids) {
|
|
132
|
+
if (onlyId && id !== onlyId) {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const entry = entries.find((e) => e.id === id);
|
|
136
|
+
const h = hubById.get(id);
|
|
137
|
+
rows.push({
|
|
138
|
+
id,
|
|
139
|
+
project: entry?.project ?? h?.project ?? '',
|
|
140
|
+
label: entry?.label ?? h?.label ?? '',
|
|
141
|
+
host: entry?.host ?? '-',
|
|
142
|
+
status: classify(h, freshS),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
return rows;
|
|
146
|
+
}
|
|
147
|
+
function printRows(rows) {
|
|
148
|
+
if (rows.length === 0) {
|
|
149
|
+
console.log('no conductors registered');
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
for (const r of rows) {
|
|
153
|
+
console.log(`${r.id}\t${r.status}\t${r.host}\t${r.project}\t${r.label}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// --- command handlers -------------------------------------------------------
|
|
157
|
+
/**
|
|
158
|
+
* Start-time auth gate. Returns true if authenticated (session or session-less
|
|
159
|
+
* provider); otherwise logs a single clear line and returns false. Must run
|
|
160
|
+
* before remote resolution (resolveRemote), which calls resolveAuth and throws
|
|
161
|
+
* when unauthenticated.
|
|
162
|
+
*/
|
|
163
|
+
export async function ensureAuthenticated(config, logger) {
|
|
164
|
+
const st = await authStatus({
|
|
165
|
+
baseUrl: config.site.base_url,
|
|
166
|
+
plugins: config.plugins ?? [],
|
|
167
|
+
});
|
|
168
|
+
if (!st.loggedIn) {
|
|
169
|
+
logger.error({ baseUrl: config.site.base_url }, `not logged in against ${config.site.base_url} — run 'gaia dropsh auth login'`);
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
async function cmdPoll(deps, log = {}) {
|
|
175
|
+
const config = await resolveConfig(deps);
|
|
176
|
+
const checkoutRoot = checkoutRootOf(config);
|
|
177
|
+
const logger = loggerFor(checkoutRoot, log);
|
|
178
|
+
if (!(await ensureAuthenticated(config, logger)))
|
|
179
|
+
return;
|
|
180
|
+
const remote = await resolveRemote(deps, config);
|
|
181
|
+
const executor = deps.executor ?? (await selectExecutor(config, logger));
|
|
182
|
+
const workspace = deps.workspace ?? (await selectWorkspace(config));
|
|
183
|
+
const agent = deps.agent ?? (await selectAgent(config));
|
|
184
|
+
const conductor = new Conductor(config, remote, executor, workspace, agent, logger, checkoutRoot);
|
|
185
|
+
await conductor.start();
|
|
186
|
+
await conductor.tick();
|
|
187
|
+
}
|
|
188
|
+
async function cmdReap(deps, log = {}) {
|
|
189
|
+
const config = await resolveConfig(deps);
|
|
190
|
+
const checkoutRoot = checkoutRootOf(config);
|
|
191
|
+
const logger = loggerFor(checkoutRoot, log);
|
|
192
|
+
if (!(await ensureAuthenticated(config, logger)))
|
|
193
|
+
return;
|
|
194
|
+
const remote = await resolveRemote(deps, config);
|
|
195
|
+
const executor = deps.executor ?? (await selectExecutor(config, logger));
|
|
196
|
+
const workspace = deps.workspace ?? (await selectWorkspace(config));
|
|
197
|
+
const agent = deps.agent ?? (await selectAgent(config));
|
|
198
|
+
const conductor = new Conductor(config, remote, executor, workspace, agent, logger, checkoutRoot);
|
|
199
|
+
// The reaper reconciles finished-but-uncleaned tickets (the cleaned_up flag)
|
|
200
|
+
// against their worktrees; it needs no registration/heartbeat (it is not a
|
|
201
|
+
// poll), just the executor + remote, so it runs standalone after a
|
|
202
|
+
// crash/restart.
|
|
203
|
+
await conductor.reap();
|
|
204
|
+
}
|
|
205
|
+
async function cmdStartForeground(deps, log = {}) {
|
|
206
|
+
const config = await resolveConfig(deps);
|
|
207
|
+
const checkoutRoot = checkoutRootOf(config);
|
|
208
|
+
const logger = loggerFor(checkoutRoot, log);
|
|
209
|
+
if (!(await ensureAuthenticated(config, logger)))
|
|
210
|
+
return;
|
|
211
|
+
const remote = await resolveRemote(deps, config);
|
|
212
|
+
const executor = deps.executor ?? (await selectExecutor(config, logger));
|
|
213
|
+
const workspace = deps.workspace ?? (await selectWorkspace(config));
|
|
214
|
+
const agent = deps.agent ?? (await selectAgent(config));
|
|
215
|
+
const conductor = new Conductor(config, remote, executor, workspace, agent, logger, checkoutRoot);
|
|
216
|
+
await conductor.start();
|
|
217
|
+
const controller = new AbortController();
|
|
218
|
+
const onSignal = () => controller.abort();
|
|
219
|
+
process.once('SIGINT', onSignal);
|
|
220
|
+
process.once('SIGTERM', onSignal);
|
|
221
|
+
try {
|
|
222
|
+
await conductor.serve(controller.signal);
|
|
223
|
+
}
|
|
224
|
+
finally {
|
|
225
|
+
process.removeListener('SIGINT', onSignal);
|
|
226
|
+
process.removeListener('SIGTERM', onSignal);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
async function cmdStart(deps, log = {}) {
|
|
230
|
+
const config = await resolveConfig(deps);
|
|
231
|
+
const checkoutRoot = checkoutRootOf(config);
|
|
232
|
+
const logger = loggerFor(checkoutRoot, log);
|
|
233
|
+
if (!(await ensureAuthenticated(config, logger)))
|
|
234
|
+
return;
|
|
235
|
+
const remote = await resolveRemote(deps, config);
|
|
236
|
+
const id = conductorIdOf(config);
|
|
237
|
+
const existing = await registry.get(id);
|
|
238
|
+
if (existing) {
|
|
239
|
+
const hubStatus = await remote.getConductorStatus(id);
|
|
240
|
+
if (hubStatus !== null && hubStatus !== 'offline') {
|
|
241
|
+
console.log(`conductor already running for ${config.project}`);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const handle = `gaia-conductor:${id}`;
|
|
246
|
+
await registry.register({
|
|
247
|
+
id,
|
|
248
|
+
path: checkoutRoot,
|
|
249
|
+
project: config.project,
|
|
250
|
+
label: config.label,
|
|
251
|
+
host: 'herdr',
|
|
252
|
+
handle,
|
|
253
|
+
});
|
|
254
|
+
// The detached foreground process is a fresh CLI invocation — forward the
|
|
255
|
+
// log flags so the herdr-hosted loop logs at the requested level. Sink stays
|
|
256
|
+
// forced to file (herdr-hosted = no TTY) unless the caller overrode it.
|
|
257
|
+
const fgFlags = [
|
|
258
|
+
log.level ? `--log-level ${log.level}` : '',
|
|
259
|
+
log.sink ? `--log-sink ${log.sink}` : '',
|
|
260
|
+
]
|
|
261
|
+
.filter(Boolean)
|
|
262
|
+
.join(' ');
|
|
263
|
+
const fgCmd = `GAIA_CONDUCTOR_LOG=file gaia conductor ${fgFlags} start --foreground`.replace(/\s+/g, ' ');
|
|
264
|
+
try {
|
|
265
|
+
await spawnViaHerdr(handle, checkoutRoot, fgCmd);
|
|
266
|
+
logger.info({ id, handle }, 'started conductor via herdr');
|
|
267
|
+
}
|
|
268
|
+
catch (err) {
|
|
269
|
+
logger.error({ id, err: err.message }, 'could not start via herdr');
|
|
270
|
+
logger.warn({}, 'Hint: run `gaia conductor start --foreground` under a service manager (systemd-user / docker) on hosts without herdr.');
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
async function cmdStop(deps, now) {
|
|
274
|
+
const config = await resolveConfig(deps);
|
|
275
|
+
const remote = await resolveRemote(deps, config);
|
|
276
|
+
const id = conductorIdOf(config);
|
|
277
|
+
if (now) {
|
|
278
|
+
const entry = await registry.get(id);
|
|
279
|
+
if (entry && entry.host === 'herdr') {
|
|
280
|
+
await killViaHerdr(entry.handle);
|
|
281
|
+
console.log(`hard-stopped conductor ${id} (${entry.handle})`);
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
console.log(`no herdr-hosted conductor to hard-stop for ${id}`);
|
|
285
|
+
}
|
|
286
|
+
// A hard kill just stops the heartbeat; it never deletes the entity. The
|
|
287
|
+
// Drupal cron reaper flips the now-stale registration to offline (single
|
|
288
|
+
// authority for the offline transition — see gaia_core cron).
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
// Graceful stop: there is no drain phase — the conductor goes offline at once,
|
|
292
|
+
// just like a hard kill, but writes offline itself instead of waiting for the
|
|
293
|
+
// cron reaper. Stop the process first (else its next heartbeat would flip it
|
|
294
|
+
// back online), then mark it offline. In-flight runs are not awaited.
|
|
295
|
+
const entry = await registry.get(id);
|
|
296
|
+
if (entry && entry.host === 'herdr') {
|
|
297
|
+
await killViaHerdr(entry.handle);
|
|
298
|
+
}
|
|
299
|
+
await remote.setConductorStatus(id, 'offline');
|
|
300
|
+
console.log(`stopped conductor ${id} (offline)`);
|
|
301
|
+
}
|
|
302
|
+
async function cmdLs(deps) {
|
|
303
|
+
const config = deps.config ?? (await tryConfig(deps));
|
|
304
|
+
const remote = await resolveRemote(deps, config ?? (await resolveConfig(deps)));
|
|
305
|
+
const rows = await buildLsRows(remote, config);
|
|
306
|
+
printRows(rows);
|
|
307
|
+
}
|
|
308
|
+
async function cmdStatus(deps) {
|
|
309
|
+
const config = await resolveConfig(deps);
|
|
310
|
+
const remote = await resolveRemote(deps, config);
|
|
311
|
+
const id = conductorIdOf(config);
|
|
312
|
+
const rows = await buildLsRows(remote, config, id);
|
|
313
|
+
printRows(rows);
|
|
314
|
+
}
|
|
315
|
+
async function cmdRm(deps) {
|
|
316
|
+
const config = await resolveConfig(deps);
|
|
317
|
+
const id = conductorIdOf(config);
|
|
318
|
+
await registry.remove(id);
|
|
319
|
+
console.log(`removed conductor ${id} from registry`);
|
|
320
|
+
}
|
|
321
|
+
/** ls may run without a config file; best-effort load. */
|
|
322
|
+
async function tryConfig(deps) {
|
|
323
|
+
if (deps.config) {
|
|
324
|
+
return deps.config;
|
|
325
|
+
}
|
|
326
|
+
try {
|
|
327
|
+
return await loadConductorConfig(defaultConfigPath());
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
return undefined;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
/** Prompt for the developer Kürzel / user id on an interactive terminal. */
|
|
334
|
+
async function promptUserId() {
|
|
335
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
336
|
+
try {
|
|
337
|
+
const answer = await new Promise((resolve) => rl.question('Your Kürzel / user id: ', resolve));
|
|
338
|
+
return answer.trim();
|
|
339
|
+
}
|
|
340
|
+
finally {
|
|
341
|
+
rl.close();
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
/** Prompt for the OAuth client secret without echoing the typed characters. */
|
|
345
|
+
async function promptSecret() {
|
|
346
|
+
const rl = createInterface({
|
|
347
|
+
input: process.stdin,
|
|
348
|
+
output: process.stdout,
|
|
349
|
+
terminal: true,
|
|
350
|
+
});
|
|
351
|
+
// Mute character echo while the secret is typed.
|
|
352
|
+
rl._writeToOutput = (s) => {
|
|
353
|
+
if (!rl.muted || s.includes('\n'))
|
|
354
|
+
process.stdout.write(s);
|
|
355
|
+
};
|
|
356
|
+
try {
|
|
357
|
+
const answer = await new Promise((resolve) => {
|
|
358
|
+
rl.question('OAuth client secret: ', (a) => {
|
|
359
|
+
process.stdout.write('\n');
|
|
360
|
+
resolve(a);
|
|
361
|
+
});
|
|
362
|
+
rl.muted = true;
|
|
363
|
+
});
|
|
364
|
+
return answer.trim();
|
|
365
|
+
}
|
|
366
|
+
finally {
|
|
367
|
+
rl.close();
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
// --- program ----------------------------------------------------------------
|
|
371
|
+
function buildProgram(deps) {
|
|
372
|
+
const program = new Command();
|
|
373
|
+
program.name('gaia').description('GAIA conductor + client CLI');
|
|
374
|
+
const conductor = program
|
|
375
|
+
.command('conductor')
|
|
376
|
+
.description('node-agent lifecycle + local registry')
|
|
377
|
+
.option('--log-level <level>', 'log verbosity: debug | info | warn | error (overrides GAIA_LOG_LEVEL)')
|
|
378
|
+
.option('--log-sink <sink>', 'log sink: stdout | file (overrides GAIA_CONDUCTOR_LOG)')
|
|
379
|
+
.addHelpText('after', `
|
|
380
|
+
Logging (precedence: CLI flag > env var > default):
|
|
381
|
+
--log-level <level> debug | info (default) | warn | error.
|
|
382
|
+
Set debug to see per-poll ticks, claims, and idle cycles.
|
|
383
|
+
--log-sink <sink> stdout | file (writes <checkoutRoot>/log.txt).
|
|
384
|
+
Default: stdout on a TTY, file otherwise.
|
|
385
|
+
GAIA_LOG_LEVEL env fallback for --log-level.
|
|
386
|
+
GAIA_CONDUCTOR_LOG env fallback for --log-sink.
|
|
387
|
+
|
|
388
|
+
Examples:
|
|
389
|
+
gaia conductor --log-level debug start --foreground
|
|
390
|
+
gaia conductor --log-level debug poll
|
|
391
|
+
GAIA_LOG_LEVEL=debug gaia conductor poll`);
|
|
392
|
+
conductor
|
|
393
|
+
.command('start')
|
|
394
|
+
.description('start the conductor loop (herdr-hosted by default)')
|
|
395
|
+
.option('--foreground', 'run the loop in this process', false)
|
|
396
|
+
.action(async function (opts) {
|
|
397
|
+
if (opts.foreground) {
|
|
398
|
+
await cmdStartForeground(deps, logOptsOf(this));
|
|
399
|
+
}
|
|
400
|
+
else {
|
|
401
|
+
await cmdStart(deps, logOptsOf(this));
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
conductor
|
|
405
|
+
.command('poll')
|
|
406
|
+
.description('run one conductor cycle then exit')
|
|
407
|
+
.action(async function () {
|
|
408
|
+
await cmdPoll(deps, logOptsOf(this));
|
|
409
|
+
});
|
|
410
|
+
conductor
|
|
411
|
+
.command('reap')
|
|
412
|
+
.description('tear down herdr worktrees of finished-but-uncleaned tickets (recovers orphans a live tick missed: conductor was down at done, restarted, or reassigned)')
|
|
413
|
+
.action(async function () {
|
|
414
|
+
await cmdReap(deps, logOptsOf(this));
|
|
415
|
+
});
|
|
416
|
+
conductor
|
|
417
|
+
.command('stop')
|
|
418
|
+
.description('stop the conductor: graceful offline (default) or hard kill (--now)')
|
|
419
|
+
.option('--now', 'hard-kill via host and let the cron reaper mark it offline', false)
|
|
420
|
+
.action(async (opts) => {
|
|
421
|
+
await cmdStop(deps, opts.now);
|
|
422
|
+
});
|
|
423
|
+
conductor
|
|
424
|
+
.command('ls')
|
|
425
|
+
.description('list conductors on this machine + status')
|
|
426
|
+
.action(async () => {
|
|
427
|
+
await cmdLs(deps);
|
|
428
|
+
});
|
|
429
|
+
conductor
|
|
430
|
+
.command('status')
|
|
431
|
+
.description('status of the conductor for this checkout')
|
|
432
|
+
.action(async () => {
|
|
433
|
+
await cmdStatus(deps);
|
|
434
|
+
});
|
|
435
|
+
conductor
|
|
436
|
+
.command('rm')
|
|
437
|
+
.description('deregister the conductor for this checkout')
|
|
438
|
+
.action(async () => {
|
|
439
|
+
await cmdRm(deps);
|
|
440
|
+
});
|
|
441
|
+
conductor
|
|
442
|
+
.command('init')
|
|
443
|
+
.description('scaffold the committed .gaia/conductor.config.js for this repo plus the user-global conductor.config.machine.js context (identity + connection incl. secret)')
|
|
444
|
+
.option('--base-url <url>', 'control-plane base URL (site.base_url) — required only when onboarding this machine')
|
|
445
|
+
.option('--project <name>', 'GAIA project name — required only to scaffold the committed repo config; omit for machine-only onboarding')
|
|
446
|
+
.option('--secret-env <VAR>', 'env var name to read the oauth client secret from (else TTY prompt)')
|
|
447
|
+
.option('--client-id <id>', 'oauth consumer id', 'gaia-agent')
|
|
448
|
+
.option('--machine-id <id>', 'machine host token for the context (defaults to hostname())')
|
|
449
|
+
.option('--user-id <kuerzel>', 'developer Kürzel for the user-global context')
|
|
450
|
+
.option('--machine-path <path>', 'user-global machine context path (defaults to ~/.config/conductor/conductor.config.machine.js)')
|
|
451
|
+
.option('--config <path>', 'target committed config path', './.gaia/conductor.config.js')
|
|
452
|
+
.option('--force', 'overwrite an existing committed config', false)
|
|
453
|
+
.option('--reonboard', 'force machine-context onboarding even if a context file exists', false)
|
|
454
|
+
.action(async (opts) => {
|
|
455
|
+
// Two independent axes decide what init writes:
|
|
456
|
+
// - machine axis: an existing context means project-only; --reonboard
|
|
457
|
+
// (or an absent context) forces machine-context (re)scaffolding.
|
|
458
|
+
// - repo axis: --project scaffolds the committed repo config; omitting
|
|
459
|
+
// it means machine-only (no repo). The 4 quadrants:
|
|
460
|
+
// context absent + project → both files
|
|
461
|
+
// context absent + no proj → machine-only onboarding
|
|
462
|
+
// context present + project → project-only
|
|
463
|
+
// context present + no proj → no-op (machine already onboarded)
|
|
464
|
+
const machinePath = opts.machinePath ?? machineContextPath();
|
|
465
|
+
const existing = await readMachineContext(machinePath);
|
|
466
|
+
const contextPresent = existsSync(machinePath);
|
|
467
|
+
const onboarding = !contextPresent || opts.reonboard;
|
|
468
|
+
const project = opts.project ?? '';
|
|
469
|
+
const hasProject = project.trim() !== '';
|
|
470
|
+
// Context present and nothing repo-scoped to do → no-op with guidance.
|
|
471
|
+
if (!onboarding && !hasProject) {
|
|
472
|
+
console.log(`machine already onboarded (${machinePath}) — pass --project to set up a repo`);
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
let userId = opts.userId ?? '';
|
|
476
|
+
let secret = '';
|
|
477
|
+
const baseUrl = opts.baseUrl ?? '';
|
|
478
|
+
if (onboarding) {
|
|
479
|
+
// Full onboarding: base-url + user-id + secret are required here.
|
|
480
|
+
if (baseUrl.trim() === '') {
|
|
481
|
+
throw new Error('--base-url is required when onboarding this machine (no machine context yet, or --reonboard)');
|
|
482
|
+
}
|
|
483
|
+
if (userId.trim() === '') {
|
|
484
|
+
if (process.stdin.isTTY)
|
|
485
|
+
userId = await promptUserId();
|
|
486
|
+
if (userId.trim() === '') {
|
|
487
|
+
throw new Error('--user-id is required (or run in a TTY to be prompted)');
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
// The secret lives in the machine context; resolve it only when that
|
|
491
|
+
// context does not already carry one (existing values are never
|
|
492
|
+
// overwritten). Source: --secret-env value, else a TTY prompt.
|
|
493
|
+
if (typeof existing.client_secret !== 'string' ||
|
|
494
|
+
existing.client_secret.trim() === '') {
|
|
495
|
+
secret =
|
|
496
|
+
opts.secretEnv !== undefined
|
|
497
|
+
? (process.env[opts.secretEnv] ?? '')
|
|
498
|
+
: '';
|
|
499
|
+
if (secret.trim() === '') {
|
|
500
|
+
if (process.stdin.isTTY)
|
|
501
|
+
secret = await promptSecret();
|
|
502
|
+
if (secret.trim() === '') {
|
|
503
|
+
throw new Error('OAuth client secret required: pass --secret-env <VAR> (exported) or run in a TTY to be prompted');
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
const inputs = {
|
|
509
|
+
baseUrl,
|
|
510
|
+
project,
|
|
511
|
+
clientId: opts.clientId,
|
|
512
|
+
secret,
|
|
513
|
+
userId,
|
|
514
|
+
...(opts.machineId !== undefined
|
|
515
|
+
? { machineId: opts.machineId }
|
|
516
|
+
: {}),
|
|
517
|
+
};
|
|
518
|
+
const res = await scaffold(inputs, {
|
|
519
|
+
configPath: opts.config,
|
|
520
|
+
force: opts.force,
|
|
521
|
+
machinePath,
|
|
522
|
+
skipMachine: !onboarding,
|
|
523
|
+
skipCommitted: !hasProject,
|
|
524
|
+
});
|
|
525
|
+
// Mode banner.
|
|
526
|
+
if (!onboarding) {
|
|
527
|
+
console.log(`machine context found (${machinePath}) → setting up project only`);
|
|
528
|
+
if (typeof existing.base_url !== 'string' ||
|
|
529
|
+
existing.base_url.trim() === '') {
|
|
530
|
+
console.log(`warning: ${machinePath} exists but looks incomplete (no base_url) — ` +
|
|
531
|
+
`run with --reonboard to fill the machine context`);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
else if (!hasProject) {
|
|
535
|
+
console.log(`onboarding this machine (no project) → run again with --project inside a repo to set it up`);
|
|
536
|
+
}
|
|
537
|
+
else {
|
|
538
|
+
console.log(opts.reonboard
|
|
539
|
+
? `re-onboarding this machine (--reonboard)`
|
|
540
|
+
: `no machine context → onboarding this machine`);
|
|
541
|
+
}
|
|
542
|
+
if (hasProject) {
|
|
543
|
+
console.log(res.wroteCommitted
|
|
544
|
+
? `wrote ${res.committedPath}`
|
|
545
|
+
: `kept ${res.committedPath} (exists — pass --force to replace)`);
|
|
546
|
+
}
|
|
547
|
+
const m = res.machine;
|
|
548
|
+
if (onboarding) {
|
|
549
|
+
console.log(m.created
|
|
550
|
+
? `wrote ${m.path} (user-global machine context)`
|
|
551
|
+
: m.filledKeys.length > 0
|
|
552
|
+
? `updated ${m.path} (filled: ${m.filledKeys.join(', ')})`
|
|
553
|
+
: `kept ${m.path} (already complete)`);
|
|
554
|
+
}
|
|
555
|
+
console.log(`\nNext steps:\n` +
|
|
556
|
+
` gaia dropsh auth login --provider session\n` +
|
|
557
|
+
` gaia dropsh auth login --provider pm\n` +
|
|
558
|
+
` gaia dropsh auth status # both profiles present`);
|
|
559
|
+
});
|
|
560
|
+
return program;
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Attach the inherited dropsh CLI as a `gaia dropsh ...` subcommand.
|
|
564
|
+
*
|
|
565
|
+
* When a config is loadable, mounts the dropsh program built with the
|
|
566
|
+
* conductor's plugins (auth providers, etc.). Best-effort: if no config is
|
|
567
|
+
* loadable, the passthrough is simply not mounted.
|
|
568
|
+
*
|
|
569
|
+
* dropsh's own commands (e.g. `auth login`) reload their config from
|
|
570
|
+
* `--config` / `$DROPSH_CONFIG` / the `dropsh.config.js` default — they do not
|
|
571
|
+
* see the conductor config we already loaded. Default `$DROPSH_CONFIG` to the
|
|
572
|
+
* conductor's own config path so `gaia dropsh auth login` uses the same site +
|
|
573
|
+
* plugins without an explicit `--config` flag. An existing `$DROPSH_CONFIG`
|
|
574
|
+
* (or a `--config` flag) still wins.
|
|
575
|
+
*/
|
|
576
|
+
/** Actionable hint shown when a `gaia dropsh` command runs with no loadable config. */
|
|
577
|
+
function dropshConfigHint(resolvedPath) {
|
|
578
|
+
return (`gaia dropsh: no conductor config could be loaded (looked at ${resolvedPath}).\n` +
|
|
579
|
+
`Point at one with --config <path>, set $DROPSH_CONFIG, or run from a directory ` +
|
|
580
|
+
`containing .gaia/conductor.config.js.`);
|
|
581
|
+
}
|
|
582
|
+
async function attachDropsh(program, deps) {
|
|
583
|
+
const config = deps.config ?? (await tryConfig(deps));
|
|
584
|
+
if (config && !process.env.DROPSH_CONFIG) {
|
|
585
|
+
process.env.DROPSH_CONFIG = config.config_path;
|
|
586
|
+
}
|
|
587
|
+
const dropsh = buildDropshProgram({
|
|
588
|
+
plugins: config?.plugins ?? [],
|
|
589
|
+
});
|
|
590
|
+
if (!config) {
|
|
591
|
+
// Registration is unconditional; a config problem must surface WHEN a dropsh
|
|
592
|
+
// command runs, not make the command vanish. The hook fires only on a real
|
|
593
|
+
// subcommand dispatch — never for `--help` — so discoverability holds.
|
|
594
|
+
dropsh.hook('preSubcommand', async (thisCommand) => {
|
|
595
|
+
const override = thisCommand.opts().config ??
|
|
596
|
+
process.env.DROPSH_CONFIG;
|
|
597
|
+
const resolved = defaultConfigPath(override);
|
|
598
|
+
let ok = false;
|
|
599
|
+
try {
|
|
600
|
+
await loadConductorConfig(resolved);
|
|
601
|
+
ok = true;
|
|
602
|
+
}
|
|
603
|
+
catch {
|
|
604
|
+
ok = false;
|
|
605
|
+
}
|
|
606
|
+
if (!ok) {
|
|
607
|
+
throw new Error(dropshConfigHint(resolved));
|
|
608
|
+
}
|
|
609
|
+
if (!process.env.DROPSH_CONFIG) {
|
|
610
|
+
process.env.DROPSH_CONFIG = resolved;
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
program.addCommand(dropsh);
|
|
615
|
+
}
|
|
616
|
+
export async function runGaiaCli(argv, deps = {}) {
|
|
617
|
+
const program = buildProgram(deps);
|
|
618
|
+
await attachDropsh(program, deps);
|
|
619
|
+
try {
|
|
620
|
+
await program.parseAsync(argv, { from: 'user' });
|
|
621
|
+
}
|
|
622
|
+
catch (err) {
|
|
623
|
+
// The mounted dropsh program calls exitOverride(), so commander surfaces
|
|
624
|
+
// help/errors as a thrown CommanderError instead of exiting the process.
|
|
625
|
+
// `--help`/`--version` are clean exits; commander-generated errors have
|
|
626
|
+
// already written their message to stderr, so only report our own thrown
|
|
627
|
+
// errors (e.g. the dropsh missing-config hint) here.
|
|
628
|
+
const e = err;
|
|
629
|
+
if (e.code === 'commander.helpDisplayed' ||
|
|
630
|
+
e.code === 'commander.version') {
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
if (typeof e.code === 'string' && e.code.startsWith('commander.')) {
|
|
634
|
+
process.exitCode = process.exitCode ?? 1;
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
process.stderr.write(`${e.message ?? String(err)}\n`);
|
|
638
|
+
process.exitCode = process.exitCode ?? 1;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
export async function main(argv) {
|
|
642
|
+
// Drop node + script path; commander parses the rest as user args.
|
|
643
|
+
await runGaiaCli(argv.slice(2));
|
|
644
|
+
}
|