@bridge4dev/runner 0.13.1 → 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.
- package/dist/adapters/claude.d.ts +15 -7
- package/dist/adapters/claude.js +1024 -70
- package/dist/adapters/codex.d.ts +18 -3
- package/dist/adapters/codex.js +224 -65
- package/dist/adapters/questions.d.ts +42 -0
- package/dist/adapters/questions.js +86 -0
- package/dist/adapters/types.d.ts +200 -4
- package/dist/attachments.d.ts +8 -1
- package/dist/attachments.js +22 -4
- package/dist/auto-resume.d.ts +18 -0
- package/dist/auto-resume.js +104 -0
- package/dist/commit-message.d.ts +51 -0
- package/dist/commit-message.js +224 -0
- package/dist/config.d.ts +29 -6
- package/dist/config.js +15 -0
- package/dist/crash-note.d.ts +54 -0
- package/dist/crash-note.js +105 -0
- package/dist/git.d.ts +71 -0
- package/dist/git.js +207 -10
- package/dist/gitops.d.ts +489 -12
- package/dist/gitops.js +1717 -96
- package/dist/index.js +402 -4
- package/dist/paths.d.ts +26 -0
- package/dist/paths.js +34 -0
- package/dist/policy.d.ts +63 -0
- package/dist/policy.js +412 -10
- package/dist/protocol.d.ts +382 -60
- package/dist/protocol.js +104 -1
- package/dist/recipe-schema.d.ts +310 -0
- package/dist/recipe-schema.js +103 -0
- package/dist/recipe.d.ts +94 -0
- package/dist/recipe.js +238 -0
- package/dist/self-update.d.ts +7 -0
- package/dist/self-update.js +28 -1
- package/dist/service-unit.d.ts +48 -1
- package/dist/service-unit.js +109 -4
- package/dist/supervisor.d.ts +108 -1
- package/dist/supervisor.js +1010 -56
- package/dist/verify-queue.d.ts +17 -0
- package/dist/verify-queue.js +100 -0
- package/dist/verify.d.ts +203 -0
- package/dist/verify.js +788 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
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
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
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
|
/**
|