@bakers-build/addon-orca 0.0.0-stage → 0.1.2-next.194

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 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 CHANGED
@@ -1,3 +1,5 @@
1
- # Temporary Holding Version
1
+ # @bakers-build/addon-orca
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ bakers.build Orca integration: executor + per-ticket worktree workspace hosted in Orca.
4
+
5
+ Part of the bakers.build CLI. Install the meta package `@bakers-build/bakers` to get the `bakers` CLI with all addons. Source: https://git.key-tec.de/bakers-build/bakers-build (bakers-cli/).
@@ -0,0 +1,39 @@
1
+ import type { RootGitExec } from '@bakers-build/core';
2
+ export interface OrcaStateConfig {
3
+ /**
4
+ * Terminal title template; must contain `{identifier}`. Other vars:
5
+ * `{title}`, `{titleSlug}`, `{runUuid}`, `{workspacePath}`. `startRun`
6
+ * appends ` #<run.id>`, the token `cleanupRun` finds the run's terminal by.
7
+ */
8
+ tabLabel?: string;
9
+ }
10
+ export interface OrcaExecutorOptions {
11
+ /** Fallback agent command when the conductor supplies none. */
12
+ command?: string;
13
+ /** Lifecycle-hook commands, copied from `config.hooks` by the factory. */
14
+ hooks?: {
15
+ after_create?: string;
16
+ before_run?: string;
17
+ after_run?: string;
18
+ after_done?: string;
19
+ on_freeze?: string;
20
+ on_resume?: string;
21
+ };
22
+ /** Main clone; otherwise derived from the git common dir by the factory. */
23
+ root?: string;
24
+ /** Overrideable git exec for root derivation (tests). */
25
+ execGit?: RootGitExec;
26
+ default?: OrcaStateConfig;
27
+ states?: Record<string, OrcaStateConfig>;
28
+ /** Same windows as herdr: wait for `started` (60 s), then an exit window (4 s). */
29
+ launchWatch?: {
30
+ timeoutMs?: number;
31
+ startTimeoutMs?: number;
32
+ pollMs?: number;
33
+ sleep?: (ms: number) => Promise<void>;
34
+ };
35
+ /** How long `startRun` waits for Orca to adopt the worktree. Default 35 s. */
36
+ adoptTimeoutMs?: number;
37
+ }
38
+ export declare const DEFAULT_TAB_LABEL = "{identifier} \u00B7 {titleSlug}";
39
+ export declare function resolveTabLabel(options: OrcaExecutorOptions, state: string): string;
@@ -0,0 +1,10 @@
1
+ export const DEFAULT_TAB_LABEL = '{identifier} · {titleSlug}';
2
+ export function resolveTabLabel(options, state) {
3
+ const tabLabel = options.states?.[state]?.tabLabel ??
4
+ options.default?.tabLabel ??
5
+ DEFAULT_TAB_LABEL;
6
+ if (!tabLabel.includes('{identifier}')) {
7
+ throw new Error(`orca tabLabel must contain {identifier}: ${tabLabel}`);
8
+ }
9
+ return tabLabel;
10
+ }
@@ -0,0 +1,44 @@
1
+ import type { BbExecutor, ExecutorCapabilities, ExecutorPlugin, HookContext, HookName, HookResult, SpawnedSession, SpawnRunInput, WorktreeTeardown } from '@bakers-build/conductor/contract';
2
+ import type { ConductorLogger } from '@bakers-build/core';
3
+ import { type OrcaExecutorOptions } from './config.js';
4
+ import { type OrcaExec } from './orca-cli.js';
5
+ import { type GitExec, type Sleep } from './workspace.js';
6
+ export type { OrcaExecutorOptions } from './config.js';
7
+ export { OrcaError, type OrcaExec } from './orca-cli.js';
8
+ export { type OrcaOpenLayout, OrcaWorkspace, type OrcaWorkspaceOptions, orcaWorkspace, } from './workspace.js';
9
+ /** Runs a lifecycle-hook shell command in a cwd; rejects on non-zero exit. */
10
+ export type ShellRunner = (command: string, cwd: string, env?: Record<string, string>) => Promise<void>;
11
+ /**
12
+ * Executor hosting each run's agent in an Orca terminal of the ticket's
13
+ * worktree. Terminal handles do not survive an Orca restart, so none is kept
14
+ * beyond the call that obtained it: a run's terminal is found again by branch
15
+ * and the `#<runId>` token in its title.
16
+ */
17
+ export declare class OrcaExecutor implements BbExecutor {
18
+ private readonly options;
19
+ private readonly execOrca;
20
+ private readonly logger;
21
+ private readonly runShell;
22
+ private readonly execGit;
23
+ private readonly sleep;
24
+ readonly id = "orca";
25
+ constructor(options: OrcaExecutorOptions, execOrca?: OrcaExec, logger?: ConductorLogger, runShell?: ShellRunner, execGit?: GitExec, sleep?: Sleep);
26
+ capabilities(): ExecutorCapabilities;
27
+ /** The single best-effort catch point for lifecycle hooks; never throws. */
28
+ runHook(name: HookName, cwd: string, ctx: HookContext, env?: Record<string, string>): Promise<HookResult>;
29
+ startRun(input: SpawnRunInput): Promise<SpawnedSession>;
30
+ private readTail;
31
+ /** Best-effort: a live or unverifiable stop must not abort the caller. */
32
+ private closeTerminal;
33
+ /**
34
+ * No-op, as for herdr: the transcript is captured after this call, and
35
+ * {@link cleanupRun} closing the terminal is what ends the agent.
36
+ */
37
+ stopRun(_branch: string): Promise<void>;
38
+ /** Close every terminal of this run on this branch; best-effort throughout. */
39
+ cleanupRun(branch: string, runId: number): Promise<void>;
40
+ removeWorktree(branch: string, worktreePath?: string): Promise<WorktreeTeardown>;
41
+ private commandFor;
42
+ }
43
+ export declare function orcaExecutor(options?: OrcaExecutorOptions): ExecutorPlugin;
44
+ export default orcaExecutor;
@@ -0,0 +1,328 @@
1
+ import { existsSync, rmSync } from 'node:fs';
2
+ import { renderTemplate, restoreWritable, watchLaunch, writeLaunchScript, } from '@bakers-build/addon-herdr/shared';
3
+ import { deriveCheckoutRoot, exec, shellQuote, slugify, } from '@bakers-build/core';
4
+ import { resolveTabLabel } from './config.js';
5
+ import { assertOrcaReachable, createOrcaExec, isRecord, } from './orca-cli.js';
6
+ import { DEFAULT_ADOPT_TIMEOUT_MS, defaultSleep, noopLogger, porcelainEntries, waitForAdoption, } from './workspace.js';
7
+ export { OrcaError } from './orca-cli.js';
8
+ export { OrcaWorkspace, orcaWorkspace, } from './workspace.js';
9
+ const defaultShellRunner = async (command, cwd, env) => {
10
+ await exec('sh', ['-c', command], { cwd, ...(env ? { env } : {}) });
11
+ };
12
+ /** Exact trailing `#<runId>` token, so `#7` never matches `#77`. */
13
+ function titleMatchesRun(title, runId) {
14
+ if (typeof title !== 'string')
15
+ return false;
16
+ const m = title.match(/#(\d+)$/);
17
+ return m !== null && Number(m[1]) === runId;
18
+ }
19
+ /** Tab id → tab title from `terminal list --include-visual-layouts`. */
20
+ function visualTabTitles(layouts) {
21
+ const titles = new Map();
22
+ const walk = (node) => {
23
+ if (!isRecord(node))
24
+ return;
25
+ if (Array.isArray(node.tabs)) {
26
+ for (const tab of node.tabs) {
27
+ if (isRecord(tab) && typeof tab.tabId === 'string') {
28
+ titles.set(tab.tabId, tab.title);
29
+ }
30
+ }
31
+ }
32
+ walk(node.first);
33
+ walk(node.second);
34
+ };
35
+ if (Array.isArray(layouts)) {
36
+ for (const layout of layouts) {
37
+ if (isRecord(layout))
38
+ walk(layout.root);
39
+ }
40
+ }
41
+ return titles;
42
+ }
43
+ function normPath(path) {
44
+ return path.replace(/\/+$/, '');
45
+ }
46
+ /**
47
+ * Executor hosting each run's agent in an Orca terminal of the ticket's
48
+ * worktree. Terminal handles do not survive an Orca restart, so none is kept
49
+ * beyond the call that obtained it: a run's terminal is found again by branch
50
+ * and the `#<runId>` token in its title.
51
+ */
52
+ export class OrcaExecutor {
53
+ options;
54
+ execOrca;
55
+ logger;
56
+ runShell;
57
+ execGit;
58
+ sleep;
59
+ id = 'orca';
60
+ constructor(options, execOrca = createOrcaExec(), logger = noopLogger, runShell = defaultShellRunner, execGit = (args) => exec('git', args), sleep = defaultSleep) {
61
+ this.options = options;
62
+ this.execOrca = execOrca;
63
+ this.logger = logger;
64
+ this.runShell = runShell;
65
+ this.execGit = execGit;
66
+ this.sleep = sleep;
67
+ }
68
+ capabilities() {
69
+ return { persistent: true };
70
+ }
71
+ /** The single best-effort catch point for lifecycle hooks; never throws. */
72
+ async runHook(name, cwd, ctx, env) {
73
+ const command = this.options.hooks?.[name];
74
+ if (!command) {
75
+ return { hook: name, ran: false, ok: true };
76
+ }
77
+ try {
78
+ await this.runShell(command, cwd, env);
79
+ return { hook: name, ran: true, ok: true };
80
+ }
81
+ catch (err) {
82
+ this.logger.error({ hook: name, worktree: cwd, ticket: ctx.ticket, err: String(err) }, 'lifecycle hook failed');
83
+ return { hook: name, ran: true, ok: false, error: String(err) };
84
+ }
85
+ }
86
+ async startRun(input) {
87
+ const tabLabel = resolveTabLabel(this.options, input.ticket.state);
88
+ await assertOrcaReachable(this.execOrca);
89
+ await waitForAdoption(this.execOrca, input.workspacePath, this.options.adoptTimeoutMs ?? DEFAULT_ADOPT_TIMEOUT_MS, this.sleep);
90
+ const title = `${renderTemplate(tabLabel, {
91
+ identifier: input.ticket.identifier,
92
+ title: input.ticket.title,
93
+ titleSlug: slugify(input.ticket.title),
94
+ runUuid: input.run.uuid,
95
+ workspacePath: input.workspacePath,
96
+ })} #${input.run.id}`;
97
+ let launch;
98
+ let handle;
99
+ try {
100
+ // Orca's `terminal create` takes no env: the run's env travels inside the
101
+ // 0600 launch script, never in argv; the terminal is typed `sh '<file>'`.
102
+ launch = writeLaunchScript(this.commandFor(input));
103
+ const created = await this.execOrca([
104
+ 'terminal',
105
+ 'create',
106
+ '--worktree',
107
+ `path:${input.workspacePath}`,
108
+ '--title',
109
+ title,
110
+ '--command',
111
+ launch.typedCommand,
112
+ ]);
113
+ const terminal = isRecord(created) ? created.terminal : undefined;
114
+ handle =
115
+ isRecord(terminal) && typeof terminal.handle === 'string'
116
+ ? terminal.handle
117
+ : undefined;
118
+ if (!handle) {
119
+ throw new Error('orca terminal create returned no terminal handle');
120
+ }
121
+ // A create `--title` is only a launch title the agent's OSC title replaces;
122
+ // a rename is a manual name restarts keep (on the tab in desktop Orca, on
123
+ // the terminal headless), so cleanupRun can still find the `#<runId>` token.
124
+ await this.execOrca([
125
+ 'terminal',
126
+ 'rename',
127
+ '--terminal',
128
+ handle,
129
+ '--title',
130
+ title,
131
+ ]);
132
+ const readHandle = handle;
133
+ await watchLaunch(launch, {
134
+ ...(this.options.launchWatch ?? {}),
135
+ readPane: () => this.readTail(readHandle),
136
+ });
137
+ return { sessionRef: input.ticket.branchName };
138
+ }
139
+ catch (err) {
140
+ if (launch) {
141
+ rmSync(launch.dir, { recursive: true, force: true });
142
+ }
143
+ if (handle) {
144
+ await this.closeTerminal(handle);
145
+ }
146
+ throw err;
147
+ }
148
+ }
149
+ async readTail(handle) {
150
+ const read = await this.execOrca([
151
+ 'terminal',
152
+ 'read',
153
+ '--terminal',
154
+ handle,
155
+ ]);
156
+ const tail = isRecord(read) && isRecord(read.terminal)
157
+ ? read.terminal.tail
158
+ : undefined;
159
+ return Array.isArray(tail) ? tail.join('\n') : '';
160
+ }
161
+ /** Best-effort: a live or unverifiable stop must not abort the caller. */
162
+ async closeTerminal(handle) {
163
+ try {
164
+ await this.execOrca(['terminal', 'close', '--terminal', handle]);
165
+ }
166
+ catch (err) {
167
+ this.logger.warn({ terminal: handle, err: String(err) }, 'orca terminal close failed (best-effort, ignored)');
168
+ }
169
+ }
170
+ /**
171
+ * No-op, as for herdr: the transcript is captured after this call, and
172
+ * {@link cleanupRun} closing the terminal is what ends the agent.
173
+ */
174
+ async stopRun(_branch) { }
175
+ /** Close every terminal of this run on this branch; best-effort throughout. */
176
+ async cleanupRun(branch, runId) {
177
+ let listed;
178
+ try {
179
+ // Orca returns 200 rows unless told otherwise; a cut-off run terminal would leak.
180
+ listed = await this.execOrca([
181
+ 'terminal',
182
+ 'list',
183
+ '--include-visual-layouts',
184
+ '--limit',
185
+ '10000',
186
+ ]);
187
+ }
188
+ catch (err) {
189
+ this.logger.warn({ branch, runId, err: String(err) }, 'orca terminal list failed (best-effort, ignored)');
190
+ return;
191
+ }
192
+ const terminals = isRecord(listed) && Array.isArray(listed.terminals)
193
+ ? listed.terminals
194
+ : [];
195
+ // Desktop Orca keeps the rename on the tab while the terminal reports the
196
+ // agent's own title; headless Orca keeps it on the terminal.
197
+ const tabTitles = visualTabTitles(isRecord(listed) ? listed.visualLayouts : undefined);
198
+ let matched = false;
199
+ for (const t of terminals) {
200
+ if (isRecord(t) &&
201
+ typeof t.handle === 'string' &&
202
+ typeof t.branch === 'string' &&
203
+ t.branch.replace(/^refs\/heads\//, '') === branch &&
204
+ (titleMatchesRun(t.title, runId) ||
205
+ (typeof t.tabId === 'string' &&
206
+ titleMatchesRun(tabTitles.get(t.tabId), runId)))) {
207
+ matched = true;
208
+ await this.closeTerminal(t.handle);
209
+ }
210
+ }
211
+ // A missed match would otherwise leak a running agent without a trace.
212
+ if (!matched) {
213
+ this.logger.warn({ branch, runId }, 'no orca terminal found for this run; its agent may still be running');
214
+ }
215
+ }
216
+ async removeWorktree(branch, worktreePath) {
217
+ const root = this.options.root;
218
+ if (root && worktreePath && normPath(worktreePath) === normPath(root)) {
219
+ this.logger.warn({ branch, worktreePath, root }, 'refusing to tear down the main checkout');
220
+ return 'unverified';
221
+ }
222
+ const gitDir = root ?? worktreePath;
223
+ let entries = [];
224
+ if (gitDir) {
225
+ try {
226
+ // Record 0 is the main checkout — never a ticket worktree.
227
+ entries = porcelainEntries(await this.execGit(['-C', gitDir, 'worktree', 'list', '--porcelain'])).slice(1);
228
+ }
229
+ catch {
230
+ // Degrade to the on-disk answer below.
231
+ }
232
+ }
233
+ const path = worktreePath
234
+ ? normPath(worktreePath)
235
+ : entries.find((e) => e.branch === branch)?.path;
236
+ if (!path) {
237
+ return 'unverified';
238
+ }
239
+ const registered = entries.some((e) => normPath(e.path) === path);
240
+ let orcaKnows = false;
241
+ try {
242
+ await assertOrcaReachable(this.execOrca);
243
+ await this.execOrca(['worktree', 'show', '--worktree', `path:${path}`]);
244
+ orcaKnows = true;
245
+ }
246
+ catch {
247
+ // Orca is down or never adopted it: tear down with git alone.
248
+ }
249
+ // Drupal hardens web/sites/default (0555): restore owner-write first.
250
+ restoreWritable(path);
251
+ if (orcaKnows) {
252
+ await this.execOrca([
253
+ 'worktree',
254
+ 'rm',
255
+ '--worktree',
256
+ `path:${path}`,
257
+ '--force',
258
+ ]);
259
+ }
260
+ else {
261
+ if (!registered && !existsSync(path)) {
262
+ return 'already_absent';
263
+ }
264
+ if (registered) {
265
+ try {
266
+ await this.execGit([
267
+ '-C',
268
+ gitDir,
269
+ 'worktree',
270
+ 'remove',
271
+ '--force',
272
+ path,
273
+ ]);
274
+ }
275
+ catch (err) {
276
+ this.logger.warn({ path, err: String(err) }, 'git worktree remove failed on a registered worktree');
277
+ }
278
+ }
279
+ try {
280
+ rmSync(path, { recursive: true, force: true });
281
+ }
282
+ catch (err) {
283
+ this.logger.warn({ path, err: String(err) }, 'worktree directory reclaim failed');
284
+ }
285
+ }
286
+ if (gitDir) {
287
+ try {
288
+ await this.execGit(['-C', gitDir, 'worktree', 'prune']);
289
+ }
290
+ catch {
291
+ // best-effort sweep
292
+ }
293
+ }
294
+ return existsSync(path) ? 'unverified' : 'removed';
295
+ }
296
+ commandFor(input) {
297
+ const env = Object.entries(input.env ?? {}).map(([key, value]) => {
298
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
299
+ throw new Error(`invalid environment variable name for orca: ${key}`);
300
+ }
301
+ return `${key}=${shellQuote(value)}`;
302
+ });
303
+ const command = input.command ?? this.options.command ?? 'claude';
304
+ return [...env, command].join(' ');
305
+ }
306
+ }
307
+ export function orcaExecutor(options = {}) {
308
+ return {
309
+ kind: 'executor',
310
+ id: 'orca',
311
+ requiredModules: [],
312
+ async createExecutor(config, deps) {
313
+ const root = await deriveCheckoutRoot(config.config_path, {
314
+ ...(options.root !== undefined ? { override: options.root } : {}),
315
+ ...(options.execGit ? { execGit: options.execGit } : {}),
316
+ });
317
+ return new OrcaExecutor({
318
+ ...options,
319
+ command: options.command ?? 'claude',
320
+ root,
321
+ ...(config.hooks ? { hooks: config.hooks } : {}),
322
+ }, createOrcaExec(), deps.logger);
323
+ },
324
+ };
325
+ }
326
+ // The class is exported too, so the default export is what lets the config
327
+ // resolver pick the factory without an `export:` key.
328
+ export default orcaExecutor;
@@ -0,0 +1,22 @@
1
+ /** Runs `orca <args> --json`; resolves with the envelope's `result`. */
2
+ export type OrcaExec = (args: string[]) => Promise<unknown>;
3
+ /** An Orca CLI failure, keyed by Orca's own error `code`. */
4
+ export declare class OrcaError extends Error {
5
+ readonly code: string;
6
+ constructor(code: string, message: string);
7
+ }
8
+ export declare function isRecord(v: unknown): v is Record<string, unknown>;
9
+ /**
10
+ * Parse Orca's `--json` envelope. Orca prints BOTH outcomes on stdout —
11
+ * `{ok:true,result}` or `{ok:false,error:{code,message}}` — so `ok` decides,
12
+ * not the exit code.
13
+ */
14
+ export declare function parseEnvelope(stdout: string): unknown;
15
+ /** The real CLI: `bin` (default `orca`, resolved through PATH). */
16
+ export declare function createOrcaExec(bin?: string): OrcaExec;
17
+ /**
18
+ * Fail fast when no Orca runtime answers on this machine. `orca status` never
19
+ * fails itself, so `runtime.reachable` is the signal. Orca is never started
20
+ * implicitly: on a headless oven host `orca open` would launch a desktop app.
21
+ */
22
+ export declare function assertOrcaReachable(orca: OrcaExec): Promise<void>;
@@ -0,0 +1,76 @@
1
+ import { ExecError, exec } from '@bakers-build/core';
2
+ /** An Orca CLI failure, keyed by Orca's own error `code`. */
3
+ export class OrcaError extends Error {
4
+ code;
5
+ constructor(code, message) {
6
+ super(message);
7
+ this.code = code;
8
+ this.name = 'OrcaError';
9
+ }
10
+ }
11
+ export function isRecord(v) {
12
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
13
+ }
14
+ /**
15
+ * Parse Orca's `--json` envelope. Orca prints BOTH outcomes on stdout —
16
+ * `{ok:true,result}` or `{ok:false,error:{code,message}}` — so `ok` decides,
17
+ * not the exit code.
18
+ */
19
+ export function parseEnvelope(stdout) {
20
+ let parsed;
21
+ try {
22
+ parsed = JSON.parse(stdout);
23
+ }
24
+ catch {
25
+ throw new OrcaError('invalid_output', `orca returned non-JSON output: ${stdout.trim().slice(0, 200)}`);
26
+ }
27
+ if (!isRecord(parsed)) {
28
+ throw new OrcaError('invalid_output', 'orca returned an invalid envelope');
29
+ }
30
+ if (parsed.ok === true) {
31
+ return parsed.result;
32
+ }
33
+ const error = isRecord(parsed.error) ? parsed.error : {};
34
+ const code = typeof error.code === 'string' ? error.code : 'unknown_error';
35
+ const message = typeof error.message === 'string' ? error.message : 'orca command failed';
36
+ throw new OrcaError(code, `orca ${code}: ${message}`);
37
+ }
38
+ /** The real CLI: `bin` (default `orca`, resolved through PATH). */
39
+ export function createOrcaExec(bin = 'orca') {
40
+ return async (args) => {
41
+ let stdout;
42
+ try {
43
+ stdout = await exec(bin, [...args, '--json']);
44
+ }
45
+ catch (err) {
46
+ const cause = (err instanceof ExecError ? err.cause : err);
47
+ if (cause?.code === 'ENOENT') {
48
+ throw new OrcaError('orca_not_found', 'orca CLI not found in PATH');
49
+ }
50
+ // A non-zero exit still carries Orca's error envelope on stdout.
51
+ if (typeof cause?.stdout === 'string' && cause.stdout.trim()) {
52
+ stdout = cause.stdout;
53
+ }
54
+ else {
55
+ throw err;
56
+ }
57
+ }
58
+ return parseEnvelope(stdout);
59
+ };
60
+ }
61
+ /**
62
+ * Fail fast when no Orca runtime answers on this machine. `orca status` never
63
+ * fails itself, so `runtime.reachable` is the signal. Orca is never started
64
+ * implicitly: on a headless oven host `orca open` would launch a desktop app.
65
+ */
66
+ export async function assertOrcaReachable(orca) {
67
+ const status = await orca(['status']);
68
+ const runtime = isRecord(status) ? status.runtime : undefined;
69
+ if (isRecord(runtime) && runtime.reachable === true) {
70
+ return;
71
+ }
72
+ const state = isRecord(runtime) && typeof runtime.state === 'string'
73
+ ? runtime.state
74
+ : 'unknown';
75
+ throw new Error(`Orca runtime not reachable (state: ${state}). Start Orca on this machine (desktop app or "orca serve") and retry.`);
76
+ }
@@ -0,0 +1,4 @@
1
+ import type { Preset } from '@bakers-build/conductor/contract';
2
+ export declare const executors: Preset['executors'];
3
+ export declare const workspaces: Preset['workspaces'];
4
+ export declare const scopes: Preset['scopes'];
@@ -0,0 +1,10 @@
1
+ import { orcaExecutor, orcaWorkspace } from './index.js';
2
+ export const executors = (acc, opts) => [
3
+ ...acc,
4
+ orcaExecutor(opts),
5
+ ];
6
+ export const workspaces = (acc, opts) => [
7
+ ...acc,
8
+ orcaWorkspace(opts),
9
+ ];
10
+ export const scopes = ['project'];
@@ -0,0 +1,88 @@
1
+ import type { BbWorkspace, EnsuredWorkspace, WorkspacePlugin } from '@bakers-build/conductor/contract';
2
+ import type { ConductorLogger, RootGitExec } from '@bakers-build/core';
3
+ import { type OrcaExec } from './orca-cli.js';
4
+ export type GitExec = (args: string[]) => Promise<string>;
5
+ export type Sleep = (ms: number) => Promise<void>;
6
+ /**
7
+ * The overview terminal a FRESH worktree opens. `{identifier}`, `{branch}`,
8
+ * `{title}` and `{worktreePath}` expand in both; `open: {}` opens nothing.
9
+ */
10
+ export interface OrcaOpenLayout {
11
+ command?: string;
12
+ title?: string;
13
+ }
14
+ export declare const DEFAULT_OPEN_LAYOUT: OrcaOpenLayout;
15
+ /** Above Orca's 30 s worktree scan cache, so one rescan always fits. */
16
+ export declare const DEFAULT_ADOPT_TIMEOUT_MS = 35000;
17
+ export declare const defaultSleep: Sleep;
18
+ export declare const noopLogger: ConductorLogger;
19
+ /**
20
+ * Wait until Orca resolves `path` as a worktree. Orca adopts worktrees made
21
+ * with plain git by rescanning `git worktree list`, which it caches for up to
22
+ * 30 s, so a fresh worktree is `selector_not_found` for a while.
23
+ */
24
+ export declare function waitForAdoption(orca: OrcaExec, path: string, timeoutMs: number, sleep: Sleep): Promise<void>;
25
+ /** `git worktree list --porcelain` records; the main checkout comes first. */
26
+ export declare function porcelainEntries(output: string): {
27
+ path: string;
28
+ branch: string | null;
29
+ }[];
30
+ export interface OrcaWorkspaceOptions {
31
+ /** Main clone (a git repo) that Orca registers and worktrees branch from. */
32
+ root: string;
33
+ /** Relative dir (under root) for new worktrees. Default `.bakers-worktrees`. */
34
+ worktreeDir?: string;
35
+ /** Base ref for new branches. Default: the upstream of the root's HEAD. */
36
+ baseBranch?: string;
37
+ /** Overview terminal of a fresh worktree. Unset → {@link DEFAULT_OPEN_LAYOUT}. */
38
+ open?: OrcaOpenLayout;
39
+ adoptTimeoutMs?: number;
40
+ execOrca?: OrcaExec;
41
+ execGit?: GitExec;
42
+ logger?: ConductorLogger;
43
+ sleep?: Sleep;
44
+ }
45
+ /**
46
+ * Per-ticket worktree hosted in Orca. The worktree is created with plain git on
47
+ * the exact ticket branch — `orca worktree create` can neither take an existing
48
+ * branch nor keep a name it considers taken — and Orca adopts it.
49
+ */
50
+ export declare class OrcaWorkspace implements BbWorkspace {
51
+ private readonly options;
52
+ private readonly root;
53
+ private readonly execOrca;
54
+ private readonly execGit;
55
+ private readonly open;
56
+ private readonly logger;
57
+ private readonly sleep;
58
+ /** Fresh worktrees awaiting {@link applyOpenLayout}; one-shot. */
59
+ private readonly pendingOpen;
60
+ constructor(options: OrcaWorkspaceOptions);
61
+ ensure(_identifier: string, branch?: string, baseRefOverride?: string): Promise<EnsuredWorkspace>;
62
+ /** Register the root with Orca once and make its git worktrees visible. */
63
+ private registerRepo;
64
+ private isRegisteredWorktree;
65
+ private refExists;
66
+ /** Check out the ticket branch as it is — never reset (`-B`) or renamed. */
67
+ private addWorktree;
68
+ /**
69
+ * Open the overview terminal of a worktree the latest {@link ensure} created.
70
+ * A reused worktree has none pending, so reattaching never duplicates it.
71
+ * Best-effort: a failure is logged, never thrown, so it cannot wedge dispatch.
72
+ */
73
+ applyOpenLayout(path: string, ctx: {
74
+ identifier: string;
75
+ branch: string;
76
+ title: string;
77
+ }): Promise<void>;
78
+ }
79
+ export declare function orcaWorkspace(opts?: {
80
+ /** Explicit repo-root override; otherwise derived from the git common dir. */
81
+ root?: string;
82
+ worktreeDir?: string;
83
+ baseBranch?: string;
84
+ open?: OrcaOpenLayout;
85
+ adoptTimeoutMs?: number;
86
+ /** Overrideable git exec for root derivation (tests). */
87
+ execGit?: RootGitExec;
88
+ }): WorkspacePlugin;
@@ -0,0 +1,241 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { renderTemplate, resolveBaseRef, truncateOpenTabTitle, validateBranch, } from '@bakers-build/addon-herdr/shared';
4
+ import { loadInstructions } from '@bakers-build/addon-workspace-git';
5
+ import { deriveCheckoutRoot, exec } from '@bakers-build/core';
6
+ import { assertOrcaReachable, createOrcaExec, isRecord, OrcaError, } from './orca-cli.js';
7
+ export const DEFAULT_OPEN_LAYOUT = {
8
+ command: 'bakers ui --ticket {identifier}',
9
+ title: '{identifier} · {title}',
10
+ };
11
+ /** Above Orca's 30 s worktree scan cache, so one rescan always fits. */
12
+ export const DEFAULT_ADOPT_TIMEOUT_MS = 35_000;
13
+ const ADOPT_POLL_MS = 1000;
14
+ export const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
15
+ export const noopLogger = {
16
+ debug() { },
17
+ info() { },
18
+ warn() { },
19
+ error() { },
20
+ };
21
+ /**
22
+ * Wait until Orca resolves `path` as a worktree. Orca adopts worktrees made
23
+ * with plain git by rescanning `git worktree list`, which it caches for up to
24
+ * 30 s, so a fresh worktree is `selector_not_found` for a while.
25
+ */
26
+ export async function waitForAdoption(orca, path, timeoutMs, sleep) {
27
+ const attempts = Math.max(1, Math.ceil(timeoutMs / ADOPT_POLL_MS));
28
+ for (let i = 0; i < attempts; i++) {
29
+ try {
30
+ await orca(['worktree', 'show', '--worktree', `path:${path}`]);
31
+ return;
32
+ }
33
+ catch (err) {
34
+ if (!(err instanceof OrcaError) || err.code !== 'selector_not_found') {
35
+ throw err;
36
+ }
37
+ }
38
+ if (i < attempts - 1)
39
+ await sleep(ADOPT_POLL_MS);
40
+ }
41
+ throw new Error(`Orca did not pick up worktree ${path} within ${Math.round(timeoutMs / 1000)}s`);
42
+ }
43
+ /** `git worktree list --porcelain` records; the main checkout comes first. */
44
+ export function porcelainEntries(output) {
45
+ return output
46
+ .split('\n\n')
47
+ .map((record) => {
48
+ const lines = record.split('\n');
49
+ const path = lines.find((l) => l.startsWith('worktree '));
50
+ const branch = lines.find((l) => l.startsWith('branch '));
51
+ return {
52
+ path: path ? path.slice('worktree '.length).trim() : '',
53
+ branch: branch
54
+ ? branch
55
+ .slice('branch '.length)
56
+ .trim()
57
+ .replace(/^refs\/heads\//, '')
58
+ : null,
59
+ };
60
+ })
61
+ .filter((e) => e.path);
62
+ }
63
+ /**
64
+ * Per-ticket worktree hosted in Orca. The worktree is created with plain git on
65
+ * the exact ticket branch — `orca worktree create` can neither take an existing
66
+ * branch nor keep a name it considers taken — and Orca adopts it.
67
+ */
68
+ export class OrcaWorkspace {
69
+ options;
70
+ root;
71
+ execOrca;
72
+ execGit;
73
+ open;
74
+ logger;
75
+ sleep;
76
+ /** Fresh worktrees awaiting {@link applyOpenLayout}; one-shot. */
77
+ pendingOpen = new Set();
78
+ constructor(options) {
79
+ this.options = options;
80
+ this.root = resolve(options.root);
81
+ this.execOrca = options.execOrca ?? createOrcaExec();
82
+ this.execGit = options.execGit ?? ((args) => exec('git', args));
83
+ // `??`, not `||`: an explicit `open: {}` must survive as the opt-out.
84
+ this.open = options.open ?? DEFAULT_OPEN_LAYOUT;
85
+ this.logger = options.logger ?? noopLogger;
86
+ this.sleep = options.sleep ?? defaultSleep;
87
+ }
88
+ async ensure(_identifier, branch, baseRefOverride) {
89
+ if (branch === undefined) {
90
+ throw new Error('orca workspace requires a branch name');
91
+ }
92
+ validateBranch(branch);
93
+ await assertOrcaReachable(this.execOrca);
94
+ await this.registerRepo();
95
+ const path = resolve(this.root, this.options.worktreeDir ?? '.bakers-worktrees', branch);
96
+ const created = !(await this.isRegisteredWorktree(path));
97
+ if (created) {
98
+ await this.addWorktree(branch, path, baseRefOverride);
99
+ }
100
+ await waitForAdoption(this.execOrca, path, this.options.adoptTimeoutMs ?? DEFAULT_ADOPT_TIMEOUT_MS, this.sleep);
101
+ if (created) {
102
+ this.pendingOpen.add(path);
103
+ }
104
+ return { path, instructions: loadInstructions(path), created };
105
+ }
106
+ /** Register the root with Orca once and make its git worktrees visible. */
107
+ async registerRepo() {
108
+ const listed = await this.execOrca(['repo', 'list']);
109
+ const repos = isRecord(listed) && Array.isArray(listed.repos) ? listed.repos : [];
110
+ const known = repos.some((r) => isRecord(r) &&
111
+ typeof r.path === 'string' &&
112
+ resolve(r.path) === this.root);
113
+ if (!known) {
114
+ await this.execOrca(['repo', 'add', '--path', this.root]);
115
+ }
116
+ // Idempotent; without it Orca's sidebar hides worktrees it did not create.
117
+ await this.execOrca([
118
+ 'repo',
119
+ 'set',
120
+ '--repo',
121
+ `path:${this.root}`,
122
+ '--external-worktree-visibility',
123
+ 'show',
124
+ ]);
125
+ }
126
+ async isRegisteredWorktree(path) {
127
+ const output = await this.execGit([
128
+ '-C',
129
+ this.root,
130
+ 'worktree',
131
+ 'list',
132
+ '--porcelain',
133
+ ]);
134
+ return (porcelainEntries(output).some((e) => e.path === path) && existsSync(path));
135
+ }
136
+ async refExists(ref) {
137
+ try {
138
+ await this.execGit([
139
+ '-C',
140
+ this.root,
141
+ 'rev-parse',
142
+ '--verify',
143
+ '--quiet',
144
+ `${ref}^{commit}`,
145
+ ]);
146
+ return true;
147
+ }
148
+ catch {
149
+ return false;
150
+ }
151
+ }
152
+ /** Check out the ticket branch as it is — never reset (`-B`) or renamed. */
153
+ async addWorktree(branch, path, baseRefOverride) {
154
+ const add = ['-C', this.root, 'worktree', 'add'];
155
+ if (await this.refExists(`refs/heads/${branch}`)) {
156
+ await this.execGit([...add, path, branch]);
157
+ return;
158
+ }
159
+ try {
160
+ await this.execGit(['-C', this.root, 'fetch', 'origin', branch]);
161
+ }
162
+ catch {
163
+ // Non-fatal: the branch is simply not on origin (yet).
164
+ }
165
+ if (await this.refExists(`refs/remotes/origin/${branch}`)) {
166
+ await this.execGit([
167
+ ...add,
168
+ '--track',
169
+ '-b',
170
+ branch,
171
+ path,
172
+ `origin/${branch}`,
173
+ ]);
174
+ return;
175
+ }
176
+ const base = await resolveBaseRef(this.root, this.execGit, {
177
+ ...(this.options.baseBranch
178
+ ? { baseBranch: this.options.baseBranch }
179
+ : {}),
180
+ ...(baseRefOverride !== undefined ? { override: baseRefOverride } : {}),
181
+ });
182
+ await this.execGit([...add, '-b', branch, path, ...(base ? [base] : [])]);
183
+ }
184
+ /**
185
+ * Open the overview terminal of a worktree the latest {@link ensure} created.
186
+ * A reused worktree has none pending, so reattaching never duplicates it.
187
+ * Best-effort: a failure is logged, never thrown, so it cannot wedge dispatch.
188
+ */
189
+ async applyOpenLayout(path, ctx) {
190
+ if (!this.pendingOpen.delete(path) || this.open.command === undefined) {
191
+ return;
192
+ }
193
+ const vars = {
194
+ identifier: ctx.identifier,
195
+ branch: ctx.branch,
196
+ title: ctx.title,
197
+ worktreePath: path,
198
+ };
199
+ try {
200
+ await this.execOrca([
201
+ 'terminal',
202
+ 'create',
203
+ '--worktree',
204
+ `path:${path}`,
205
+ '--title',
206
+ renderTemplate(this.open.title ?? '{identifier} · {title}', {
207
+ ...vars,
208
+ title: truncateOpenTabTitle(ctx.title),
209
+ }),
210
+ '--command',
211
+ renderTemplate(this.open.command, vars),
212
+ ]);
213
+ }
214
+ catch (err) {
215
+ this.logger.warn({ branch: ctx.branch, worktree: path, err: String(err) }, 'orca overview terminal failed (best-effort, ignored)');
216
+ }
217
+ }
218
+ }
219
+ export function orcaWorkspace(opts = {}) {
220
+ return {
221
+ kind: 'workspace',
222
+ id: 'orca',
223
+ requiredModules: [],
224
+ async createWorkspace(config, deps) {
225
+ const root = await deriveCheckoutRoot(config.config_path, {
226
+ ...(opts.root !== undefined ? { override: opts.root } : {}),
227
+ ...(opts.execGit ? { execGit: opts.execGit } : {}),
228
+ });
229
+ return new OrcaWorkspace({
230
+ root,
231
+ ...(opts.worktreeDir ? { worktreeDir: opts.worktreeDir } : {}),
232
+ ...(opts.baseBranch ? { baseBranch: opts.baseBranch } : {}),
233
+ ...(opts.open ? { open: opts.open } : {}),
234
+ ...(opts.adoptTimeoutMs !== undefined
235
+ ? { adoptTimeoutMs: opts.adoptTimeoutMs }
236
+ : {}),
237
+ ...(deps?.logger ? { logger: deps.logger } : {}),
238
+ });
239
+ },
240
+ };
241
+ }
package/package.json CHANGED
@@ -1,6 +1,30 @@
1
1
  {
2
2
  "name": "@bakers-build/addon-orca",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.2-next.194",
4
+ "description": "bakers.build Orca integration: executor + per-ticket worktree workspace hosted in Orca.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "exports": {
8
+ ".": "./dist/src/index.js",
9
+ "./preset": "./dist/src/preset.js"
10
+ },
11
+ "files": [
12
+ "dist/src"
13
+ ],
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://git.key-tec.de/bakers-build/bakers-build.git",
20
+ "directory": "bakers-cli/addons/orca"
21
+ },
22
+ "dependencies": {
23
+ "@bakers-build/addon-herdr": "^0.1.2-next.194",
24
+ "@bakers-build/addon-workspace-git": "^0.1.2-next.194"
25
+ },
26
+ "peerDependencies": {
27
+ "@bakers-build/conductor": "^0.1.2-next.194",
28
+ "@bakers-build/core": "^0.1.2-next.194"
29
+ }
30
+ }