@bakers-build/conductor 0.0.0-stage → 0.1.0-next.42
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 +4 -2
- package/dist/src/bakers-project-context.d.ts +12 -0
- package/dist/src/bakers-project-context.js +59 -0
- package/dist/src/cli/config-plan.d.ts +35 -0
- package/dist/src/cli/config-plan.js +1264 -0
- package/dist/src/cli/config-schema.d.ts +137 -0
- package/dist/src/cli/config-schema.js +239 -0
- package/dist/src/cli/config-source-edit.d.ts +17 -0
- package/dist/src/cli/config-source-edit.js +95 -0
- package/dist/src/cli/config-templates.d.ts +43 -0
- package/dist/src/cli/config-templates.js +130 -0
- package/dist/src/cli/config-validation.d.ts +12 -0
- package/dist/src/cli/config-validation.js +353 -0
- package/dist/src/cli/migrate-addon-names.d.ts +82 -0
- package/dist/src/cli/migrate-addon-names.js +334 -0
- package/dist/src/cli/project-context.d.ts +21 -0
- package/dist/src/cli/project-context.js +105 -0
- package/dist/src/commands/conductor.d.ts +91 -0
- package/dist/src/commands/conductor.js +843 -0
- package/dist/src/config.d.ts +109 -0
- package/dist/src/config.js +712 -0
- package/dist/src/contract.d.ts +13 -0
- package/dist/src/contract.js +19 -0
- package/dist/src/core/action-queue.d.ts +31 -0
- package/dist/src/core/action-queue.js +69 -0
- package/dist/src/core/attempt.d.ts +25 -0
- package/dist/src/core/attempt.js +41 -0
- package/dist/src/core/conductor.d.ts +236 -0
- package/dist/src/core/conductor.js +1293 -0
- package/dist/src/core/host-key.d.ts +19 -0
- package/dist/src/core/host-key.js +27 -0
- package/dist/src/core/inbox-runtime.d.ts +48 -0
- package/dist/src/core/inbox-runtime.js +180 -0
- package/dist/src/core/mr-changes.d.ts +18 -0
- package/dist/src/core/mr-changes.js +47 -0
- package/dist/src/index.d.ts +8 -0
- package/dist/src/index.js +27 -0
- package/dist/src/plugins/agent-args.d.ts +43 -0
- package/dist/src/plugins/agent-args.js +221 -0
- package/dist/src/plugins/agent.d.ts +121 -0
- package/dist/src/plugins/agent.js +12 -0
- package/dist/src/plugins/executor.d.ts +182 -0
- package/dist/src/plugins/executor.js +1 -0
- package/dist/src/plugins/message.d.ts +61 -0
- package/dist/src/plugins/message.js +47 -0
- package/dist/src/plugins/plugins.d.ts +96 -0
- package/dist/src/plugins/plugins.js +120 -0
- package/dist/src/plugins/preset.d.ts +61 -0
- package/dist/src/plugins/preset.js +23 -0
- package/dist/src/plugins/process-host.d.ts +7 -0
- package/dist/src/plugins/process-host.js +1 -0
- package/dist/src/plugins/remote.d.ts +420 -0
- package/dist/src/plugins/remote.js +1 -0
- package/dist/src/plugins/workspace.d.ts +69 -0
- package/dist/src/plugins/workspace.js +1 -0
- package/dist/src/preset.d.ts +2 -0
- package/dist/src/preset.js +8 -0
- package/dist/src/types.d.ts +132 -0
- package/dist/src/types.js +6 -0
- package/package.json +38 -4
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host stem for `bb_machine.host_key` — the one entity that carries the
|
|
3
|
+
* field. `bb_conductor` has none, and reading one off it is precisely what
|
|
4
|
+
* left the host gate with no input at all (BB-450 review B7). Prefer
|
|
5
|
+
* `config.host_key` from MachineContext.machine_id; fall back to a
|
|
6
|
+
* best-effort parse of the composed `conductor_id` only when the loader could
|
|
7
|
+
* not supply the stem.
|
|
8
|
+
*
|
|
9
|
+
* Shared by the `Conductor` registration payload and the lifecycle commands
|
|
10
|
+
* (`stop`/`start`), so both agree on "this machine's" host_key — the input
|
|
11
|
+
* `signalVerifiedProcess()` compares a record's `host_key` against (BB-450
|
|
12
|
+
* AC-4). Two callers computing this independently is exactly how a gate can
|
|
13
|
+
* end up with no input on one side and a different answer on the other.
|
|
14
|
+
*/
|
|
15
|
+
export declare function hostKeyOf(config: {
|
|
16
|
+
host_key?: string;
|
|
17
|
+
conductor_id?: string;
|
|
18
|
+
machine_id?: string;
|
|
19
|
+
}): string;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host stem for `bb_machine.host_key` — the one entity that carries the
|
|
3
|
+
* field. `bb_conductor` has none, and reading one off it is precisely what
|
|
4
|
+
* left the host gate with no input at all (BB-450 review B7). Prefer
|
|
5
|
+
* `config.host_key` from MachineContext.machine_id; fall back to a
|
|
6
|
+
* best-effort parse of the composed `conductor_id` only when the loader could
|
|
7
|
+
* not supply the stem.
|
|
8
|
+
*
|
|
9
|
+
* Shared by the `Conductor` registration payload and the lifecycle commands
|
|
10
|
+
* (`stop`/`start`), so both agree on "this machine's" host_key — the input
|
|
11
|
+
* `signalVerifiedProcess()` compares a record's `host_key` against (BB-450
|
|
12
|
+
* AC-4). Two callers computing this independently is exactly how a gate can
|
|
13
|
+
* end up with no input on one side and a different answer on the other.
|
|
14
|
+
*/
|
|
15
|
+
export function hostKeyOf(config) {
|
|
16
|
+
const fromConfig = config.host_key?.trim();
|
|
17
|
+
if (fromConfig)
|
|
18
|
+
return fromConfig;
|
|
19
|
+
const id = config.conductor_id ?? config.machine_id ?? '';
|
|
20
|
+
const parts = id.split('-');
|
|
21
|
+
if (parts.length >= 3 && parts[0] && parts[1]) {
|
|
22
|
+
const middle = parts.slice(1, -1).join('-');
|
|
23
|
+
if (middle)
|
|
24
|
+
return middle;
|
|
25
|
+
}
|
|
26
|
+
return id;
|
|
27
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ConductorLogger } from '@bakers-build/core';
|
|
2
|
+
import { type MessageResult } from '@bakers-build/message';
|
|
3
|
+
import type { LoadedInbox } from '../plugins/message.js';
|
|
4
|
+
import type { BbRemote, InboxMessage, Ticket } from '../plugins/remote.js';
|
|
5
|
+
import type { ActionQueue } from './action-queue.js';
|
|
6
|
+
/** Per-handler timeout; the request's `signal` aborts after it. */
|
|
7
|
+
export declare const HANDLER_TIMEOUT_MS = 60000;
|
|
8
|
+
type Outcome = {
|
|
9
|
+
status: 'done';
|
|
10
|
+
result: MessageResult;
|
|
11
|
+
} | {
|
|
12
|
+
status: 'failed';
|
|
13
|
+
result: MessageResult;
|
|
14
|
+
};
|
|
15
|
+
export declare class InboxRuntime {
|
|
16
|
+
private readonly addons;
|
|
17
|
+
private readonly remote;
|
|
18
|
+
private readonly logger;
|
|
19
|
+
private readonly env;
|
|
20
|
+
private readonly ids;
|
|
21
|
+
private registeredOk;
|
|
22
|
+
constructor(addons: LoadedInbox[], remote: BbRemote, logger: ConductorLogger, env: (ticket: Ticket) => Promise<Record<string, string>>);
|
|
23
|
+
/** Whether this conductor runs no contract-2 addon. */
|
|
24
|
+
get empty(): boolean;
|
|
25
|
+
/** Whether the last registration reached the server. */
|
|
26
|
+
get registered(): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Registers every loaded addon's contract-2 definition. An empty list is
|
|
29
|
+
* sent too, so a conductor that dropped its addons leaves every version it
|
|
30
|
+
* was listed on.
|
|
31
|
+
*/
|
|
32
|
+
register(conductorId: string): Promise<void>;
|
|
33
|
+
/** The addon and message key a message names, if this conductor runs it. */
|
|
34
|
+
private target;
|
|
35
|
+
/** Runs the handler a message names. Never throws. */
|
|
36
|
+
handle(msg: InboxMessage): Promise<Outcome>;
|
|
37
|
+
/** Runs one handler with the timeout and validates its result. */
|
|
38
|
+
private run;
|
|
39
|
+
}
|
|
40
|
+
/** Answers one inbox message through the contract-2 runtime. Catches its own errors. */
|
|
41
|
+
export declare function answerV2(remote: BbRemote, runtime: InboxRuntime, msg: InboxMessage, logger: ConductorLogger): Promise<void>;
|
|
42
|
+
/**
|
|
43
|
+
* The tick step: enqueue every pending message this conductor handles on the
|
|
44
|
+
* action queue; the queue answers them. Catches its own errors so the tick
|
|
45
|
+
* continues.
|
|
46
|
+
*/
|
|
47
|
+
export declare function runInbox(remote: BbRemote, runtime: InboxRuntime, conductorId: string, logger: ConductorLogger, queue: ActionQueue): Promise<void>;
|
|
48
|
+
export {};
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Contract-2 inbox dispatch (BB-661): one message, one handler, one
|
|
2
|
+
// `{ markdown }` answer. `runInbox` is the tick step: it registers the
|
|
3
|
+
// addon's messages and enqueues every pending inbox message this conductor
|
|
4
|
+
// handles.
|
|
5
|
+
import { existsSync } from 'node:fs';
|
|
6
|
+
import { validateResult } from '@bakers-build/message';
|
|
7
|
+
/** Per-handler timeout; the request's `signal` aborts after it. */
|
|
8
|
+
export const HANDLER_TIMEOUT_MS = 60_000;
|
|
9
|
+
const failed = (text) => ({
|
|
10
|
+
status: 'failed',
|
|
11
|
+
result: { markdown: text },
|
|
12
|
+
});
|
|
13
|
+
export class InboxRuntime {
|
|
14
|
+
addons;
|
|
15
|
+
remote;
|
|
16
|
+
logger;
|
|
17
|
+
env;
|
|
18
|
+
ids = new Map();
|
|
19
|
+
registeredOk = false;
|
|
20
|
+
constructor(addons, remote, logger, env) {
|
|
21
|
+
this.addons = addons;
|
|
22
|
+
this.remote = remote;
|
|
23
|
+
this.logger = logger;
|
|
24
|
+
this.env = env;
|
|
25
|
+
}
|
|
26
|
+
/** Whether this conductor runs no contract-2 addon. */
|
|
27
|
+
get empty() {
|
|
28
|
+
return this.addons.length === 0;
|
|
29
|
+
}
|
|
30
|
+
/** Whether the last registration reached the server. */
|
|
31
|
+
get registered() {
|
|
32
|
+
return this.registeredOk;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Registers every loaded addon's contract-2 definition. An empty list is
|
|
36
|
+
* sent too, so a conductor that dropped its addons leaves every version it
|
|
37
|
+
* was listed on.
|
|
38
|
+
*/
|
|
39
|
+
async register(conductorId) {
|
|
40
|
+
const reg = await this.remote.registerAddons(conductorId, this.addons.map((a) => ({
|
|
41
|
+
name: a.name,
|
|
42
|
+
version: a.version,
|
|
43
|
+
contract: 2,
|
|
44
|
+
messages: a.messages,
|
|
45
|
+
...(a.label !== undefined ? { label: a.label } : {}),
|
|
46
|
+
...(a.icon !== undefined ? { icon: a.icon } : {}),
|
|
47
|
+
})));
|
|
48
|
+
this.registeredOk = true;
|
|
49
|
+
for (const a of reg.accepted)
|
|
50
|
+
this.ids.set(a.name, a.id);
|
|
51
|
+
for (const r of reg.rejected) {
|
|
52
|
+
this.logger.error({ addon: r.name, version: r.version, reason: r.error }, `addon ${r.name}@${r.version} rejected by the server: ${r.error}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** The addon and message key a message names, if this conductor runs it. */
|
|
56
|
+
target(msg) {
|
|
57
|
+
const dot = msg.messageId.indexOf('.');
|
|
58
|
+
const addon = this.addons.find((a) => a.name === msg.messageId.slice(0, dot));
|
|
59
|
+
const key = msg.messageId.slice(dot + 1);
|
|
60
|
+
return dot > 0 && addon?.messages[key] && addon.handlers[key]
|
|
61
|
+
? [addon, key]
|
|
62
|
+
: undefined;
|
|
63
|
+
}
|
|
64
|
+
/** Runs the handler a message names. Never throws. */
|
|
65
|
+
async handle(msg) {
|
|
66
|
+
const target = this.target(msg);
|
|
67
|
+
if (!target)
|
|
68
|
+
return failed(`no handler for message ${msg.messageId}`);
|
|
69
|
+
const [addon, key] = target;
|
|
70
|
+
try {
|
|
71
|
+
const ticket = await this.remote.getTicket(msg.ticketUuid);
|
|
72
|
+
if (!msg.worktreePath || !existsSync(msg.worktreePath)) {
|
|
73
|
+
return failed(`no worktree for ${ticket.identifier} on this machine`);
|
|
74
|
+
}
|
|
75
|
+
const result = await this.run(addon, key, ticket, msg);
|
|
76
|
+
return { status: 'done', result };
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
return failed(err instanceof Error ? err.message : String(err));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** Runs one handler with the timeout and validates its result. */
|
|
83
|
+
async run(addon, key, ticket, msg) {
|
|
84
|
+
const handler = addon.handlers[key];
|
|
85
|
+
if (!handler)
|
|
86
|
+
throw new Error(`no handler for ${key}`);
|
|
87
|
+
const worktreePath = msg.worktreePath ?? '';
|
|
88
|
+
const controller = new AbortController();
|
|
89
|
+
let timer;
|
|
90
|
+
const timeout = new Promise((_resolve, reject) => {
|
|
91
|
+
timer = setTimeout(() => {
|
|
92
|
+
reject(new Error(`timed out after ${HANDLER_TIMEOUT_MS / 1000}s`));
|
|
93
|
+
controller.abort();
|
|
94
|
+
}, HANDLER_TIMEOUT_MS);
|
|
95
|
+
});
|
|
96
|
+
try {
|
|
97
|
+
const result = await Promise.race([
|
|
98
|
+
handler({
|
|
99
|
+
id: msg.id,
|
|
100
|
+
messageType: msg.messageId,
|
|
101
|
+
addonVersion: addon.version,
|
|
102
|
+
requestedBy: msg.requestedBy ?? '',
|
|
103
|
+
ticket,
|
|
104
|
+
...(Object.keys(msg.payload).length > 0
|
|
105
|
+
? { payload: msg.payload }
|
|
106
|
+
: {}),
|
|
107
|
+
worktreePath,
|
|
108
|
+
env: await this.env(ticket),
|
|
109
|
+
logger: this.logger,
|
|
110
|
+
signal: controller.signal,
|
|
111
|
+
}),
|
|
112
|
+
timeout,
|
|
113
|
+
]);
|
|
114
|
+
const valid = validateResult(result);
|
|
115
|
+
if (!valid.ok)
|
|
116
|
+
throw new Error(`invalid result: ${valid.errors.join('; ')}`);
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
clearTimeout(timer);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/** One line per answer: status and a bounded prefix of the markdown. */
|
|
125
|
+
function logAnswer(logger, msg, outcome) {
|
|
126
|
+
const line = `inbox message ${msg.messageId} ${outcome.status}`;
|
|
127
|
+
const meta = { id: msg.id, ticket: msg.ticketUuid };
|
|
128
|
+
if (outcome.status === 'failed')
|
|
129
|
+
logger.warn(meta, line);
|
|
130
|
+
else
|
|
131
|
+
logger.info(meta, line);
|
|
132
|
+
}
|
|
133
|
+
/** Answers one inbox message through the contract-2 runtime. Catches its own errors. */
|
|
134
|
+
export async function answerV2(remote, runtime, msg, logger) {
|
|
135
|
+
try {
|
|
136
|
+
await remote.writeInboxResult(msg.id, 'running');
|
|
137
|
+
const outcome = await runtime.handle(msg);
|
|
138
|
+
try {
|
|
139
|
+
await remote.writeInboxResult(msg.id, outcome.status, outcome.result);
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
await remote.writeInboxResult(msg.id, 'failed', {
|
|
143
|
+
markdown: `Could not store the result: ${err instanceof Error ? err.message : String(err)}`,
|
|
144
|
+
});
|
|
145
|
+
throw err;
|
|
146
|
+
}
|
|
147
|
+
logAnswer(logger, msg, outcome);
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
logger.error({ message: msg.messageId, id: msg.id, err: String(err) }, 'inbox message failed');
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* The tick step: enqueue every pending message this conductor handles on the
|
|
155
|
+
* action queue; the queue answers them. Catches its own errors so the tick
|
|
156
|
+
* continues.
|
|
157
|
+
*/
|
|
158
|
+
export async function runInbox(remote, runtime, conductorId, logger, queue) {
|
|
159
|
+
if (runtime.empty)
|
|
160
|
+
return;
|
|
161
|
+
try {
|
|
162
|
+
if (!runtime.registered)
|
|
163
|
+
await runtime.register(conductorId);
|
|
164
|
+
const snapshot = await remote.fetchInbox(conductorId);
|
|
165
|
+
for (const msg of snapshot.messages) {
|
|
166
|
+
const run = () => answerV2(remote, runtime, msg, logger);
|
|
167
|
+
const queued = queue.enqueue({
|
|
168
|
+
key: `message:${msg.id}`,
|
|
169
|
+
lane: msg.ticketUuid,
|
|
170
|
+
run,
|
|
171
|
+
});
|
|
172
|
+
if (queued) {
|
|
173
|
+
logger.info({ id: msg.id, ticket: msg.ticketUuid, payload: msg.payload }, `inbox message ${msg.messageId} received`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
logger.error({ conductorId, err: String(err) }, 'inbox step failed');
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { TicketMetric } from '../plugins/remote.js';
|
|
2
|
+
/** The `mr_changes` measurement (skill definition `mr-changes.json`). */
|
|
3
|
+
export interface MrChanges {
|
|
4
|
+
score: number;
|
|
5
|
+
files_changed: number;
|
|
6
|
+
additions: number;
|
|
7
|
+
deletions: number;
|
|
8
|
+
}
|
|
9
|
+
/** Sums `git diff --numstat` output; a binary file (`-`) counts as 0 lines. */
|
|
10
|
+
export declare function parseNumstat(text: string): MrChanges;
|
|
11
|
+
/** The fixed-format `bb_metric` rows, one shared `recorded`. */
|
|
12
|
+
export declare function mrChangesRows(v: MrChanges, recorded: number): TicketMetric[];
|
|
13
|
+
/**
|
|
14
|
+
* Measures the ticket branch checked out in `worktreePath` against the merge
|
|
15
|
+
* base with `origin/<baseBranch>` — the diff the merge request's Changes tab
|
|
16
|
+
* shows. `HEAD`, not the remote branch: a merge deletes the remote branch.
|
|
17
|
+
*/
|
|
18
|
+
export declare function measureMrChanges(worktreePath: string, baseBranch: string): Promise<MrChanges>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { exec } from '@bakers-build/core';
|
|
2
|
+
/** Definition order and units of the `mr_changes` values. */
|
|
3
|
+
const VALUES = [
|
|
4
|
+
['score', 'lines'],
|
|
5
|
+
['files_changed', 'count'],
|
|
6
|
+
['additions', 'lines'],
|
|
7
|
+
['deletions', 'lines'],
|
|
8
|
+
];
|
|
9
|
+
/** Sums `git diff --numstat` output; a binary file (`-`) counts as 0 lines. */
|
|
10
|
+
export function parseNumstat(text) {
|
|
11
|
+
let additions = 0;
|
|
12
|
+
let deletions = 0;
|
|
13
|
+
let files = 0;
|
|
14
|
+
for (const line of text.split('\n')) {
|
|
15
|
+
if (!line.trim())
|
|
16
|
+
continue;
|
|
17
|
+
const [added = '', deleted = ''] = line.split('\t');
|
|
18
|
+
additions += Number(added) || 0;
|
|
19
|
+
deletions += Number(deleted) || 0;
|
|
20
|
+
files += 1;
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
score: additions + deletions,
|
|
24
|
+
files_changed: files,
|
|
25
|
+
additions,
|
|
26
|
+
deletions,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/** The fixed-format `bb_metric` rows, one shared `recorded`. */
|
|
30
|
+
export function mrChangesRows(v, recorded) {
|
|
31
|
+
return VALUES.map(([name, unit]) => ({
|
|
32
|
+
name: `mr_changes.${name}`,
|
|
33
|
+
value: v[name].toFixed(4),
|
|
34
|
+
unit,
|
|
35
|
+
recorded,
|
|
36
|
+
}));
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Measures the ticket branch checked out in `worktreePath` against the merge
|
|
40
|
+
* base with `origin/<baseBranch>` — the diff the merge request's Changes tab
|
|
41
|
+
* shows. `HEAD`, not the remote branch: a merge deletes the remote branch.
|
|
42
|
+
*/
|
|
43
|
+
export async function measureMrChanges(worktreePath, baseBranch) {
|
|
44
|
+
await exec('git', ['fetch', 'origin', baseBranch], { cwd: worktreePath });
|
|
45
|
+
const out = await exec('git', ['diff', '--numstat', '-M', `origin/${baseBranch}...HEAD`], { cwd: worktreePath });
|
|
46
|
+
return parseNumstat(out);
|
|
47
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type * from '@bakers-build/core';
|
|
2
|
+
export { conductorId } from '@bakers-build/core';
|
|
3
|
+
export { BB_CONFIG_SCHEMA_VERSION, readConfigSchemaVersion, } from './cli/config-schema.js';
|
|
4
|
+
export { type AddonRenameResult, type ConfigSurface, HOST_BARREL_PATHS, migrateAddonNames, migrateAddonNamesInFile, RENAMED_ADDONS, runAddonRenameMigration, } from './cli/migrate-addon-names.js';
|
|
5
|
+
export { type BbCliDeps, default as conductorCommandPlugin, runConductorCli, } from './commands/conductor.js';
|
|
6
|
+
export { composeConductorConfig, DEFAULT_AGENT_PROMPT, loadConductorConfig, resolveConfigPath, stripLegacyConnectionFromConfigSource, } from './config.js';
|
|
7
|
+
export * from './contract.js';
|
|
8
|
+
export { Conductor } from './core/conductor.js';
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// BB-224: the conductor registry lives ONLY in `@bakers-build/core` — the former
|
|
2
|
+
// thin re-export shim under `./cli/` is deleted and importers name core direct.
|
|
3
|
+
export { conductorId } from '@bakers-build/core';
|
|
4
|
+
// BB-216: the connection-config schema version + its regex reader. BB-230:
|
|
5
|
+
// the host's `bakers config upgrade` intro prints the `from → to` pair from these.
|
|
6
|
+
export { BB_CONFIG_SCHEMA_VERSION, readConfigSchemaVersion, } from './cli/config-schema.js';
|
|
7
|
+
// BB-224 (Finding 8b): the addon package-name rename pass `bakers config upgrade` runs
|
|
8
|
+
// as its last step (`@bakers-build/plugin-*` + the deleted `@bakers-build/core/builtins`
|
|
9
|
+
// and `@bakers-build/core/plugins` → `@bakers-build/addon-*`).
|
|
10
|
+
export { HOST_BARREL_PATHS, migrateAddonNames, migrateAddonNamesInFile, RENAMED_ADDONS, runAddonRenameMigration, } from './cli/migrate-addon-names.js';
|
|
11
|
+
// BB-201: the conductor is now a COMMAND PLUGIN mounted by the `@bakers-build/bakers`
|
|
12
|
+
// host, not the CLI entrypoint. `main`/`runBbCli` are gone; the default export
|
|
13
|
+
// is the `BbCommandPlugin`, exposed here as `./commands/conductor` too.
|
|
14
|
+
export { default as conductorCommandPlugin, runConductorCli, } from './commands/conductor.js';
|
|
15
|
+
// BB-218: the balanced strip is exported for the host/tests + reuse.
|
|
16
|
+
export { composeConductorConfig, DEFAULT_AGENT_PROMPT, loadConductorConfig, resolveConfigPath, stripLegacyConnectionFromConfigSource, } from './config.js';
|
|
17
|
+
// BB-224 (Finding 6): this package now OWNS the conductor-surface contract —
|
|
18
|
+
// the interfaces, the config types, the preset view and the slot selectors — and
|
|
19
|
+
// re-exports the whole of it here. The runtime-light subpath
|
|
20
|
+
// `@bakers-build/conductor/contract` is the one an ADDON should import (this main
|
|
21
|
+
// entry pulls the engine + the command plugin). The built-in IMPLEMENTATIONS it
|
|
22
|
+
// used to re-export from the deleted `@bakers-build/core/plugins` barrel now live in
|
|
23
|
+
// their own addons (`@bakers-build/addon-remote-drupal`,
|
|
24
|
+
// `@bakers-build/addon-workspace-git`, `@bakers-build/addon-fake`) and are deliberately NOT
|
|
25
|
+
// re-exported: the engine must not edge an addon (acyclic layer rule).
|
|
26
|
+
export * from './contract.js';
|
|
27
|
+
export { Conductor } from './core/conductor.js';
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared validation and known-scalar override detection for harness `args`.
|
|
3
|
+
* Quote only at final command assembly with `shellQuote`; this helper returns
|
|
4
|
+
* a copied list of raw validated strings so override detection never has to
|
|
5
|
+
* decode shell quoting, and later mutation of the caller's array cannot
|
|
6
|
+
* introduce unvalidated switches.
|
|
7
|
+
*/
|
|
8
|
+
export type KnownScalarKind = 'model' | 'effort';
|
|
9
|
+
export interface KnownOptionSpec {
|
|
10
|
+
kind: KnownScalarKind;
|
|
11
|
+
/** Long and short flags, e.g. `['--model', '-m']`. */
|
|
12
|
+
flags: readonly string[];
|
|
13
|
+
/** Codex `-c`/`--config` assignment keys, matched before the first `=`. */
|
|
14
|
+
configKeys?: readonly string[];
|
|
15
|
+
}
|
|
16
|
+
export interface KnownOverrides {
|
|
17
|
+
model: boolean;
|
|
18
|
+
effort: boolean;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Validate `args` (omitted → `[]`) and return a shallow copy of the raw
|
|
22
|
+
* strings. Rejects non-arrays, non-string members, NUL, tilde paths, and
|
|
23
|
+
* reserved flag forms.
|
|
24
|
+
*/
|
|
25
|
+
export declare function validateAgentArgs(args: unknown, reserved: readonly string[]): string[];
|
|
26
|
+
export declare function validateOptionalString(value: unknown, field: string): string | undefined;
|
|
27
|
+
export declare function validateOptionalBoolean(value: unknown, field: string): boolean | undefined;
|
|
28
|
+
/**
|
|
29
|
+
* Detect whether `args` already carries a known model or effort option.
|
|
30
|
+
* Caller args are never removed, reordered, or normalized; this only
|
|
31
|
+
* suppresses bakers.build-generated defaults. Unknown flags stay literal.
|
|
32
|
+
*/
|
|
33
|
+
export declare function detectKnownOverrides(args: readonly string[], specs: readonly KnownOptionSpec[]): KnownOverrides;
|
|
34
|
+
export declare function resolveAgentCliOptions(input: {
|
|
35
|
+
args?: unknown;
|
|
36
|
+
model?: unknown;
|
|
37
|
+
effort?: unknown;
|
|
38
|
+
}, reserved: readonly string[], known: readonly KnownOptionSpec[]): {
|
|
39
|
+
args: string[];
|
|
40
|
+
model: string | undefined;
|
|
41
|
+
effort: string | undefined;
|
|
42
|
+
overrides: KnownOverrides;
|
|
43
|
+
};
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared validation and known-scalar override detection for harness `args`.
|
|
3
|
+
* Quote only at final command assembly with `shellQuote`; this helper returns
|
|
4
|
+
* a copied list of raw validated strings so override detection never has to
|
|
5
|
+
* decode shell quoting, and later mutation of the caller's array cannot
|
|
6
|
+
* introduce unvalidated switches.
|
|
7
|
+
*/
|
|
8
|
+
const CONFIG_FLAGS = ['-c', '--config'];
|
|
9
|
+
function isShortFlag(flag) {
|
|
10
|
+
return flag.startsWith('-') && !flag.startsWith('--');
|
|
11
|
+
}
|
|
12
|
+
function isReservedToken(token, reserved) {
|
|
13
|
+
if (token === reserved) {
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
if (token.startsWith(`${reserved}=`)) {
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
if (isShortFlag(reserved) &&
|
|
20
|
+
token.startsWith(reserved) &&
|
|
21
|
+
token.length > reserved.length &&
|
|
22
|
+
!token.startsWith('--')) {
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
function rejectTilde(value, label) {
|
|
28
|
+
if (value.startsWith('~')) {
|
|
29
|
+
throw new Error(`${label} starts with ~; use an absolute path`);
|
|
30
|
+
}
|
|
31
|
+
const eq = value.indexOf('=');
|
|
32
|
+
if (value.startsWith('--') &&
|
|
33
|
+
eq !== -1 &&
|
|
34
|
+
value.slice(eq + 1).startsWith('~')) {
|
|
35
|
+
throw new Error(`${label} assigns a path starting with ~; use an absolute path`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function rejectReserved(value, reserved) {
|
|
39
|
+
for (const flag of reserved) {
|
|
40
|
+
if (isReservedToken(value, flag)) {
|
|
41
|
+
throw new Error(`args contains reserved flag ${flag}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function configAssignment(token) {
|
|
46
|
+
const eq = token.indexOf('=');
|
|
47
|
+
if (eq <= 0) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
return { key: token.slice(0, eq), value: token.slice(eq + 1) };
|
|
51
|
+
}
|
|
52
|
+
function matchFlag(token, flag) {
|
|
53
|
+
if (token === flag) {
|
|
54
|
+
return { flag };
|
|
55
|
+
}
|
|
56
|
+
if (token.startsWith(`${flag}=`)) {
|
|
57
|
+
return { flag, value: token.slice(flag.length + 1) };
|
|
58
|
+
}
|
|
59
|
+
if (isShortFlag(flag) &&
|
|
60
|
+
token.startsWith(flag) &&
|
|
61
|
+
token.length > flag.length &&
|
|
62
|
+
!token.startsWith('--')) {
|
|
63
|
+
const rest = token.slice(flag.length);
|
|
64
|
+
return {
|
|
65
|
+
flag,
|
|
66
|
+
value: rest.startsWith('=') ? rest.slice(1) : rest,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Validate `args` (omitted → `[]`) and return a shallow copy of the raw
|
|
73
|
+
* strings. Rejects non-arrays, non-string members, NUL, tilde paths, and
|
|
74
|
+
* reserved flag forms.
|
|
75
|
+
*/
|
|
76
|
+
export function validateAgentArgs(args, reserved) {
|
|
77
|
+
if (args === undefined) {
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
if (!Array.isArray(args)) {
|
|
81
|
+
throw new Error('args must be an array of strings');
|
|
82
|
+
}
|
|
83
|
+
const copy = [];
|
|
84
|
+
for (let i = 0; i < args.length; i++) {
|
|
85
|
+
const value = args[i];
|
|
86
|
+
if (typeof value !== 'string') {
|
|
87
|
+
throw new Error(`args[${i}] must be a string`);
|
|
88
|
+
}
|
|
89
|
+
if (value.includes('\0')) {
|
|
90
|
+
throw new Error(`args[${i}] contains a NUL byte`);
|
|
91
|
+
}
|
|
92
|
+
rejectReserved(value, reserved);
|
|
93
|
+
rejectTilde(value, `args[${i}]`);
|
|
94
|
+
copy.push(value);
|
|
95
|
+
}
|
|
96
|
+
return copy;
|
|
97
|
+
}
|
|
98
|
+
export function validateOptionalString(value, field) {
|
|
99
|
+
if (value === undefined) {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
if (typeof value !== 'string') {
|
|
103
|
+
throw new Error(`${field} must be a string`);
|
|
104
|
+
}
|
|
105
|
+
if (value.includes('\0')) {
|
|
106
|
+
throw new Error(`${field} contains a NUL byte`);
|
|
107
|
+
}
|
|
108
|
+
return value;
|
|
109
|
+
}
|
|
110
|
+
export function validateOptionalBoolean(value, field) {
|
|
111
|
+
if (value === undefined) {
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
if (typeof value !== 'boolean') {
|
|
115
|
+
throw new Error(`${field} must be a boolean`);
|
|
116
|
+
}
|
|
117
|
+
return value;
|
|
118
|
+
}
|
|
119
|
+
function kindForConfigKey(key, specs) {
|
|
120
|
+
for (const spec of specs) {
|
|
121
|
+
if (spec.configKeys?.includes(key)) {
|
|
122
|
+
return spec.kind;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
function consumeConfig(token, next, specs) {
|
|
128
|
+
let assignment;
|
|
129
|
+
let consumed = 0;
|
|
130
|
+
for (const flag of CONFIG_FLAGS) {
|
|
131
|
+
if (token === flag) {
|
|
132
|
+
if (next === undefined) {
|
|
133
|
+
throw new Error(`${flag} requires a value`);
|
|
134
|
+
}
|
|
135
|
+
assignment = next;
|
|
136
|
+
consumed = 2;
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
if (token.startsWith(`${flag}=`)) {
|
|
140
|
+
assignment = token.slice(flag.length + 1);
|
|
141
|
+
consumed = 1;
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
if (isShortFlag(flag) &&
|
|
145
|
+
token.startsWith(flag) &&
|
|
146
|
+
token.length > flag.length &&
|
|
147
|
+
!token.startsWith('--')) {
|
|
148
|
+
const rest = token.slice(flag.length);
|
|
149
|
+
assignment = rest.startsWith('=') ? rest.slice(1) : rest;
|
|
150
|
+
consumed = 1;
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (assignment === undefined) {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
const parsed = configAssignment(assignment);
|
|
158
|
+
if (!parsed) {
|
|
159
|
+
return { consumed };
|
|
160
|
+
}
|
|
161
|
+
const kind = kindForConfigKey(parsed.key, specs);
|
|
162
|
+
return kind ? { kind, consumed } : { consumed };
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Detect whether `args` already carries a known model or effort option.
|
|
166
|
+
* Caller args are never removed, reordered, or normalized; this only
|
|
167
|
+
* suppresses bakers.build-generated defaults. Unknown flags stay literal.
|
|
168
|
+
*/
|
|
169
|
+
export function detectKnownOverrides(args, specs) {
|
|
170
|
+
const overrides = { model: false, effort: false };
|
|
171
|
+
const usesConfig = specs.some((spec) => (spec.configKeys?.length ?? 0) > 0);
|
|
172
|
+
for (let i = 0; i < args.length; i++) {
|
|
173
|
+
const token = args[i];
|
|
174
|
+
if (token === undefined) {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (usesConfig) {
|
|
178
|
+
const config = consumeConfig(token, args[i + 1], specs);
|
|
179
|
+
if (config) {
|
|
180
|
+
if (config.kind === 'model') {
|
|
181
|
+
overrides.model = true;
|
|
182
|
+
}
|
|
183
|
+
if (config.kind === 'effort') {
|
|
184
|
+
overrides.effort = true;
|
|
185
|
+
}
|
|
186
|
+
i += config.consumed - 1;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
for (const spec of specs) {
|
|
191
|
+
for (const flag of spec.flags) {
|
|
192
|
+
const matched = matchFlag(token, flag);
|
|
193
|
+
if (!matched) {
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (matched.value === undefined) {
|
|
197
|
+
if (args[i + 1] === undefined) {
|
|
198
|
+
throw new Error(`${flag} requires a value`);
|
|
199
|
+
}
|
|
200
|
+
i += 1;
|
|
201
|
+
}
|
|
202
|
+
if (spec.kind === 'model') {
|
|
203
|
+
overrides.model = true;
|
|
204
|
+
}
|
|
205
|
+
if (spec.kind === 'effort') {
|
|
206
|
+
overrides.effort = true;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return overrides;
|
|
212
|
+
}
|
|
213
|
+
export function resolveAgentCliOptions(input, reserved, known) {
|
|
214
|
+
const args = validateAgentArgs(input.args, reserved);
|
|
215
|
+
return {
|
|
216
|
+
args,
|
|
217
|
+
model: validateOptionalString(input.model, 'model'),
|
|
218
|
+
effort: validateOptionalString(input.effort, 'effort'),
|
|
219
|
+
overrides: detectKnownOverrides(args, known),
|
|
220
|
+
};
|
|
221
|
+
}
|