@bakers-build/addon-herdr 0.0.0-stage → 0.1.0-next.38

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.
@@ -0,0 +1,219 @@
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 HerdrExecutorOptions } from './config.js';
4
+ import { type HerdrExec } from './pane-layout.js';
5
+ export type { AgentLaunchHost, AgentLaunchHostProvider, HostedAgent, } from './agent-host.js';
6
+ export { herdrAgentHost } from './agents.js';
7
+ export type { HerdrExec } from './pane-layout.js';
8
+ export { envArgs, findTabIdByLabel, parsePaneInfo, parsePanesInTab, parseTabCreate, } from './panes.js';
9
+ export { herdrProcessHost } from './process-host.js';
10
+ export { HerdrWorkspace, type HerdrWorkspaceOptions, herdrWorkspace, type OpenLayout, } from './workspace.js';
11
+ export { parseWorkspaceList, type WorkspaceEntry, } from './workspace-list.js';
12
+ /** Runs a lifecycle-hook shell command in a cwd; rejects on non-zero exit. */
13
+ export type ShellRunner = (command: string, cwd: string, env?: Record<string, string>) => Promise<void>;
14
+ /** Runs `git <args>` in a cwd; resolves with stdout, rejects on non-zero exit. */
15
+ export type GitRunner = (args: string[], cwd: string) => Promise<string>;
16
+ export declare class HerdrExecutor implements BbExecutor {
17
+ private readonly options;
18
+ private readonly execHerdr;
19
+ private readonly logger;
20
+ private readonly runShell;
21
+ private readonly runGit;
22
+ readonly id = "herdr";
23
+ constructor(options: HerdrExecutorOptions, execHerdr?: HerdrExec, logger?: ConductorLogger, runShell?: ShellRunner, runGit?: GitRunner);
24
+ capabilities(): ExecutorCapabilities;
25
+ /**
26
+ * Run the lifecycle hook `name` best-effort — the single catch point for all
27
+ * lifecycle hooks (see {@link BbExecutor.runHook}). No configured command →
28
+ * silent no-op. A failing command → log loudly (hook + worktree + ticket +
29
+ * err) and return. NEVER throws, so a hook failure can't abort dispatch or
30
+ * wedge a run.
31
+ *
32
+ * BB-237: it also RETURNS what happened ({@link HookResult}). Not throwing
33
+ * kept a hook from wedging a run, but swallowing the outcome entirely left the
34
+ * caller unable to tell a provisioned worktree from a broken one — so an agent
35
+ * was dispatched into a worktree whose bootstrap had aborted, with nothing but
36
+ * a log line (which, on a TTY, went nowhere) to say so.
37
+ */
38
+ runHook(name: HookName, cwd: string, ctx: HookContext, env?: Record<string, string>): Promise<HookResult>;
39
+ /**
40
+ * List worktrees, ALWAYS anchored to the configured parent repo root
41
+ * (BB-211). `herdr worktree list` is machine-global, but run WITHOUT
42
+ * `--cwd` it resolves herdr's own ambient (previously selected / persisted /
43
+ * process / shell / daemon) repository context. When a preceding
44
+ * `bakers-cleanup-*` integration test selected then deleted a
45
+ * `/tmp/bakers-cleanup-*` repo, that stale context survives and the unanchored
46
+ * list exits (`cannot change to '/tmp/bakers-cleanup-…'`), aborting dispatch.
47
+ * Anchoring every list with `--cwd this.options.root` makes listing
48
+ * independent of that ambient context. This is the SINGLE choke point for
49
+ * all executor `worktree list` call paths — the sibling
50
+ * {@link HerdrWorkspace.ensure} anchors identically. The `--cwd` is only
51
+ * omitted when no root is configured (rootless test callers).
52
+ *
53
+ * On a herdr failure it throws an error naming the explicit configured root
54
+ * and the operation (AC-9) instead of surfacing herdr's raw stale-context
55
+ * message, so a future context failure is diagnosable.
56
+ */
57
+ private listWorktrees;
58
+ /**
59
+ * Find the worktree entry (path + open_workspace_id) for a branch via
60
+ * worktree list. Returns null if the branch has no worktree.
61
+ */
62
+ private findWorktreeByBranch;
63
+ /**
64
+ * Find the open_workspace_id for a branch via worktree list.
65
+ * Returns null if no workspace is open for that branch.
66
+ */
67
+ private findWorkspaceByBranch;
68
+ /**
69
+ * Find the open_workspace_id for a worktree by its stable `worktreePath`
70
+ * (falling back to `branch`; see {@link resolveWorktreeEntry}). Unlike
71
+ * {@link findWorkspaceByBranch}, this does not rely on the mutable branch —
72
+ * it is the startRun lookup the BB-114 fix hangs on. Returns null when the
73
+ * worktree is not listed as open.
74
+ */
75
+ private findOpenWorkspace;
76
+ /** Parent repo root for git worktree admin ops (falls back to `fallback`). */
77
+ private gitRoot;
78
+ /**
79
+ * Sweep orphaned `.git/worktrees/<name>` admin entries at the parent repo
80
+ * root — the ones whose on-disk dir has vanished (BB-141: they show up as
81
+ * `prunable` in `git worktree list`). Best-effort: a prune failure must never
82
+ * fail teardown.
83
+ */
84
+ private pruneWorktrees;
85
+ /**
86
+ * Remove an on-disk worktree GIT-AWARELY (BB-141 RC-1). The old fallback
87
+ * raw-`rm`'d the directory and left the parent repo's `.git/worktrees/<name>`
88
+ * admin entry behind → a `prunable` orphan. This:
89
+ * 1. restores owner-write (Drupal hardens web/sites/default 0555 +
90
+ * settings.php 0444 — chmod-restorable by the owning user, no sudo);
91
+ * 2. `git worktree remove --force <path>` from the PARENT repo root, which
92
+ * unlinks the directory AND drops the `.git/worktrees/<name>` entry —
93
+ * but ONLY when the path really is a registered worktree (BB-293):
94
+ * against an unregistered path git exits 128 and the shared exec helper
95
+ * logs `ERROR: exec failed` for a condition this code expects and
96
+ * handles. The guard lives here rather than at the caller because this
97
+ * method has a second call site (the path resolved via
98
+ * {@link findGitWorktree});
99
+ * 3. guarantees the directory is gone (a raw force-remove) in case git
100
+ * refused or was skipped — a no-op if git already removed it;
101
+ * 4. prunes the repo so any now-stale admin entry is swept.
102
+ *
103
+ * Returns whether the directory is gone afterwards. A reclaim that cannot
104
+ * remove it REPORTS instead of throwing (BB-293, spec D5): the raw EACCES
105
+ * used to propagate all the way out of {@link removeWorktree} and reach the
106
+ * reaper as an opaque exception.
107
+ */
108
+ private gitAwareRemove;
109
+ /**
110
+ * When herdr has lost track of a worktree and its `worktreePath` was never
111
+ * persisted, the PARENT repo's `git worktree list` still knows it (BB-141
112
+ * RC-2 — the ~28 never-touched leftovers). Resolve the lost worktree's on-disk
113
+ * path there, preferring an exact `worktreePath` match, then a branch match;
114
+ * never the main worktree. Returns null when nothing matches. Best-effort: a
115
+ * git failure resolves to null.
116
+ *
117
+ * `cwd` is only where git is RUN. The main worktree is excluded by git's own
118
+ * porcelain ordering (it is always the first record) rather than by comparing
119
+ * against `cwd`, because the two coincide only when a parent root is
120
+ * configured. Without one the probe falls back to running git inside the
121
+ * worktree itself, and a `path !== cwd` filter then dropped the very entry
122
+ * being asked about — so `registered: true` was unreachable for a rootless
123
+ * executor (BB-293 review, finding 1). The configured root is excluded too,
124
+ * belt and braces, so a caller that anchors elsewhere still never resolves the
125
+ * main checkout as a ticket worktree.
126
+ */
127
+ private findGitWorktree;
128
+ /**
129
+ * Ask — passively — whether this worktree still exists, in BOTH dimensions
130
+ * that can come apart (BB-293): git's registration, and the directory on
131
+ * disk. The reported failure was exactly a case where they disagreed — the
132
+ * directory was there, the registration was not — so a single-dimension
133
+ * check cannot decide what to do.
134
+ *
135
+ * Passive by construction: it writes nothing, calls no herdr, and NEVER
136
+ * throws. A git failure yields `registered: false`, degrading the verdict to
137
+ * the on-disk answer rather than failing the teardown.
138
+ *
139
+ * The persisted `worktreePath` is the stable key and wins when given;
140
+ * `registered` then describes THAT path, never a branch match pointing
141
+ * somewhere else (the checked-out branch is mutable — BB-114).
142
+ *
143
+ * git is run at the configured parent root when there is one, else inside the
144
+ * worktree itself — `git worktree list` enumerates the whole repo from any of
145
+ * its worktrees, so the rootless answer is the same list.
146
+ */
147
+ private probeWorktree;
148
+ /**
149
+ * BB-166 focus guard. herdr's `worktree remove` deletes the ticket
150
+ * worktree's on-disk directory; if that workspace is the CURRENTLY FOCUSED
151
+ * one, the interactive client is left in a now-deleted `cwd` and herdr spawns
152
+ * a stray empty workspace for the dead path. So before the remove, if the
153
+ * target workspace is focused, switch focus to the main-checkout (non-linked)
154
+ * workspace of the SAME repo. A background reap of a non-focused worktree
155
+ * (or one herdr no longer lists) focuses nothing.
156
+ *
157
+ * Returns whether it is SAFE to proceed with the remove: `false` when a
158
+ * required focus switch cannot be resolved or verified — the caller then
159
+ * aborts (returns `false` → `cleaned_up=0`) so the reaper retries later,
160
+ * rather than deleting the focused path out from under the user.
161
+ */
162
+ private ensureFocusSafeToRemove;
163
+ /**
164
+ * Tear down a ticket's entire worktree (git worktree + hosted workspace).
165
+ * `herdr worktree remove` is keyed by an open workspace id, so resolve the
166
+ * worktree first — by its stable `worktreePath`, falling back to `branch`
167
+ * (see {@link resolveWorktreeEntry}): if its workspace is already open use
168
+ * that id; otherwise open the on-disk worktree to obtain one (mirrors
169
+ * startRun's open-if-needed).
170
+ *
171
+ * `herdr worktree list` is machine-global — it enumerates every repo's
172
+ * worktrees on the host, most of which are not ours. So a no-match does NOT
173
+ * mean "error"; it means herdr no longer tracks this ticket's worktree. When
174
+ * herdr has lost track, teardown is resolved off git itself (never a foreign
175
+ * worktree), git-awarely so no `.git/worktrees/<name>` metadata is orphaned
176
+ * (BB-141 RC-1). Since BB-293 that resolution starts by ASKING — a passive
177
+ * {@link probeWorktree} reads both dimensions of existence and the four cases
178
+ * branch explicitly:
179
+ * - neither registered nor on disk → nothing to do at all;
180
+ * - registered but gone from disk → sweep the prunable admin entry;
181
+ * - on disk but not registered → reclaim the directory, WITHOUT asking git
182
+ * to remove a worktree it does not know (the bogus `ERROR: exec failed`);
183
+ * - both → reclaim it git-awarely, `git worktree remove` included.
184
+ * A path that resolves in NEITHER dimension is not "absent" — it is
185
+ * unresolved, and still reported `unverified` (BB-141 RC-2).
186
+ *
187
+ * Reports what the teardown DID (BB-293): `removed` when the worktree is
188
+ * gone because this host removed it (or reclaimed an on-disk orphan, or swept
189
+ * the prunable admin entry it left behind), `already_absent` when a RESOLVED
190
+ * worktree existed in neither dimension, and `unverified` ONLY when nothing
191
+ * was resolvable or the directory could not be reclaimed — the reaper then
192
+ * leaves the ticket on its work list for a later retry instead of falsely
193
+ * flagging it cleaned (BB-141 RC-2).
194
+ */
195
+ removeWorktree(branch: string, worktreePath?: string): Promise<WorktreeTeardown>;
196
+ startRun(input: SpawnRunInput): Promise<SpawnedSession>;
197
+ /** Best-effort cleanup: close only the just-created tab. Swallows errors. */
198
+ private rollback;
199
+ /**
200
+ * No-op: at finalise time the agent is idle and its transcript is already on
201
+ * disk; the {@link cleanupRun} tab-close kills the PTY, so the agent dies
202
+ * implicitly. Kept to satisfy the run lifecycle seam.
203
+ */
204
+ stopRun(_branch: string): Promise<void>;
205
+ /**
206
+ * Close ONLY the branch-workspace tab whose label carries the `#<runId>`
207
+ * token (`herdr tab close <tab_id>`), killing that PTY (BB-183). The run
208
+ * identity lives in the tab label, so teardown is run-scoped: a sibling run's
209
+ * tab and any non-run tab in the same workspace are left untouched, and a
210
+ * missing match is a quiet no-op. Best-effort: a failing close is swallowed.
211
+ * No `/exit`/C-c and no `(done)` rename — the matched tab simply goes away.
212
+ * The branch worktree is untouched (that is the reap/{@link removeWorktree}
213
+ * lifecycle).
214
+ */
215
+ cleanupRun(branch: string, runId: number): Promise<void>;
216
+ private commandFor;
217
+ }
218
+ export declare function herdrExecutor(options?: HerdrExecutorOptions): ExecutorPlugin;
219
+ export default herdrExecutor;