@bridge4dev/runner 0.11.0 → 0.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/adapters/claude.d.ts +15 -7
  2. package/dist/adapters/claude.js +1024 -70
  3. package/dist/adapters/codex.d.ts +18 -3
  4. package/dist/adapters/codex.js +224 -65
  5. package/dist/adapters/questions.d.ts +42 -0
  6. package/dist/adapters/questions.js +86 -0
  7. package/dist/adapters/types.d.ts +200 -4
  8. package/dist/attachments.d.ts +8 -1
  9. package/dist/attachments.js +22 -4
  10. package/dist/auto-resume.d.ts +18 -0
  11. package/dist/auto-resume.js +104 -0
  12. package/dist/commit-message.d.ts +51 -0
  13. package/dist/commit-message.js +224 -0
  14. package/dist/config.d.ts +29 -6
  15. package/dist/config.js +15 -0
  16. package/dist/crash-note.d.ts +54 -0
  17. package/dist/crash-note.js +105 -0
  18. package/dist/git.d.ts +71 -0
  19. package/dist/git.js +207 -10
  20. package/dist/gitops.d.ts +489 -12
  21. package/dist/gitops.js +1717 -96
  22. package/dist/index.js +435 -32
  23. package/dist/paths.d.ts +26 -0
  24. package/dist/paths.js +34 -0
  25. package/dist/policy.d.ts +63 -0
  26. package/dist/policy.js +412 -10
  27. package/dist/protocol.d.ts +382 -60
  28. package/dist/protocol.js +104 -1
  29. package/dist/recipe-schema.d.ts +310 -0
  30. package/dist/recipe-schema.js +103 -0
  31. package/dist/recipe.d.ts +94 -0
  32. package/dist/recipe.js +238 -0
  33. package/dist/self-update.d.ts +7 -0
  34. package/dist/self-update.js +171 -23
  35. package/dist/service-unit.d.ts +79 -0
  36. package/dist/service-unit.js +211 -0
  37. package/dist/supervisor.d.ts +108 -1
  38. package/dist/supervisor.js +1010 -56
  39. package/dist/verify-queue.d.ts +17 -0
  40. package/dist/verify-queue.js +100 -0
  41. package/dist/verify.d.ts +203 -0
  42. package/dist/verify.js +788 -0
  43. package/dist/version.d.ts +1 -1
  44. package/dist/version.js +1 -1
  45. package/package.json +2 -2
package/dist/git.js CHANGED
@@ -2,7 +2,7 @@ import { execFile } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { promisify } from 'node:util';
5
- import { worktreesDir } from './paths.js';
5
+ import { previewsDir, worktreesDir } from './paths.js';
6
6
  const execFileAsync = promisify(execFile);
7
7
  const GIT_TIMEOUT_MS = 30_000;
8
8
  async function git(cwd, ...args) {
@@ -92,7 +92,8 @@ export async function ensureSessionWorktree(workspacePath, sessionId, branchHint
92
92
  // A ticket group gets a branch named after its tickets so the work reads as
93
93
  // one thing in git history. The worktree DIRECTORY stays id-derived — it is
94
94
  // the collision guard below, and two sessions must never share one.
95
- const branch = sanitizeBranch(branchHint) ?? `devbridge/s-${short}`;
95
+ const planned = options.plan ? sanitizeBranch(options.plan.branch) : null;
96
+ const branch = planned ?? sanitizeBranch(branchHint) ?? `devbridge/s-${short}`;
96
97
  const worktreePath = sessionWorktreePath(sessionId);
97
98
  if (fs.existsSync(path.join(worktreePath, '.git'))) {
98
99
  // Runner restart — reuse, but verify the worktree really is ours: a
@@ -109,19 +110,198 @@ export async function ensureSessionWorktree(workspacePath, sessionId, branchHint
109
110
  const branchExists = await git(workspacePath, 'rev-parse', '--verify', '--quiet', branch)
110
111
  .then(() => true)
111
112
  .catch(() => false);
113
+ // With a plan, both mismatches are failures worth stopping for. Without one
114
+ // (an API from before session 13) the historical guesswork is kept exactly as
115
+ // it was, so an old server does not start failing after a runner update.
116
+ const plan = planned ? options.plan : undefined;
117
+ if (plan?.source === 'NEW' && branchExists) {
118
+ throw new Error(`branch ${branch} already exists in ${workspacePath} — a new session must not silently continue somebody else's work`);
119
+ }
120
+ if (plan?.source === 'CONTINUE' && !branchExists) {
121
+ throw new Error(`branch ${branch} no longer exists in ${workspacePath}`);
122
+ }
112
123
  if (branchExists) {
124
+ // Git refuses to check one branch out in two worktrees. Saying which
125
+ // worktree holds it beats the raw «is already checked out» from git.
126
+ const holder = await worktreeHolding(workspacePath, branch);
127
+ if (holder) {
128
+ throw new Error(`branch ${branch} is already checked out in ${holder}`);
129
+ }
113
130
  await git(workspacePath, 'worktree', 'add', worktreePath, branch);
131
+ return { branch, worktreePath };
114
132
  }
115
- else {
116
- if (options.requireExistingBranch) {
117
- // The caller is restoring a session that already produced work. Creating
118
- // a fresh branch off HEAD here would look like success while quietly
119
- // hiding every commit the agent made — fail loudly instead.
120
- throw new Error(`session branch ${branch} no longer exists in ${workspacePath}`);
133
+ if (options.requireExistingBranch) {
134
+ // The caller is restoring a session that already produced work. Creating
135
+ // a fresh branch off HEAD here would look like success while quietly
136
+ // hiding every commit the agent made — fail loudly instead.
137
+ throw new Error(`session branch ${branch} no longer exists in ${workspacePath}`);
138
+ }
139
+ // Fork point: the sha the API pinned, then the branch it named, then HEAD.
140
+ // A pinned sha that is not in this repository is not worth guessing around —
141
+ // branching off the wrong commit is the failure this whole plan removes.
142
+ const startPoint = await resolveStartPoint(workspacePath, plan);
143
+ // What HEAD points at, read BEFORE the worktree is added. This is the only
144
+ // chance to learn the fork point for a session created while the runner was
145
+ // offline: the API had nobody to ask, so `branchPlan.baseBranch` is empty and
146
+ // without this the base is never pinned at all — which switches off the drift
147
+ // guard for that session's whole life (QA-107).
148
+ const headBranch = plan?.baseBranch
149
+ ? plan.baseBranch
150
+ : await git(workspacePath, 'rev-parse', '--abbrev-ref', 'HEAD')
151
+ .then((name) => (name && name !== 'HEAD' ? name : undefined))
152
+ .catch(() => undefined);
153
+ await git(workspacePath, 'worktree', 'add', '-b', branch, worktreePath, startPoint);
154
+ const baseSha = await git(workspacePath, 'rev-parse', startPoint).catch(() => undefined);
155
+ return {
156
+ branch,
157
+ worktreePath,
158
+ ...(headBranch ? { baseBranch: headBranch } : {}),
159
+ ...(baseSha ? { baseSha } : {}),
160
+ };
161
+ }
162
+ /**
163
+ * DIRECT mode (session 16): the session's workplace IS the project folder.
164
+ *
165
+ * Nothing is created and nothing is moved. The folder stays on the branch it is
166
+ * on, and that branch is the session's branch — which is the whole point: the
167
+ * work the agent does is already where the person expects to find it, with no
168
+ * «Apply» step in between and nothing to lose if the session is never applied.
169
+ *
170
+ * The two refusals are both about NOT guessing:
171
+ *
172
+ * - not a git work tree — every git surface downstream would fail one call at
173
+ * a time instead of once, here, with a sentence that names the folder;
174
+ * - a detached HEAD — commits would land on no branch at all and be reachable
175
+ * only by sha. A person who checked out a tag to look at something must not
176
+ * discover an agent committed onto it.
177
+ *
178
+ * The path returned is the repository ROOT, not necessarily the folder that was
179
+ * configured. Every path in the Source Control panel is repo-root-relative
180
+ * because that is what `git status` prints, so the root is the only place the
181
+ * paths and the commands agree — and it is also the confinement root layer 1
182
+ * hands the agent.
183
+ */
184
+ export async function prepareDirectWorkspace(workspacePath) {
185
+ const inside = await git(workspacePath, 'rev-parse', '--is-inside-work-tree').catch(() => null);
186
+ if (inside !== 'true') {
187
+ throw new Error(`${workspacePath} is not a git work tree`);
188
+ }
189
+ const branch = await git(workspacePath, 'rev-parse', '--abbrev-ref', 'HEAD').catch(() => null);
190
+ if (!branch || branch === 'HEAD') {
191
+ throw new Error('The project folder is not on a branch (detached HEAD) — check a branch out there before starting a session');
192
+ }
193
+ const top = await git(workspacePath, 'rev-parse', '--show-toplevel').catch(() => null);
194
+ const baseSha = await git(workspacePath, 'rev-parse', 'HEAD').catch(() => undefined);
195
+ return {
196
+ branch,
197
+ worktreePath: top || workspacePath,
198
+ baseBranch: branch,
199
+ ...(baseSha ? { baseSha } : {}),
200
+ };
201
+ }
202
+ async function resolveStartPoint(workspacePath, plan) {
203
+ if (plan?.baseSha && /^[0-9a-f]{7,64}$/i.test(plan.baseSha)) {
204
+ const exists = await git(workspacePath, 'rev-parse', '--verify', '--quiet', `${plan.baseSha}^{commit}`)
205
+ .then(() => true)
206
+ .catch(() => false);
207
+ if (exists)
208
+ return plan.baseSha;
209
+ throw new Error(`base commit ${plan.baseSha} is not in ${workspacePath}`);
210
+ }
211
+ const baseBranch = plan?.baseBranch ? sanitizeBranch(plan.baseBranch) : null;
212
+ if (baseBranch) {
213
+ const exists = await git(workspacePath, 'rev-parse', '--verify', '--quiet', baseBranch)
214
+ .then(() => true)
215
+ .catch(() => false);
216
+ if (exists)
217
+ return baseBranch;
218
+ throw new Error(`base branch ${baseBranch} is not in ${workspacePath}`);
219
+ }
220
+ return 'HEAD';
221
+ }
222
+ /** Which worktree, if any, currently has `branch` checked out. */
223
+ async function worktreeHolding(workspacePath, branch) {
224
+ const raw = await git(workspacePath, 'worktree', 'list', '--porcelain').catch(() => '');
225
+ let current = null;
226
+ for (const line of raw.split('\n')) {
227
+ if (line.startsWith('worktree '))
228
+ current = line.slice('worktree '.length).trim();
229
+ else if (line.startsWith('branch ') && current) {
230
+ if (line
231
+ .slice('branch '.length)
232
+ .trim()
233
+ .replace(/^refs\/heads\//, '') === branch) {
234
+ return current;
235
+ }
121
236
  }
122
- await git(workspacePath, 'worktree', 'add', '-b', branch, worktreePath, 'HEAD');
123
237
  }
124
- return { branch, worktreePath };
238
+ return null;
239
+ }
240
+ // ─── Preview worktree (session 14) ───────────────────────────────────
241
+ /**
242
+ * One preview checkout per repository, and never the project folder itself.
243
+ *
244
+ * The honest constraint behind «show me branch B while branch A is running»:
245
+ * the docker build context IS the project folder, so a rebuild there replaces
246
+ * the single running copy. A second worktree is the only way to have both — and
247
+ * the runner NEVER switches the branch in the project folder, because that
248
+ * silently moves the base, and the target of «Apply», for every session of the
249
+ * project at once.
250
+ *
251
+ * Checked out DETACHED at a sha rather than on the branch: git refuses to have
252
+ * one branch checked out twice, and a preview is a snapshot of a commit, not a
253
+ * place anybody commits.
254
+ */
255
+ export function previewWorktreePath(workspaceKey) {
256
+ const safe = workspaceKey.replace(/[^A-Za-z0-9_-]/g, '-').slice(-60) || 'preview';
257
+ return path.join(previewsDir(), safe);
258
+ }
259
+ export async function ensurePreviewWorktree(input) {
260
+ const branch = sanitizeBranch(input.branch);
261
+ if (!branch)
262
+ throw new Error('Invalid branch name');
263
+ const sha = await git(input.workspacePath, 'rev-parse', `${branch}^{commit}`).catch(() => null);
264
+ if (!sha)
265
+ throw new Error(`Branch ${branch} is not in this repository`);
266
+ const worktreePath = previewWorktreePath(input.workspaceKey);
267
+ fs.mkdirSync(previewsDir(), { recursive: true, mode: 0o700 });
268
+ await git(input.workspacePath, 'worktree', 'prune').catch(() => undefined);
269
+ if (fs.existsSync(path.join(worktreePath, '.git'))) {
270
+ // Reuse the slot: a preview is a place, not a history. `--detach` keeps it
271
+ // out of the branch namespace, so moving it cannot collide with a session.
272
+ await git(worktreePath, 'checkout', '--detach', sha);
273
+ await git(worktreePath, 'reset', '--hard', sha);
274
+ await git(worktreePath, 'clean', '-fd').catch(() => undefined);
275
+ return { worktreePath, branch, sha };
276
+ }
277
+ await git(input.workspacePath, 'worktree', 'add', '--detach', worktreePath, sha);
278
+ return { worktreePath, branch, sha };
279
+ }
280
+ /** Give the slot back. The branch is untouched — it was never checked out. */
281
+ export async function removePreviewWorktree(workspaceKey) {
282
+ const worktreePath = previewWorktreePath(workspaceKey);
283
+ if (!fs.existsSync(worktreePath))
284
+ return false;
285
+ const gitFile = path.join(worktreePath, '.git');
286
+ let mainRepo = null;
287
+ if (fs.existsSync(gitFile) && fs.statSync(gitFile).isFile()) {
288
+ const pointer = fs
289
+ .readFileSync(gitFile, 'utf8')
290
+ .match(/^gitdir:\s*(.+)$/m)?.[1]
291
+ ?.trim();
292
+ if (pointer) {
293
+ const dotGit = path.resolve(pointer, '..', '..');
294
+ if (path.basename(dotGit) === '.git')
295
+ mainRepo = path.dirname(dotGit);
296
+ }
297
+ }
298
+ if (mainRepo) {
299
+ await git(mainRepo, 'worktree', 'remove', '--force', worktreePath);
300
+ }
301
+ else {
302
+ fs.rmSync(worktreePath, { recursive: true, force: true });
303
+ }
304
+ return true;
125
305
  }
126
306
  /**
127
307
  * Drop a session branch after its worktree is gone. Only ever called when the
@@ -133,6 +313,23 @@ export async function deleteSessionBranch(workspacePath, branch) {
133
313
  const safe = sanitizeBranch(branch);
134
314
  if (!safe)
135
315
  throw new Error(`refusing to delete an unsafe branch name: ${branch}`);
316
+ // Session 16: a DIRECT session's «session branch» is the branch the PERSON is
317
+ // working on, and deleting a session must never be able to delete that. The
318
+ // API already refuses to ask, and git itself refuses to drop a branch that is
319
+ // checked out — but this is the line that would run the command, so it is the
320
+ // line that checks. Three cheap reads against an operation with no undo.
321
+ // The worktree this branch lived in was removed a moment ago; without a prune
322
+ // its stale registration would answer «still checked out» and leave a branch
323
+ // behind on every ordinary purge.
324
+ await git(workspacePath, 'worktree', 'prune').catch(() => undefined);
325
+ const current = await git(workspacePath, 'rev-parse', '--abbrev-ref', 'HEAD').catch(() => null);
326
+ if (current === safe) {
327
+ throw new Error(`refusing to delete ${safe}: the project folder is on it`);
328
+ }
329
+ const holder = await worktreeHolding(workspacePath, safe);
330
+ if (holder) {
331
+ throw new Error(`refusing to delete ${safe}: it is checked out in ${holder}`);
332
+ }
136
333
  await git(workspacePath, 'branch', '-D', safe);
137
334
  }
138
335
  /**