@gaia-ai/conductor 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +5 -0
- package/dist/src/cli/gaia.d.ts +21 -0
- package/dist/src/cli/gaia.js +690 -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 +51 -0
- package/dist/src/config.js +277 -0
- package/dist/src/core/conductor.d.ts +69 -0
- package/dist/src/core/conductor.js +389 -0
- package/dist/src/index.d.ts +8 -0
- package/dist/src/index.js +5 -0
- package/package.json +34 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
/**
|
|
6
|
+
* Default agent prompt - the GAIA run contract. One run works EXACTLY one state;
|
|
7
|
+
* the agent must stop instead of running the whole flow in one session. The run
|
|
8
|
+
* is closed automatically when the ticket state changes on the next claim - the
|
|
9
|
+
* agent does not release it. Run mechanics live here (not in the repo's
|
|
10
|
+
* WORKFLOW.md). The prompt routes through the gaia skill (`ticket:run`) rather
|
|
11
|
+
* than pointing at WORKFLOW.md directly (GAIA-125): the intake splash + state
|
|
12
|
+
* engine are a skill mechanic, so a bare "follow WORKFLOW.md" pointer left the
|
|
13
|
+
* splash unrendered unless an external skill-forcing hook happened to fire. A
|
|
14
|
+
* conductor config may override via the `prompt` field.
|
|
15
|
+
*
|
|
16
|
+
* The ticket + its comments are NOT embedded (GAIA-112): embedding unbounded
|
|
17
|
+
* ticket content into a single typed pane line overran the PTY canonical line
|
|
18
|
+
* cap and truncated the dispatch command. Instead the prompt is a bounded,
|
|
19
|
+
* constant-size pointer and the agent reads the ticket + all comments at run
|
|
20
|
+
* start via `gaia dropsh read … --include comments`.
|
|
21
|
+
*
|
|
22
|
+
* HINT — the canonical cap is platform-specific: MAX_CANON is 4096 B on Linux
|
|
23
|
+
* but only 1024 B on macOS (a whole line >= 1024 B is silently DROPPED there).
|
|
24
|
+
* herdr types env-prefix + this prompt + flags as ONE line, so keep the total
|
|
25
|
+
* well under 1024 B — i.e. keep this prompt short (~a few hundred bytes). Do NOT
|
|
26
|
+
* grow it back toward the old ~1 KB, or macOS dispatch truncates silently again.
|
|
27
|
+
*
|
|
28
|
+
* The prompt is a SINGLE LINE — no newlines, no control chars (GAIA-128). herdr
|
|
29
|
+
* types it into the pane as one line, so a newline would submit early and any
|
|
30
|
+
* control char would force bash-only `$'…'` quoting that fish can't parse.
|
|
31
|
+
* Being single-line + bounded, plain `'…'` shell-quoting suffices (fish-safe)
|
|
32
|
+
* and the typed line stays well under the cap (1024 B on macOS, 4096 B on Linux)
|
|
33
|
+
* — so NO base64/`bash -c` wrapper and NO multiline handling are needed (that
|
|
34
|
+
* wrapper, GAIA-118, was the thing that re-inflated the line past the cap and
|
|
35
|
+
* truncated it mid-quote).
|
|
36
|
+
* Placeholders: `{identifier}`, `{state}`, `{runUuid}` ({state} falls back to
|
|
37
|
+
* `triage` for unclassified tickets).
|
|
38
|
+
*/
|
|
39
|
+
export const DEFAULT_AGENT_PROMPT = `You are a GAIA agent working ticket {identifier}, current state: {state}. ` +
|
|
40
|
+
`Invoke the gaia skill and run \`ticket:run {identifier} {state}\` — it first ` +
|
|
41
|
+
`reads the ticket + all comments ` +
|
|
42
|
+
`(gaia dropsh read gaia_ticket/gaia_ticket/$GAIA_ID --include comments), ` +
|
|
43
|
+
`renders the intake splash, runs the {state} engine, and applies ` +
|
|
44
|
+
`WORKFLOW.md's {state} policy. Do ONLY the {state} work — never start or ` +
|
|
45
|
+
`prepare a later state. When done, or if you are blocked, STOP and run /exit.`;
|
|
46
|
+
function requirePlugin(value, kind) {
|
|
47
|
+
if (!isRecord(value) || value.kind !== kind) {
|
|
48
|
+
throw new Error(`conductor config requires a ${kind} plugin in the "${kind}" slot`);
|
|
49
|
+
}
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* A named-plugin descriptor `{ plugin, export?, with?/options? }` — the
|
|
54
|
+
* import-free form. `config.ts` resolves it into a constructed plugin, so a
|
|
55
|
+
* committed conductor.config.js need not `import` its plugin packages (which
|
|
56
|
+
* would resolve relative to the config file, not the CLI — the source of the
|
|
57
|
+
* "unknown command dropsh" breakage after the @gaia → @gaia-ai scope rename).
|
|
58
|
+
* Mirror of dropsh 0.4.1 `loadNamedPlugin` / `resolveSlot`.
|
|
59
|
+
*/
|
|
60
|
+
function isPluginDescriptor(entry) {
|
|
61
|
+
return isRecord(entry) && typeof entry.plugin === 'string';
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Resolve + construct a descriptor. Resolution base order, ESLint-style:
|
|
65
|
+
* config dir → cwd → conductor install (`import.meta.url`) — the first
|
|
66
|
+
* `createRequire(base).resolve(name)` that succeeds wins. Then `import` the
|
|
67
|
+
* module, pick `export` (default 'default'), and call the factory with
|
|
68
|
+
* `with` (falling back to `options`).
|
|
69
|
+
*/
|
|
70
|
+
async function loadNamedPlugin(entry, configPath) {
|
|
71
|
+
const bases = [
|
|
72
|
+
pathToFileURL(configPath).href,
|
|
73
|
+
pathToFileURL(`${process.cwd()}/`).href,
|
|
74
|
+
import.meta.url,
|
|
75
|
+
];
|
|
76
|
+
let resolved;
|
|
77
|
+
for (const base of bases) {
|
|
78
|
+
try {
|
|
79
|
+
resolved = createRequire(base).resolve(entry.plugin);
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// try the next base
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (resolved === undefined) {
|
|
87
|
+
throw new Error(`conductor config cannot resolve plugin '${entry.plugin}'`);
|
|
88
|
+
}
|
|
89
|
+
const mod = (await import(pathToFileURL(resolved).href));
|
|
90
|
+
let factory;
|
|
91
|
+
if (typeof entry.export === 'string') {
|
|
92
|
+
factory = mod[entry.export];
|
|
93
|
+
if (typeof factory !== 'function') {
|
|
94
|
+
throw new Error(`plugin '${entry.plugin}' has no callable export '${entry.export}'`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
else if (typeof mod.default === 'function') {
|
|
98
|
+
factory = mod.default;
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
const fns = Object.keys(mod).filter((k) => typeof mod[k] === 'function');
|
|
102
|
+
if (fns.length === 1) {
|
|
103
|
+
factory = mod[fns[0]];
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
throw new Error(`plugin '${entry.plugin}' has no default export and ${fns.length} function exports (${fns.join(', ')}); specify "export"`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return factory(entry.with ?? entry.options);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Resolve a slot value: a descriptor is constructed via `loadNamedPlugin`, an
|
|
113
|
+
* already-constructed plugin passes straight through (backward-compat). Either
|
|
114
|
+
* way the kind-guard runs, so a wrong-target descriptor still errors.
|
|
115
|
+
*/
|
|
116
|
+
async function resolveSlot(value, kind, configPath) {
|
|
117
|
+
const resolved = isPluginDescriptor(value)
|
|
118
|
+
? await loadNamedPlugin(value, configPath)
|
|
119
|
+
: value;
|
|
120
|
+
return requirePlugin(resolved, kind);
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Resolve the `plugins[]` array: descriptor entries are constructed, already-
|
|
124
|
+
* constructed entries pass through. No kind-guard (plugins are not slotted).
|
|
125
|
+
*
|
|
126
|
+
* A factory may return one plugin OR an array of plugins — an aggregator built
|
|
127
|
+
* with dropsh's `composePlugins(a(), b())` returns the flat child list. dropsh's
|
|
128
|
+
* own `loadConfig` flattens such a nested entry one level; we mirror that here so
|
|
129
|
+
* `buildProgram({ plugins })` — which reads each entry's hooks/renderers per
|
|
130
|
+
* top-level element and does NOT recurse — sees every child.
|
|
131
|
+
*/
|
|
132
|
+
async function resolvePlugins(raw, configPath) {
|
|
133
|
+
if (!Array.isArray(raw)) {
|
|
134
|
+
return [];
|
|
135
|
+
}
|
|
136
|
+
const resolved = await Promise.all(raw.map((entry) => isPluginDescriptor(entry) ? loadNamedPlugin(entry, configPath) : entry));
|
|
137
|
+
return resolved.flat();
|
|
138
|
+
}
|
|
139
|
+
function isRecord(value) {
|
|
140
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
141
|
+
}
|
|
142
|
+
function requireNonEmptyString(value, key) {
|
|
143
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
144
|
+
throw new Error(`conductor config requires non-empty ${key}`);
|
|
145
|
+
}
|
|
146
|
+
return value;
|
|
147
|
+
}
|
|
148
|
+
function optionalPositiveInteger(value, fallback, key) {
|
|
149
|
+
if (value === undefined) {
|
|
150
|
+
return fallback;
|
|
151
|
+
}
|
|
152
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {
|
|
153
|
+
throw new Error(`conductor config requires positive integer ${key}`);
|
|
154
|
+
}
|
|
155
|
+
return value;
|
|
156
|
+
}
|
|
157
|
+
/** A repo's config files live in this dir, one `<stem>.config.js` per conductor. */
|
|
158
|
+
const GAIA_DIR = '.gaia';
|
|
159
|
+
/** List the `*.config.js` stems in a `.gaia/` dir (e.g. `shop.config.js` → `shop`). */
|
|
160
|
+
function configStems(gaiaDir) {
|
|
161
|
+
return readdirSync(gaiaDir)
|
|
162
|
+
.filter((f) => f.endsWith('.config.js'))
|
|
163
|
+
.map((f) => f.slice(0, -'.config.js'.length))
|
|
164
|
+
.sort();
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Walk from `cwd` root-ward (git/eslint style) to the nearest ancestor whose
|
|
168
|
+
* `.gaia/` dir holds at least one `*.config.js`; return that `.gaia/` dir, or
|
|
169
|
+
* `undefined` if none is found up to the filesystem root.
|
|
170
|
+
*/
|
|
171
|
+
function findGaiaDir(cwd) {
|
|
172
|
+
let dir = resolve(cwd);
|
|
173
|
+
for (;;) {
|
|
174
|
+
const gaiaDir = join(dir, GAIA_DIR);
|
|
175
|
+
if (existsSync(gaiaDir) && configStems(gaiaDir).length > 0) {
|
|
176
|
+
return gaiaDir;
|
|
177
|
+
}
|
|
178
|
+
const parent = dirname(dir);
|
|
179
|
+
if (parent === dir) {
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
dir = parent;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Resolve the conductor config path from `cwd`.
|
|
187
|
+
*
|
|
188
|
+
* 1. An explicit `--config` / `$GAIA_CONDUCTOR_CONFIG` wins verbatim (no walk).
|
|
189
|
+
* 2. Otherwise walk root-ward to the nearest `.gaia/` dir holding `*.config.js`
|
|
190
|
+
* — so any subdirectory of a project/worktree resolves the same dir.
|
|
191
|
+
* - A `--conductor <name>` / `$GAIA_CONDUCTOR` selector resolves
|
|
192
|
+
* `<gaiaDir>/<name>.config.js` (error listing the stems if absent).
|
|
193
|
+
* - No selector: exactly one config → use it (back-compat: the lone
|
|
194
|
+
* `conductor.config.js`); many → error naming the stems + the selector.
|
|
195
|
+
* 3. No `.gaia/` config anywhere up the tree → an actionable `gaia init` error
|
|
196
|
+
* (never leak a raw "Cannot find module" from a later import()).
|
|
197
|
+
*/
|
|
198
|
+
export function resolveConfigPath(override, cwd = process.cwd(), conductorName) {
|
|
199
|
+
const explicit = override ?? process.env.GAIA_CONDUCTOR_CONFIG;
|
|
200
|
+
if (explicit) {
|
|
201
|
+
return explicit;
|
|
202
|
+
}
|
|
203
|
+
const gaiaDir = findGaiaDir(cwd);
|
|
204
|
+
if (gaiaDir === undefined) {
|
|
205
|
+
throw new Error(`no .gaia/conductor.config.js found from ${cwd} upward; run \`gaia init\``);
|
|
206
|
+
}
|
|
207
|
+
const stems = configStems(gaiaDir);
|
|
208
|
+
const name = conductorName ?? process.env.GAIA_CONDUCTOR;
|
|
209
|
+
if (name !== undefined && name !== '') {
|
|
210
|
+
const candidate = join(gaiaDir, `${name}.config.js`);
|
|
211
|
+
if (!existsSync(candidate)) {
|
|
212
|
+
throw new Error(`no conductor '${name}' in ${gaiaDir}; available: ${stems.join(', ')}`);
|
|
213
|
+
}
|
|
214
|
+
return candidate;
|
|
215
|
+
}
|
|
216
|
+
if (stems.length === 1) {
|
|
217
|
+
return join(gaiaDir, `${stems[0]}.config.js`);
|
|
218
|
+
}
|
|
219
|
+
throw new Error(`${stems.length} conductors in ${gaiaDir} (${stems.join(', ')}); ` +
|
|
220
|
+
'select one with --conductor <name> or $GAIA_CONDUCTOR');
|
|
221
|
+
}
|
|
222
|
+
export async function loadConductorConfig(configFile) {
|
|
223
|
+
const configPath = resolve(configFile);
|
|
224
|
+
const module = (await import(pathToFileURL(configPath).href));
|
|
225
|
+
const raw = module.default;
|
|
226
|
+
if (!isRecord(raw)) {
|
|
227
|
+
throw new Error('conductor config default export must be an object');
|
|
228
|
+
}
|
|
229
|
+
const config = raw;
|
|
230
|
+
const site = isRecord(config.site) ? config.site : {};
|
|
231
|
+
const baseUrl = requireNonEmptyString(site.base_url, 'site.base_url');
|
|
232
|
+
const project = requireNonEmptyString(config.project, 'project');
|
|
233
|
+
if (!Array.isArray(config.states) || config.states.length === 0) {
|
|
234
|
+
throw new Error('conductor config requires non-empty states');
|
|
235
|
+
}
|
|
236
|
+
const states = config.states.map((state) => requireNonEmptyString(state, 'states'));
|
|
237
|
+
// machine_id is required — the config MUST set it; there is no derived
|
|
238
|
+
// fallback. The id is defined in a single place (config.machine_id), which
|
|
239
|
+
// the CLI lifecycle commands and registration all read, never re-derive. The
|
|
240
|
+
// conductor label defaults to it (a conductor is identified by it). A repo's
|
|
241
|
+
// committed config composes it from the machine context
|
|
242
|
+
// (`${user_id}-${machine_id}-${project}`); with several named configs in one
|
|
243
|
+
// .gaia/ (GAIA-126) each composes its own from its own project, so co-located
|
|
244
|
+
// conductors get distinct identities with no filename-based fallback.
|
|
245
|
+
const machineId = requireNonEmptyString(config.machine_id, 'machine_id');
|
|
246
|
+
const label = typeof config.label === 'string' && config.label.trim() !== ''
|
|
247
|
+
? config.label
|
|
248
|
+
: machineId;
|
|
249
|
+
return {
|
|
250
|
+
site: {
|
|
251
|
+
base_url: baseUrl,
|
|
252
|
+
jsonapi_prefix: typeof site.jsonapi_prefix === 'string' &&
|
|
253
|
+
site.jsonapi_prefix.trim() !== ''
|
|
254
|
+
? site.jsonapi_prefix
|
|
255
|
+
: '/jsonapi',
|
|
256
|
+
},
|
|
257
|
+
...(Array.isArray(config.plugins)
|
|
258
|
+
? { plugins: await resolvePlugins(config.plugins, configPath) }
|
|
259
|
+
: {}),
|
|
260
|
+
remote: await resolveSlot(config.remote, 'remote', configPath),
|
|
261
|
+
executor: await resolveSlot(config.executor, 'executor', configPath),
|
|
262
|
+
agent: await resolveSlot(config.agent, 'agent', configPath),
|
|
263
|
+
workspace: await resolveSlot(config.workspace, 'workspace', configPath),
|
|
264
|
+
label,
|
|
265
|
+
machine_id: machineId,
|
|
266
|
+
project,
|
|
267
|
+
states,
|
|
268
|
+
prompt: typeof config.prompt === 'string' && config.prompt.trim() !== ''
|
|
269
|
+
? config.prompt
|
|
270
|
+
: DEFAULT_AGENT_PROMPT,
|
|
271
|
+
max_parallel: optionalPositiveInteger(config.max_parallel, 1, 'max_parallel'),
|
|
272
|
+
poll_interval_ms: optionalPositiveInteger(config.poll_interval_ms, 5000, 'poll_interval_ms'),
|
|
273
|
+
lease_seconds: optionalPositiveInteger(config.lease_seconds, 300, 'lease_seconds'),
|
|
274
|
+
...(config.hooks !== undefined ? { hooks: config.hooks } : {}),
|
|
275
|
+
config_path: configPath,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { type ConductorFileConfig, type ConductorLogger, type GaiaAgent, type GaiaExecutor, type GaiaRemote, type GaiaWorkspace } from '@gaia-ai/core';
|
|
2
|
+
/**
|
|
3
|
+
* Resolve the environment a run executes in (GAIA-99): parse the ticket's
|
|
4
|
+
* effective env_vars, drop any reserved key (loud warn — key NAME only, never
|
|
5
|
+
* the value), then overlay the conductor's core vars so they always win.
|
|
6
|
+
*
|
|
7
|
+
* Values are potentially secret (e.g. a DB DSN): this function logs key names
|
|
8
|
+
* only, never a value. The returned map is injected into BOTH the agent run env
|
|
9
|
+
* and every workspace hook.
|
|
10
|
+
*/
|
|
11
|
+
export declare function resolveRunEnv(effectiveEnvVars: string | undefined, core: Record<string, string>, logger: ConductorLogger): Record<string, string>;
|
|
12
|
+
export declare class Conductor {
|
|
13
|
+
private readonly config;
|
|
14
|
+
private readonly remote;
|
|
15
|
+
private readonly executor;
|
|
16
|
+
private readonly workspace;
|
|
17
|
+
private readonly agent;
|
|
18
|
+
private readonly logger;
|
|
19
|
+
private readonly checkoutRoot;
|
|
20
|
+
private uuid;
|
|
21
|
+
constructor(config: ConductorFileConfig, remote: GaiaRemote, executor: GaiaExecutor, workspace: GaiaWorkspace, agent: GaiaAgent, logger: ConductorLogger, checkoutRoot?: string);
|
|
22
|
+
get id(): string;
|
|
23
|
+
/** This conductor's registration payload, built from config. */
|
|
24
|
+
private registration;
|
|
25
|
+
start(): Promise<void>;
|
|
26
|
+
tick(): Promise<void>;
|
|
27
|
+
/**
|
|
28
|
+
* One-shot finalisation: for each of this conductor's runs that is
|
|
29
|
+
* state=done but not yet closed, read the agent transcript from its worktree
|
|
30
|
+
* and write it as the final log, then mark the run closed. Keyed off the
|
|
31
|
+
* durable `state=done AND closed=false` query — no in-memory state, so a
|
|
32
|
+
* restarted conductor still finalises any unclosed done run on its next tick.
|
|
33
|
+
*/
|
|
34
|
+
private finalizeDoneRuns;
|
|
35
|
+
/**
|
|
36
|
+
* Reconcile finished tickets against their herdr worktrees and tear down any
|
|
37
|
+
* whose workspace still lingers (GAIA-89). Driven by the durable `cleaned_up`
|
|
38
|
+
* flag via {@link GaiaRemote.fetchUncleanedTickets}: every ticket that is
|
|
39
|
+
* finished (state=done OR closed) but not yet cleaned. The SAME reconciliation
|
|
40
|
+
* runs on every tick AND standalone as `gaia conductor reap`, and the query is
|
|
41
|
+
* scoped to THIS conductor (GAIA-121) — so it catches the orphans a
|
|
42
|
+
* live-tick-only path structurally cannot (the conductor was down when the
|
|
43
|
+
* ticket hit `done`, or crashed mid-cleanup) for its OWN tickets. A foreign
|
|
44
|
+
* (non-gaia) worktree has no gaia ticket, so it is never on the work list; a
|
|
45
|
+
* ticket assigned to another conductor is filtered out at the query — both are
|
|
46
|
+
* left untouched with no host enumeration.
|
|
47
|
+
*
|
|
48
|
+
* Per ticket, close is DECOUPLED from teardown (no withholding): a done ticket
|
|
49
|
+
* is closed at once as a lifecycle step, independent of whether its worktree
|
|
50
|
+
* teardown then succeeds. Because the list is conductor-scoped, every ticket's
|
|
51
|
+
* worktree belongs on THIS host: `removeWorktree` returning `false` means the
|
|
52
|
+
* directory was already deleted out of band (nothing left to remove), which is
|
|
53
|
+
* itself a completed teardown → flag `cleaned_up`. There is no cross-host defer
|
|
54
|
+
* (GAIA-121) — the manufactured ambiguity that required it is gone once the
|
|
55
|
+
* query no longer loads foreign tickets. A teardown that THROWS logs loudly and
|
|
56
|
+
* leaves `cleaned_up=0`, so the next reconciliation retries; one miss never
|
|
57
|
+
* orphans the workspace (RC1). Re-running on an already-cleaned ticket is a
|
|
58
|
+
* no-op — it is off the list (idempotent). Each ticket is isolated in a
|
|
59
|
+
* try/catch so one failure never aborts the rest.
|
|
60
|
+
*
|
|
61
|
+
* The `after_done` hook runs through the executor (GAIA-84), which owns the
|
|
62
|
+
* best-effort policy — it logs+swallows a hook failure and never throws, so
|
|
63
|
+
* the core needs no per-call-site try/catch around it.
|
|
64
|
+
*/
|
|
65
|
+
reap(): Promise<void>;
|
|
66
|
+
private dispatch;
|
|
67
|
+
serve(signal?: AbortSignal): Promise<void>;
|
|
68
|
+
private pollLoop;
|
|
69
|
+
}
|