@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,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-agent effort footprint parsed from that agent's run transcript
|
|
3
|
+
* (BB-132). The metrics are transcript-derived and therefore
|
|
4
|
+
* **agent-specific** (each agent's transcript has its own format), so parsing
|
|
5
|
+
* lives behind the agent abstraction (see {@link BbAgent.parseFootprint}) —
|
|
6
|
+
* the conductor stays agent-agnostic and writes the footprint verbatim.
|
|
7
|
+
* `duration_s` is likewise derived from the transcript (its first→last entry
|
|
8
|
+
* timestamps), NOT recomputed by the conductor from a re-read `started_at`
|
|
9
|
+
* (BB-151): the log is the single source of the run's wall-clock length, so
|
|
10
|
+
* there is no timestamp round-trip through JSON:API to mis-parse.
|
|
11
|
+
*/
|
|
12
|
+
export interface AgentFootprint {
|
|
13
|
+
/** Total tokens across every usage bucket of every assistant turn. */
|
|
14
|
+
tokens: number;
|
|
15
|
+
/**
|
|
16
|
+
* Wall-clock run length in seconds, derived from the transcript's first→last
|
|
17
|
+
* entry timestamps (BB-151). 0 when the transcript has fewer than two
|
|
18
|
+
* timestamped entries (empty/absent log).
|
|
19
|
+
*/
|
|
20
|
+
duration_s: number;
|
|
21
|
+
/** Number of assistant turns in the transcript. */
|
|
22
|
+
agent_turns: number;
|
|
23
|
+
/** Number of tool-use calls across all assistant turns. */
|
|
24
|
+
tool_calls: number;
|
|
25
|
+
/** Number of user-submitted prompts. */
|
|
26
|
+
user_prompts: number;
|
|
27
|
+
/** Total words across those user prompts. */
|
|
28
|
+
user_prompt_words: number;
|
|
29
|
+
/**
|
|
30
|
+
* Active agent time in seconds: `duration_s` minus the time spent waiting on
|
|
31
|
+
* the human, so the wait is `duration_s - active_s`. 0 means not measured,
|
|
32
|
+
* which is also what harnesses without a net-time parser report.
|
|
33
|
+
*/
|
|
34
|
+
active_s: number;
|
|
35
|
+
/** Model of the last assistant turn, when present (informational). */
|
|
36
|
+
model?: string;
|
|
37
|
+
}
|
|
38
|
+
/** An all-zero footprint — the honest result for an empty/absent transcript. */
|
|
39
|
+
export declare function emptyAgentFootprint(): AgentFootprint;
|
|
40
|
+
/**
|
|
41
|
+
* A bakers.build agent: the program the conductor runs to work a ticket (e.g. claude).
|
|
42
|
+
* It knows how it is launched (CLI + model + flags) and where its per-run
|
|
43
|
+
* transcript/log lives — so the conductor stays agent-agnostic and the CLI can
|
|
44
|
+
* attach the run log on release without agent-specific knowledge.
|
|
45
|
+
*/
|
|
46
|
+
/**
|
|
47
|
+
* Identity of the run a transcript lookup is scoped to (BB-473): the run's
|
|
48
|
+
* own UUID (usable as a session id by agents that support pinning one) and,
|
|
49
|
+
* optionally, when the run started (a lower bound for agents with no session
|
|
50
|
+
* mechanism at all, so a stale transcript from a prior run in the same
|
|
51
|
+
* worktree/cwd can never be selected). Both fields are optional so a call
|
|
52
|
+
* site or addon that predates BB-473 keeps compiling and behaving exactly
|
|
53
|
+
* as before (today's mtime/cwd-scan fallback).
|
|
54
|
+
*/
|
|
55
|
+
export interface AgentRunIdentity {
|
|
56
|
+
sessionId: string;
|
|
57
|
+
startedAt?: string | number | Date;
|
|
58
|
+
/**
|
|
59
|
+
* The run's full deterministic attempt-id family (BB-473 review round 4,
|
|
60
|
+
* Critical 2), attempt 0 (== `sessionId`) first. When present, `getRunLog`
|
|
61
|
+
* should select the transcript of the HIGHEST-numbered attempt whose file
|
|
62
|
+
* actually exists, instead of the `startedAt` time window — a window
|
|
63
|
+
* selects by *when*, which a sibling session newer than the bound can
|
|
64
|
+
* still beat; a family selects by *which attempt*, which nothing outside
|
|
65
|
+
* this run can collide with. Omitted, callers keep the pre-round-4
|
|
66
|
+
* single-`sessionId` behaviour.
|
|
67
|
+
*/
|
|
68
|
+
attemptFamily?: string[];
|
|
69
|
+
}
|
|
70
|
+
export interface BbAgent {
|
|
71
|
+
/** Stable id, e.g. 'claude'. */
|
|
72
|
+
readonly id: string;
|
|
73
|
+
/**
|
|
74
|
+
* Informational notes the conductor logs once per dispatch, before
|
|
75
|
+
* `startRun`. Unsupported effort on a harness that cannot express it is
|
|
76
|
+
* the current producer. Omitted when there is nothing to say.
|
|
77
|
+
*/
|
|
78
|
+
readonly launchWarnings?: readonly string[];
|
|
79
|
+
/**
|
|
80
|
+
* Build the full agent CLI invocation for a bakers.build prompt. With an empty
|
|
81
|
+
* prompt, returns the bare launch command (no prompt argument).
|
|
82
|
+
*
|
|
83
|
+
* `run`, when given (BB-473), lets an agent that supports pinning its own
|
|
84
|
+
* session/transcript id do so at launch (e.g. `claude --session-id
|
|
85
|
+
* <uuid>`), so `getRunLog` can read that one file directly instead of
|
|
86
|
+
* scanning a worktree-keyed directory by mtime — the mtime scan is what let
|
|
87
|
+
* a resumed/re-dispatched/reused worktree finalise a FOREIGN transcript.
|
|
88
|
+
*/
|
|
89
|
+
launchCommand(prompt: string, run?: AgentRunIdentity): string;
|
|
90
|
+
/**
|
|
91
|
+
* Locate and read this agent's run transcript for a run that executed in
|
|
92
|
+
* `worktreePath`. MUST return '' (never throw) when nothing is found.
|
|
93
|
+
*
|
|
94
|
+
* `run`, when given (BB-473), scopes the lookup to that run's own
|
|
95
|
+
* transcript — by direct filename lookup for agents that support pinning a
|
|
96
|
+
* session id at launch, or (for agents with no such mechanism) by
|
|
97
|
+
* discarding any transcript older than `run.startedAt`. Omitted, the call
|
|
98
|
+
* preserves today's directory-scan-by-mtime behaviour.
|
|
99
|
+
*/
|
|
100
|
+
getRunLog(worktreePath: string, run?: AgentRunIdentity): Promise<string>;
|
|
101
|
+
/**
|
|
102
|
+
* Whether this agent already owns a transcript/session named `sessionId`
|
|
103
|
+
* under `worktreePath` (BB-473). Some CLIs refuse to start when a
|
|
104
|
+
* `--session-id` they are handed is already in use (`claude`, `grok`), so
|
|
105
|
+
* the conductor calls this **before** pinning the run's own UUID into
|
|
106
|
+
* `launchCommand` — a live re-dispatch of the same run must still be able
|
|
107
|
+
* to launch, even though the UUID it would pin already names a transcript
|
|
108
|
+
* from the run's first attempt. Optional: an agent whose launch mechanism
|
|
109
|
+
* cannot collide (no pinning at all, or a CLI that creates-if-missing like
|
|
110
|
+
* `pi`) declares none, and the conductor always pins for it.
|
|
111
|
+
*/
|
|
112
|
+
sessionExists?(worktreePath: string, sessionId: string): Promise<boolean>;
|
|
113
|
+
/**
|
|
114
|
+
* Parse this agent's run transcript (as returned by {@link getRunLog}) into
|
|
115
|
+
* a footprint of effort metrics (BB-132). The transcript format is
|
|
116
|
+
* agent-specific, so each agent owns its own parser. MUST be tolerant of
|
|
117
|
+
* blank/partial/absent input (never throw) — an empty log yields
|
|
118
|
+
* {@link emptyAgentFootprint}.
|
|
119
|
+
*/
|
|
120
|
+
parseFootprint(log: string): AgentFootprint;
|
|
121
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** An all-zero footprint — the honest result for an empty/absent transcript. */
|
|
2
|
+
export function emptyAgentFootprint() {
|
|
3
|
+
return {
|
|
4
|
+
tokens: 0,
|
|
5
|
+
duration_s: 0,
|
|
6
|
+
agent_turns: 0,
|
|
7
|
+
tool_calls: 0,
|
|
8
|
+
user_prompts: 0,
|
|
9
|
+
user_prompt_words: 0,
|
|
10
|
+
active_s: 0,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import type { Ticket } from './remote.js';
|
|
2
|
+
export interface SpawnedSession {
|
|
3
|
+
sessionRef: string;
|
|
4
|
+
}
|
|
5
|
+
export interface ExecutorCapabilities {
|
|
6
|
+
persistent: boolean;
|
|
7
|
+
}
|
|
8
|
+
export interface SpawnRunInput {
|
|
9
|
+
ticket: {
|
|
10
|
+
uuid: string;
|
|
11
|
+
identifier: string;
|
|
12
|
+
title: string;
|
|
13
|
+
branchName: string;
|
|
14
|
+
state: string;
|
|
15
|
+
url?: string;
|
|
16
|
+
};
|
|
17
|
+
run: {
|
|
18
|
+
uuid: string;
|
|
19
|
+
id: number;
|
|
20
|
+
handler: string;
|
|
21
|
+
};
|
|
22
|
+
workspacePath: string;
|
|
23
|
+
instructions: {
|
|
24
|
+
path: string;
|
|
25
|
+
sha256: string;
|
|
26
|
+
text: string;
|
|
27
|
+
} | null;
|
|
28
|
+
command?: string;
|
|
29
|
+
env?: Record<string, string>;
|
|
30
|
+
}
|
|
31
|
+
/** The lifecycle-hook slots, keyed exactly like `config.hooks`. */
|
|
32
|
+
export type HookName = 'after_create' | 'before_run' | 'after_run' | 'after_done' | 'on_freeze' | 'on_resume';
|
|
33
|
+
/**
|
|
34
|
+
* What an addon hook listener receives (BB-599). One context object is
|
|
35
|
+
* shared by every listener of one dispatch, so a key a `before_run` listener
|
|
36
|
+
* adds to `env` reaches the next listener, that slot's shell hook and the
|
|
37
|
+
* agent launch. Reserved keys (`BB_*`, `WORKSPACE_ROOT`, `TICKET_URL`) and
|
|
38
|
+
* invalid entries are restored after each listener. `env` values may be
|
|
39
|
+
* secret: never log them.
|
|
40
|
+
*
|
|
41
|
+
* At `after_run` and `after_done` the context is rebuilt from the current
|
|
42
|
+
* ticket and the durable run record, not from a launch-time snapshot — a
|
|
43
|
+
* listener must find what it started by `run.uuid`, not by a captured value.
|
|
44
|
+
*/
|
|
45
|
+
export interface HookListenerContext {
|
|
46
|
+
/** Absolute path of the ticket's worktree (the hook's cwd). */
|
|
47
|
+
worktreePath: string;
|
|
48
|
+
ticket: Ticket;
|
|
49
|
+
/** The run's state at start; at `after_done` the ticket's current state. */
|
|
50
|
+
state: string;
|
|
51
|
+
env: Record<string, string>;
|
|
52
|
+
/** The run this slot belongs to; absent at `after_done` without a run. */
|
|
53
|
+
run?: {
|
|
54
|
+
uuid: string;
|
|
55
|
+
id?: number;
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/** A listener's callback for one slot. Awaited; a failure is logged, never fatal. */
|
|
59
|
+
export type HookListenerFn = (ctx: HookListenerContext) => void | Promise<void>;
|
|
60
|
+
/**
|
|
61
|
+
* One addon contribution to the conductor `hooks` accumulator (BB-599): an
|
|
62
|
+
* object with an optional function per lifecycle slot. Listeners run in
|
|
63
|
+
* accumulator order, each before the slot's project shell hook.
|
|
64
|
+
*/
|
|
65
|
+
export type HookListener = {
|
|
66
|
+
[K in HookName]?: HookListenerFn;
|
|
67
|
+
} & {
|
|
68
|
+
/** Names the listener in conductor logs. */
|
|
69
|
+
id?: string;
|
|
70
|
+
};
|
|
71
|
+
/** Context for a hook invocation — carries the ticket for observable logging. */
|
|
72
|
+
export interface HookContext {
|
|
73
|
+
/** The ticket identifier/uuid the hook is running for (log context). */
|
|
74
|
+
ticket: string;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* What a lifecycle hook invocation DID (BB-237). `runHook` still never throws,
|
|
78
|
+
* but it no longer swallows the outcome silently: it reports it, so a caller can
|
|
79
|
+
* record a failed provisioning hook against the run it is about to dispatch
|
|
80
|
+
* instead of relying on a log line to survive (AC-6). Reporting — rather than
|
|
81
|
+
* throwing — keeps the never-wedge property that the never-throw contract exists
|
|
82
|
+
* for; boundedness stays with the conductor's circuit breaker.
|
|
83
|
+
*/
|
|
84
|
+
export interface HookResult {
|
|
85
|
+
/** The hook slot that was invoked. */
|
|
86
|
+
hook: HookName;
|
|
87
|
+
/** false when no command is configured for the slot — nothing ran. */
|
|
88
|
+
ran: boolean;
|
|
89
|
+
/** false ONLY when a configured command actually failed. */
|
|
90
|
+
ok: boolean;
|
|
91
|
+
/** The stringified failure; present iff `ok` is false. */
|
|
92
|
+
error?: string;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Outcome of a worktree teardown (BB-293).
|
|
96
|
+
*
|
|
97
|
+
* - `removed` — the worktree was torn down on this host, or the prunable
|
|
98
|
+
* admin entry it left behind was swept.
|
|
99
|
+
* - `already_absent` — nothing to tear down: neither git nor the filesystem
|
|
100
|
+
* still has it. A FINISHED teardown, not a failure.
|
|
101
|
+
* - `unverified` — it could not be resolved or removed and may still be on
|
|
102
|
+
* disk; the reaper leaves the ticket on its work list.
|
|
103
|
+
*
|
|
104
|
+
* Replaces the former `boolean`, which could not distinguish "gone because we
|
|
105
|
+
* removed it" from "gone before we got here" — the distinction the reaper needs
|
|
106
|
+
* in order to record the absence rather than swallow it. The mapping preserves
|
|
107
|
+
* the old semantics exactly: `removed` is the old `true`, `unverified` the old
|
|
108
|
+
* `false`.
|
|
109
|
+
*/
|
|
110
|
+
export type WorktreeTeardown = 'removed' | 'already_absent' | 'unverified';
|
|
111
|
+
export interface BbExecutor {
|
|
112
|
+
id: string;
|
|
113
|
+
capabilities(): ExecutorCapabilities;
|
|
114
|
+
/**
|
|
115
|
+
* Run the lifecycle hook `name` (command from `config.hooks[name]`) in `cwd`,
|
|
116
|
+
* best-effort. This is the SINGLE catch point for all lifecycle hooks:
|
|
117
|
+
*
|
|
118
|
+
* - MUST NEVER throw. No configured command → silent no-op. A failing command
|
|
119
|
+
* → `logger.error({hook,worktree,ticket,err}, 'lifecycle hook failed')` then
|
|
120
|
+
* return. So a hook failure never aborts dispatch or wedges a run in
|
|
121
|
+
* `claimed`.
|
|
122
|
+
* - The underlying shell command still fails honestly (a non-zero exit
|
|
123
|
+
* rejects); only the executor catches + logs + continues.
|
|
124
|
+
* - MUST report the outcome as a {@link HookResult} (BB-237): never throwing
|
|
125
|
+
* is not the same as never telling. The caller decides what a failure means
|
|
126
|
+
* for the run it is dispatching; the executor only refuses to abort.
|
|
127
|
+
*
|
|
128
|
+
* `env`, when given, is the run's resolved environment (per-ticket env_vars
|
|
129
|
+
* merged with the core BB_* vars, BB-99), merged over the hook process's
|
|
130
|
+
* inherited env. Values may be sensitive — implementations must never log
|
|
131
|
+
* them (log key names only).
|
|
132
|
+
*/
|
|
133
|
+
runHook(name: HookName, cwd: string, ctx: HookContext, env?: Record<string, string>): Promise<HookResult>;
|
|
134
|
+
startRun(input: SpawnRunInput): Promise<SpawnedSession>;
|
|
135
|
+
/**
|
|
136
|
+
* Signal the run's agent to stop. Called by the conductor's run-finalise pass
|
|
137
|
+
* BEFORE it captures the agent transcript (BB-132), so a stop must not
|
|
138
|
+
* destroy the log. For herdr this is a NO-OP: at finalise time the agent is
|
|
139
|
+
* idle and its jsonl transcript is already fully written to disk, and the
|
|
140
|
+
* subsequent {@link cleanupRun} tab-close kills the PTY (the agent dies with
|
|
141
|
+
* it). The seam exists for executors whose agent outlives its UI surface.
|
|
142
|
+
* Best-effort; gated by the conductor on `capabilities().persistent`.
|
|
143
|
+
*/
|
|
144
|
+
stopRun(branch: string): Promise<void>;
|
|
145
|
+
/**
|
|
146
|
+
* Tear down ONE run's hosted UI surface — for herdr, close only the tab whose
|
|
147
|
+
* label carries the `#<runId>` token (`startRun` labels every tab
|
|
148
|
+
* `<identifier> · <state> #<run.id>`), via `herdr tab close <tab_id>` (killing
|
|
149
|
+
* that PTY). Run-scoped by design (BB-183): a sibling run's tab in the same
|
|
150
|
+
* branch workspace — a still-open prior-state run or a concurrent re-claim —
|
|
151
|
+
* and any non-run tab are left untouched. Called as the LAST finalisation step,
|
|
152
|
+
* strictly after the transcript is captured, and again at dispatch to clear a
|
|
153
|
+
* reused workspace's leftover tab for the run being (re-)started. No matching
|
|
154
|
+
* tab → a quiet no-op. Does NOT touch the branch worktree (that is the
|
|
155
|
+
* reap/{@link removeWorktree} lifecycle). Best-effort: a failing tab-close is
|
|
156
|
+
* swallowed and never aborts finalisation or dispatch. A no-op for
|
|
157
|
+
* non-persistent executors.
|
|
158
|
+
*/
|
|
159
|
+
cleanupRun(branch: string, runId: number): Promise<void>;
|
|
160
|
+
/**
|
|
161
|
+
* Tear down the branch's entire worktree (git worktree + hosted workspace),
|
|
162
|
+
* reclaiming its disk. Called by the conductor's ticket-cleanup pass once a
|
|
163
|
+
* ticket is done. A no-op for non-persistent executors (no hosted workspace);
|
|
164
|
+
* the conductor gates the call on `capabilities().persistent`.
|
|
165
|
+
*
|
|
166
|
+
* `worktreePath` is the stable, identifier-derived worktree path (from the
|
|
167
|
+
* ticket's latest run). Prefer it over `branch` to resolve the worktree: the
|
|
168
|
+
* checked-out branch is mutable (the coding agent may rename/switch it), the
|
|
169
|
+
* path is not.
|
|
170
|
+
*
|
|
171
|
+
* Reports what the teardown DID as a {@link WorktreeTeardown} (BB-293):
|
|
172
|
+
* `removed` when the worktree was present on this host and torn down (or its
|
|
173
|
+
* prunable admin entry swept), `already_absent` when there was nothing to
|
|
174
|
+
* tear down at all, and `unverified` when nothing matched here and the
|
|
175
|
+
* worktree may still be on disk (another conductor's host, or a path that
|
|
176
|
+
* could not be resolved). The reaper flags a ticket `cleaned_up` for the
|
|
177
|
+
* first two and leaves it on the work list for `unverified`, so it never
|
|
178
|
+
* false-teardowns a worktree another host physically holds. A genuine
|
|
179
|
+
* failure still throws (surfaced as a teardown miss).
|
|
180
|
+
*/
|
|
181
|
+
removeWorktree(branch: string, worktreePath?: string): Promise<WorktreeTeardown>;
|
|
182
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { ConductorLogger } from '@bakers-build/core';
|
|
2
|
+
import { type MessageDefinition, type MessageResult } from '@bakers-build/message';
|
|
3
|
+
import type { Ticket } from './remote.js';
|
|
4
|
+
/** What a handler gets: its own request identity plus the ticket's worktree. */
|
|
5
|
+
export interface InboxMessageRequest {
|
|
6
|
+
/** The bb_inbox_message entity UUID; the request's own identity, never the message type. */
|
|
7
|
+
id: string;
|
|
8
|
+
/** The namespaced message type, e.g. `ports.tunnel`. */
|
|
9
|
+
messageType: string;
|
|
10
|
+
addonVersion: string;
|
|
11
|
+
requestedBy: string;
|
|
12
|
+
ticket: Ticket;
|
|
13
|
+
payload?: Record<string, unknown>;
|
|
14
|
+
worktreePath: string;
|
|
15
|
+
env: Record<string, string>;
|
|
16
|
+
logger: ConductorLogger;
|
|
17
|
+
/** Aborts when the handler runs past its timeout. */
|
|
18
|
+
signal: AbortSignal;
|
|
19
|
+
}
|
|
20
|
+
export type InboxMessageHandler = (request: InboxMessageRequest) => Promise<MessageResult>;
|
|
21
|
+
/** An addon preset's `inbox` export. */
|
|
22
|
+
export interface InboxMessages {
|
|
23
|
+
contract: 2;
|
|
24
|
+
label?: string;
|
|
25
|
+
/** Inline SVG markup, at most 8192 characters; shown as an image only. */
|
|
26
|
+
icon?: string;
|
|
27
|
+
messages: Record<string, MessageDefinition>;
|
|
28
|
+
/** Lazy: imported only when the conductor loads the addon. */
|
|
29
|
+
handlers: (options?: unknown) => Promise<Record<string, InboxMessageHandler>>;
|
|
30
|
+
}
|
|
31
|
+
/** One `inbox` export as discovery found it. */
|
|
32
|
+
export interface DiscoveredInbox {
|
|
33
|
+
name: string;
|
|
34
|
+
version: string;
|
|
35
|
+
inbox: InboxMessages;
|
|
36
|
+
/** The addon entry's `with` options, handed to `inbox.handlers`. */
|
|
37
|
+
options?: unknown;
|
|
38
|
+
}
|
|
39
|
+
/** A validated inbox addon with its handlers resolved. */
|
|
40
|
+
export interface LoadedInbox {
|
|
41
|
+
name: string;
|
|
42
|
+
version: string;
|
|
43
|
+
label: string | undefined;
|
|
44
|
+
icon?: string;
|
|
45
|
+
messages: Record<string, MessageDefinition>;
|
|
46
|
+
handlers: Record<string, InboxMessageHandler>;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Validates every declared message definition, checks that handlers and
|
|
50
|
+
* declared message types match one to one, and rejects a contract other than
|
|
51
|
+
* 2. `handlers` gets `options` (the addon entry's `with`). Throws an Error naming
|
|
52
|
+
* the addon on any violation.
|
|
53
|
+
*/
|
|
54
|
+
export declare function loadInbox(found: DiscoveredInbox, options?: unknown): Promise<LoadedInbox>;
|
|
55
|
+
/** Validates a request's payload against its message's input schema. */
|
|
56
|
+
export declare function validateRequestPayload(messages: Record<string, MessageDefinition>, messageType: string, payload: unknown): {
|
|
57
|
+
ok: true;
|
|
58
|
+
} | {
|
|
59
|
+
ok: false;
|
|
60
|
+
errors: string[];
|
|
61
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// The InboxMessageHandler contract on the conductor surface: an addon's
|
|
2
|
+
// preset may export `inbox` — a map of message types plus LAZY handlers, so
|
|
3
|
+
// a command-only load never imports them. Contract 2 replaces the retired
|
|
4
|
+
// `actions`/views/rows contract (BB-661): this is the only live inbox
|
|
5
|
+
// contract.
|
|
6
|
+
import { MESSAGE_CONTRACT_VERSION, validateArgs, validateMessageDefinition, } from '@bakers-build/message';
|
|
7
|
+
/**
|
|
8
|
+
* Validates every declared message definition, checks that handlers and
|
|
9
|
+
* declared message types match one to one, and rejects a contract other than
|
|
10
|
+
* 2. `handlers` gets `options` (the addon entry's `with`). Throws an Error naming
|
|
11
|
+
* the addon on any violation.
|
|
12
|
+
*/
|
|
13
|
+
export async function loadInbox(found, options) {
|
|
14
|
+
const fail = (msg) => new Error(`addon ${found.name}: ${msg}`);
|
|
15
|
+
if (found.inbox.contract !== MESSAGE_CONTRACT_VERSION)
|
|
16
|
+
throw fail(`unsupported inbox contract ${found.inbox.contract}`);
|
|
17
|
+
for (const [key, def] of Object.entries(found.inbox.messages)) {
|
|
18
|
+
const valid = validateMessageDefinition(def);
|
|
19
|
+
if (!valid.ok)
|
|
20
|
+
throw fail(`messages.${key}: ${valid.errors.join('; ')}`);
|
|
21
|
+
}
|
|
22
|
+
const handlers = await found.inbox.handlers(options);
|
|
23
|
+
const declared = Object.keys(found.inbox.messages);
|
|
24
|
+
for (const key of declared) {
|
|
25
|
+
if (typeof handlers[key] !== 'function')
|
|
26
|
+
throw fail(`no handler for message ${key}`);
|
|
27
|
+
}
|
|
28
|
+
for (const key of Object.keys(handlers)) {
|
|
29
|
+
if (!declared.includes(key))
|
|
30
|
+
throw fail(`handler ${key} is not declared`);
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
name: found.name,
|
|
34
|
+
version: found.version,
|
|
35
|
+
label: found.inbox.label,
|
|
36
|
+
...(found.inbox.icon !== undefined ? { icon: found.inbox.icon } : {}),
|
|
37
|
+
messages: found.inbox.messages,
|
|
38
|
+
handlers,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/** Validates a request's payload against its message's input schema. */
|
|
42
|
+
export function validateRequestPayload(messages, messageType, payload) {
|
|
43
|
+
const def = messages[messageType];
|
|
44
|
+
if (!def)
|
|
45
|
+
return { ok: false, errors: [`unknown message type ${messageType}`] };
|
|
46
|
+
return validateArgs(def.input, payload);
|
|
47
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { AgentRoutes, ConductorLogger } from '@bakers-build/core';
|
|
2
|
+
import type { DropSHPlugin } from 'dropsh/plugin';
|
|
3
|
+
import type { ConductorFileConfig } from '../types.js';
|
|
4
|
+
import type { BbAgent } from './agent.js';
|
|
5
|
+
import type { BbExecutor } from './executor.js';
|
|
6
|
+
import type { BbRemote, Ticket } from './remote.js';
|
|
7
|
+
import type { BbWorkspace } from './workspace.js';
|
|
8
|
+
/** Runtime dependencies handed to a plugin factory at selection time. */
|
|
9
|
+
export interface ExecutorDeps {
|
|
10
|
+
/** The in-scope conductor logger — the executor logs hook failures through it. */
|
|
11
|
+
logger: ConductorLogger;
|
|
12
|
+
}
|
|
13
|
+
export interface RemotePlugin extends DropSHPlugin {
|
|
14
|
+
readonly kind: 'remote';
|
|
15
|
+
readonly id: string;
|
|
16
|
+
readonly requiredModules: string[];
|
|
17
|
+
createRemote(config: ConductorFileConfig): Promise<BbRemote>;
|
|
18
|
+
}
|
|
19
|
+
export interface ExecutorPlugin extends DropSHPlugin {
|
|
20
|
+
readonly kind: 'executor';
|
|
21
|
+
readonly id: string;
|
|
22
|
+
readonly requiredModules: string[];
|
|
23
|
+
createExecutor(config: ConductorFileConfig, deps: ExecutorDeps): Promise<BbExecutor>;
|
|
24
|
+
}
|
|
25
|
+
export interface WorkspacePlugin extends DropSHPlugin {
|
|
26
|
+
readonly kind: 'workspace';
|
|
27
|
+
readonly id: string;
|
|
28
|
+
readonly requiredModules: string[];
|
|
29
|
+
/**
|
|
30
|
+
* BB-234: `deps` is **optional** so every existing workspace plugin
|
|
31
|
+
* (`addon-workspace-git`, `addon-fake`) keeps compiling untouched — a
|
|
32
|
+
* workspace that logs nothing simply ignores it. It reuses {@link ExecutorDeps}
|
|
33
|
+
* rather than minting a `WorkspaceDeps` alias: that interface is already the
|
|
34
|
+
* contract's name for "runtime deps handed to a plugin factory", and a second
|
|
35
|
+
* identical shape would be two names for one thing.
|
|
36
|
+
*/
|
|
37
|
+
createWorkspace(config: ConductorFileConfig, deps?: ExecutorDeps): Promise<BbWorkspace>;
|
|
38
|
+
}
|
|
39
|
+
export interface AgentPlugin extends DropSHPlugin {
|
|
40
|
+
readonly kind: 'agent';
|
|
41
|
+
readonly id: string;
|
|
42
|
+
readonly requiredModules: string[];
|
|
43
|
+
createAgent(config: ConductorFileConfig): Promise<BbAgent>;
|
|
44
|
+
}
|
|
45
|
+
/** Resolves the remote from its named config slot. */
|
|
46
|
+
export declare function selectRemote(config: ConductorFileConfig): Promise<BbRemote>;
|
|
47
|
+
/** Resolves the executor from its named config slot, injecting the logger. */
|
|
48
|
+
export declare function selectExecutor(config: ConductorFileConfig, logger: ConductorLogger): Promise<BbExecutor>;
|
|
49
|
+
/**
|
|
50
|
+
* Resolves the workspace from its named config slot, injecting the logger when
|
|
51
|
+
* the caller has one — mirroring {@link selectExecutor}. Without it the herdr
|
|
52
|
+
* workspace's best-effort open-layout warn would land on a noop logger
|
|
53
|
+
* (BB-234).
|
|
54
|
+
*/
|
|
55
|
+
export declare function selectWorkspace(config: ConductorFileConfig, logger?: ConductorLogger): Promise<BbWorkspace>;
|
|
56
|
+
/** A config-side agent choice: an agent plugin + an optional static priority over the ticket. */
|
|
57
|
+
export interface AgentCandidate {
|
|
58
|
+
alias?: string;
|
|
59
|
+
description?: string;
|
|
60
|
+
model?: string;
|
|
61
|
+
modelVersion?: string;
|
|
62
|
+
agent: AgentPlugin;
|
|
63
|
+
priority?: (ticket: Ticket) => number;
|
|
64
|
+
}
|
|
65
|
+
/** A constructed candidate: the agent id, its BbAgent, and the config-side priority. */
|
|
66
|
+
export interface ResolvedAgent {
|
|
67
|
+
alias?: string;
|
|
68
|
+
description?: string;
|
|
69
|
+
/** The candidate's configured model, when it pins one. Preferred over the
|
|
70
|
+
* transcript-derived model at run close, because an explicit human answer
|
|
71
|
+
* beats a guess; absent when the harness's own local configuration chooses
|
|
72
|
+
* the model, in which case the transcript's raw model id is used instead. */
|
|
73
|
+
model?: string;
|
|
74
|
+
/** The candidate's configured exact model version, when it pins one.
|
|
75
|
+
* Preferred over the transcript-derived version at run close for the same
|
|
76
|
+
* reason as `model`; absent when the transcript's raw model id should be
|
|
77
|
+
* used instead. */
|
|
78
|
+
modelVersion?: string;
|
|
79
|
+
id: string;
|
|
80
|
+
agent: BbAgent;
|
|
81
|
+
priority?: (ticket: Ticket) => number;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Pick the agent for a ticket: highest `priority(ticket)` wins; a candidate with
|
|
85
|
+
* no `priority` scores lowest; ties resolve by config array order (stable sort).
|
|
86
|
+
*/
|
|
87
|
+
export declare function selectAgent(candidates: ResolvedAgent[], ticket: Ticket): ResolvedAgent;
|
|
88
|
+
/** Constructs every agent candidate from the normalized effective catalog. */
|
|
89
|
+
export declare function selectAgents(config: ConductorFileConfig): Promise<ResolvedAgent[]>;
|
|
90
|
+
/** Resolve logical aliases without changing the legacy priority selector. */
|
|
91
|
+
export declare function resolveAgent(candidates: ResolvedAgent[], ticket: Ticket, options?: {
|
|
92
|
+
warn?: (message: string) => void;
|
|
93
|
+
selectAgent?: ConductorFileConfig['selectAgent'];
|
|
94
|
+
routes?: AgentRoutes;
|
|
95
|
+
defaultAgent?: string;
|
|
96
|
+
}): ResolvedAgent;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/** Resolves the remote from its named config slot. */
|
|
2
|
+
export async function selectRemote(config) {
|
|
3
|
+
return config.remote.createRemote(config);
|
|
4
|
+
}
|
|
5
|
+
/** Resolves the executor from its named config slot, injecting the logger. */
|
|
6
|
+
export async function selectExecutor(config, logger) {
|
|
7
|
+
return config.executor.createExecutor(config, { logger });
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Resolves the workspace from its named config slot, injecting the logger when
|
|
11
|
+
* the caller has one — mirroring {@link selectExecutor}. Without it the herdr
|
|
12
|
+
* workspace's best-effort open-layout warn would land on a noop logger
|
|
13
|
+
* (BB-234).
|
|
14
|
+
*/
|
|
15
|
+
export async function selectWorkspace(config, logger) {
|
|
16
|
+
return config.workspace.createWorkspace(config, logger ? { logger } : undefined);
|
|
17
|
+
}
|
|
18
|
+
/** A missing `priority` scores strictly below any real number (AC-4). */
|
|
19
|
+
const DEFAULT_PRIORITY = Number.NEGATIVE_INFINITY;
|
|
20
|
+
/**
|
|
21
|
+
* Pick the agent for a ticket: highest `priority(ticket)` wins; a candidate with
|
|
22
|
+
* no `priority` scores lowest; ties resolve by config array order (stable sort).
|
|
23
|
+
*/
|
|
24
|
+
export function selectAgent(candidates, ticket) {
|
|
25
|
+
const best = candidates
|
|
26
|
+
.map((c, index) => ({
|
|
27
|
+
c,
|
|
28
|
+
index,
|
|
29
|
+
score: c.priority ? c.priority(ticket) : DEFAULT_PRIORITY,
|
|
30
|
+
}))
|
|
31
|
+
.sort((a, b) => b.score - a.score || a.index - b.index)[0];
|
|
32
|
+
if (!best) {
|
|
33
|
+
throw new Error('selectAgent: no agent candidates configured');
|
|
34
|
+
}
|
|
35
|
+
return best.c;
|
|
36
|
+
}
|
|
37
|
+
/** Constructs every agent candidate from the normalized effective catalog. */
|
|
38
|
+
export async function selectAgents(config) {
|
|
39
|
+
const candidates = config.agents;
|
|
40
|
+
return Promise.all(candidates.map(async (c) => ({
|
|
41
|
+
id: c.agent.id,
|
|
42
|
+
...(c.alias !== undefined ? { alias: c.alias } : {}),
|
|
43
|
+
...(c.description !== undefined ? { description: c.description } : {}),
|
|
44
|
+
...(c.model !== undefined ? { model: c.model } : {}),
|
|
45
|
+
...(c.modelVersion !== undefined ? { modelVersion: c.modelVersion } : {}),
|
|
46
|
+
agent: await c.agent.createAgent(config),
|
|
47
|
+
...(c.priority ? { priority: c.priority } : {}),
|
|
48
|
+
})));
|
|
49
|
+
}
|
|
50
|
+
/** Resolve logical aliases without changing the legacy priority selector. */
|
|
51
|
+
export function resolveAgent(candidates, ticket, options = {}) {
|
|
52
|
+
if (options.selectAgent)
|
|
53
|
+
return options.selectAgent(candidates, ticket);
|
|
54
|
+
const labels = ticket.labels.filter((label) => label.startsWith('agent:'));
|
|
55
|
+
const scoped = [];
|
|
56
|
+
const general = [];
|
|
57
|
+
for (const label of labels) {
|
|
58
|
+
const [alias, state, extra] = label.slice('agent:'.length).split('@');
|
|
59
|
+
if (!alias || extra !== undefined)
|
|
60
|
+
continue;
|
|
61
|
+
if (state === ticket.state)
|
|
62
|
+
scoped.push(alias);
|
|
63
|
+
else if (state === undefined)
|
|
64
|
+
general.push(alias);
|
|
65
|
+
}
|
|
66
|
+
const warn = options.warn ?? console.warn;
|
|
67
|
+
const routes = options.routes ?? {};
|
|
68
|
+
const direct = (aliases) => {
|
|
69
|
+
for (const alias of aliases) {
|
|
70
|
+
const candidate = candidates.find((c) => c.alias === alias);
|
|
71
|
+
if (candidate)
|
|
72
|
+
return candidate;
|
|
73
|
+
if (routes[alias] !== undefined)
|
|
74
|
+
continue;
|
|
75
|
+
warn(`${ticket.identifier}: agent alias '${alias}' is unavailable; trying the next fallback`);
|
|
76
|
+
}
|
|
77
|
+
return undefined;
|
|
78
|
+
};
|
|
79
|
+
const directCandidate = direct(scoped) ?? direct(general);
|
|
80
|
+
if (directCandidate)
|
|
81
|
+
return directCandidate;
|
|
82
|
+
const resolveRoutes = (names) => {
|
|
83
|
+
for (const name of names) {
|
|
84
|
+
if (name === 'default')
|
|
85
|
+
continue;
|
|
86
|
+
const route = routes[name];
|
|
87
|
+
if (route === undefined)
|
|
88
|
+
continue;
|
|
89
|
+
const alias = route[ticket.state] ?? route['*'];
|
|
90
|
+
if (alias === undefined) {
|
|
91
|
+
warn(`${ticket.identifier}: agent route '${name}' has no entry for '${ticket.state}' or '*'; trying the next fallback`);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const candidate = candidates.find((c) => c.alias === alias);
|
|
95
|
+
if (candidate)
|
|
96
|
+
return candidate;
|
|
97
|
+
warn(`${ticket.identifier}: agent route '${name}' resolves to unavailable alias '${alias}'; trying the next fallback`);
|
|
98
|
+
}
|
|
99
|
+
return undefined;
|
|
100
|
+
};
|
|
101
|
+
const routeCandidate = resolveRoutes(scoped) ?? resolveRoutes(general);
|
|
102
|
+
if (routeCandidate)
|
|
103
|
+
return routeCandidate;
|
|
104
|
+
const defaultRoute = routes.default;
|
|
105
|
+
for (const alias of [defaultRoute?.[ticket.state], defaultRoute?.['*']]) {
|
|
106
|
+
if (alias === undefined)
|
|
107
|
+
continue;
|
|
108
|
+
const candidate = candidates.find((c) => c.alias === alias);
|
|
109
|
+
if (candidate)
|
|
110
|
+
return candidate;
|
|
111
|
+
warn(`${ticket.identifier}: agent route 'default' resolves to unavailable alias '${alias}'; trying the next fallback`);
|
|
112
|
+
}
|
|
113
|
+
if (options.defaultAgent !== undefined) {
|
|
114
|
+
const candidate = candidates.find((c) => c.alias === options.defaultAgent);
|
|
115
|
+
if (candidate)
|
|
116
|
+
return candidate;
|
|
117
|
+
warn(`${ticket.identifier}: agent alias '${options.defaultAgent}' is unavailable; trying the next fallback`);
|
|
118
|
+
}
|
|
119
|
+
return selectAgent(candidates, ticket);
|
|
120
|
+
}
|