bareloop 0.8.0 → 0.10.0
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/CHANGELOG.md +625 -0
- package/README.md +101 -110
- package/bareloop.context.md +333 -25
- package/package.json +1 -1
- package/src/authorflow.js +1447 -0
- package/src/authoring.js +1486 -0
- package/src/authorjob.js +801 -0
- package/src/authorscout.js +767 -0
- package/src/bridges.js +16 -3
- package/src/declaredclose.js +519 -0
- package/src/index.js +75 -0
- package/src/job.js +48 -4
- package/src/kinds.js +1445 -0
- package/src/ledger.js +18 -1
- package/src/plan.js +26 -1
- package/src/planrun.js +448 -40
- package/src/ralph.js +24 -4
- package/src/reuse.js +120 -9
- package/src/root.js +8 -1
- package/src/run.js +12 -4
- package/src/text.js +199 -0
- package/src/tools.js +11 -0
- package/src/validate.js +168 -11
- package/src/workbranch.js +261 -0
- package/types/authorflow.d.ts +764 -0
- package/types/authoring.d.ts +559 -0
- package/types/authorjob.d.ts +242 -0
- package/types/authorscout.d.ts +320 -0
- package/types/declaredclose.d.ts +174 -0
- package/types/index.d.ts +9 -1
- package/types/job.d.ts +1 -0
- package/types/kinds.d.ts +395 -0
- package/types/plan.d.ts +18 -0
- package/types/planrun.d.ts +53 -3
- package/types/ralph.d.ts +1 -0
- package/types/reuse.d.ts +1 -1
- package/types/run.d.ts +10 -2
- package/types/text.d.ts +151 -0
- package/types/validate.d.ts +74 -9
- package/types/workbranch.d.ts +102 -0
package/src/validate.js
CHANGED
|
@@ -177,6 +177,69 @@ function unboundedQuantLen(src, i) {
|
|
|
177
177
|
return len;
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
+
/** How many characters of `(`-prologue sit at `src[i]` before the group's BODY:
|
|
181
|
+
* 1 for a plain or capturing group, 3 for `(?:` `(?=` `(?!`, 4 for `(?<=` `(?<!`,
|
|
182
|
+
* and past the `>` of a named group `(?<name>`. The body is read back when the
|
|
183
|
+
* group closes, and a prologue counted as body text would make `(?:a|aa)` split
|
|
184
|
+
* as `?:a` / `aa` — no prefix relation, and the hazard read as safe.
|
|
185
|
+
* @param {string} src @param {number} i index of the `(` */
|
|
186
|
+
function groupPrologueLen(src, i) {
|
|
187
|
+
if (src[i + 1] !== '?') return 1;
|
|
188
|
+
const c = src[i + 2];
|
|
189
|
+
if (c === ':' || c === '=' || c === '!') return 3;
|
|
190
|
+
if (c === '<') {
|
|
191
|
+
if (src[i + 3] === '=' || src[i + 3] === '!') return 4; // lookbehind
|
|
192
|
+
const close = src.indexOf('>', i + 3); // named group
|
|
193
|
+
return close === -1 ? 3 : close - i + 1;
|
|
194
|
+
}
|
|
195
|
+
return 1; // modifier groups etc — read the lot as body (fail-safe)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Do a group body's TOP-LEVEL alternation branches overlap — is one a prefix of
|
|
200
|
+
* another, equality included? That is the practical detectable core of the
|
|
201
|
+
* overlapping-alternation blowup (`(a|aa)`, `(a|a)`, `(\d|\d\d)`, `(a|a?)`):
|
|
202
|
+
* two branches that can consume the same text give the engine two ways to reach
|
|
203
|
+
* the same position, and an unbounded repeat multiplies them.
|
|
204
|
+
*
|
|
205
|
+
* The comparison is over the branches' SOURCE TEXT, which is what makes it
|
|
206
|
+
* conservative rather than exact — `(ab|abc)+` carries the prefix relation and
|
|
207
|
+
* did not blow up on the body measured. Exactness needs the real regex engine
|
|
208
|
+
* F49 declined to take as a dependency, and guessing it wrong would ADMIT an
|
|
209
|
+
* exponential pattern, so the over-rejection stands as a named limit.
|
|
210
|
+
*
|
|
211
|
+
* Splitting respects escapes, character classes and nested groups: a `|` inside
|
|
212
|
+
* `[a|b]` or inside `(x|y)` is not this group's alternation, and treating it as
|
|
213
|
+
* one would invent branches nobody wrote.
|
|
214
|
+
* @param {string} body the text between a group's prologue and its `)`
|
|
215
|
+
* @returns {boolean}
|
|
216
|
+
*/
|
|
217
|
+
function altBranchesOverlap(body) {
|
|
218
|
+
/** @type {string[]} */
|
|
219
|
+
const branches = [];
|
|
220
|
+
let start = 0;
|
|
221
|
+
let depth = 0;
|
|
222
|
+
for (let i = 0; i < body.length; i++) {
|
|
223
|
+
const c = body[i];
|
|
224
|
+
if (c === '\\') { i++; continue; }
|
|
225
|
+
if (c === '[') { i++; if (body[i] === '^') i++; while (i < body.length && body[i] !== ']') { if (body[i] === '\\') i++; i++; } continue; }
|
|
226
|
+
if (c === '(') { depth++; continue; }
|
|
227
|
+
if (c === ')') { depth--; continue; }
|
|
228
|
+
if (c === '|' && depth === 0) { branches.push(body.slice(start, i)); start = i + 1; }
|
|
229
|
+
}
|
|
230
|
+
if (branches.length === 0) return false; // no top-level alternation at all
|
|
231
|
+
branches.push(body.slice(start));
|
|
232
|
+
for (let i = 0; i < branches.length; i++) {
|
|
233
|
+
for (let j = 0; j < branches.length; j++) {
|
|
234
|
+
// `startsWith` covers EQUALITY, which is the 2ⁿ case: `(a|a)+$` took 52.3s
|
|
235
|
+
// on a 27-character body. Skipping i===j is what keeps that from being
|
|
236
|
+
// every pattern's own reflexive match.
|
|
237
|
+
if (i !== j && branches[j].startsWith(branches[i])) return true;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
|
|
180
243
|
/**
|
|
181
244
|
* F49 — a heuristic reject for the catastrophic-backtracking footgun: an
|
|
182
245
|
* unbounded quantifier applied to a group whose body itself repeats unboundedly
|
|
@@ -201,16 +264,40 @@ function unboundedQuantLen(src, i) {
|
|
|
201
264
|
* So the reject targets the dominant exponential class and fails it as a
|
|
202
265
|
* mechanical gap at the validation gate, before any tokens burn. A full ReDoS
|
|
203
266
|
* analyzer needs a real regex engine (an external native dep we do not take for
|
|
204
|
-
* a LOW issue)
|
|
205
|
-
*
|
|
267
|
+
* a LOW issue).
|
|
268
|
+
*
|
|
269
|
+
* SECOND CLASS, added 2026-08-12 — OVERLAPPING ALTERNATION under an unbounded
|
|
270
|
+
* repeat (`(a|aa)+`, `(a|a)+`, `(\d|\d\d)*`). F49 recorded this as a genuine
|
|
271
|
+
* false NEGATIVE of the shape approach and PARKED the widening, because changing
|
|
272
|
+
* the detector changes which SIGNED specs are admissible. It is closed here on
|
|
273
|
+
* measurement, one fresh process per point (a hot RegExp tiers up out of the V8
|
|
274
|
+
* interpreter, and a single-process sweep read a LONGER body as faster —
|
|
275
|
+
* backwards for an exponential, and the harness rather than the regex).
|
|
276
|
+
* `(a|aa)+$` against a run of `a` with one non-matching tail character:
|
|
277
|
+
*
|
|
278
|
+
* 27ch 218ms · 31ch 1,879ms · 35ch 12,082ms · 39ch 52,563ms · 41ch 189,420ms
|
|
279
|
+
*
|
|
280
|
+
* ~6.8× per 4 characters — the Fibonacci decomposition count, φ⁴. `(a|a)+$` (the
|
|
281
|
+
* 2ⁿ equal-branch case) took 52,256ms on 27ch, and `((a|aa))+$` — the redundant
|
|
282
|
+
* wrapper the `((a+))+` entry above exists for — 15,426ms on 35ch, which is why
|
|
283
|
+
* the overlap propagates up through a wrapper exactly as the inner repeat does.
|
|
284
|
+
* Input-bounding is theater here for F49's own reason: tens of characters suffice.
|
|
285
|
+
*
|
|
286
|
+
* The detected core is the PREFIX RELATION between top-level branches (one branch
|
|
287
|
+
* a prefix of another, equality included — `(a|a)+` is the 2ⁿ case). Whether a
|
|
288
|
+
* given prefix overlap can actually be DRIVEN exponentially depends on what
|
|
289
|
+
* follows the group, and deciding that needs the real engine this file declined
|
|
290
|
+
* to take as a dependency — so the reject is conservative by construction, which
|
|
291
|
+
* is the fail-safe direction.
|
|
292
|
+
*
|
|
293
|
+
* MONOTONIC, and that is the rule this change lives under: it only ever ADDS
|
|
294
|
+
* rejections. Nothing above is narrowed, and no previously-rejected shape became
|
|
295
|
+
* admissible (asserted over the whole corpus in tests/plan.test.js). F49's
|
|
296
|
+
* "never sharpen the detector" bars chasing false POSITIVES; closing a false
|
|
297
|
+
* NEGATIVE by adding rejections is the opposite, allowed direction — distinct
|
|
298
|
+
* rules, not in tension (the `((a+))+` widening set the precedent).
|
|
206
299
|
*
|
|
207
300
|
* SCOPE IS ASYMMETRIC, both directions named on purpose:
|
|
208
|
-
* - false NEGATIVE: overlapping-alternation blowup (`(a|ab)+`-class) is not
|
|
209
|
-
* detected — there is no inner QUANTIFIER to find, so the shape scan cannot
|
|
210
|
-
* see it; the blowup comes from overlapping alternation BRANCHES under a
|
|
211
|
-
* repeat. A genuine limit of the approach, not a bug in it. Widening the
|
|
212
|
-
* detector changes which SIGNED specs are admissible, which is
|
|
213
|
-
* arbiter-adjacent — parked, not chased (PRD v1.55).
|
|
214
301
|
* - false POSITIVE: a group whose repetitions are disambiguated by a literal
|
|
215
302
|
* anchor/delimiter (`(?:^- .+$\n?)+`, `(?:CHANGELOG:.+\n)+`) is FLAGGED even
|
|
216
303
|
* though a real engine runs it linearly — the scan sees the nested-quantifier
|
|
@@ -221,13 +308,28 @@ function unboundedQuantLen(src, i) {
|
|
|
221
308
|
* and guessing it wrong would ADMIT an exponential pattern — so the shape
|
|
222
309
|
* reject stands, and the over-rejection is a named, accepted limitation.
|
|
223
310
|
*
|
|
311
|
+
* The alternation class has its OWN instance of the same limit, measured and
|
|
312
|
+
* locked in `REDOS_OVERREJECTED_ALT`: `(ab|abc)+$`, `(x|xy|xyz)+$` and
|
|
313
|
+
* `(x(a|aa))+$` carry the prefix relation and did NOT blow up on the bodies
|
|
314
|
+
* measured (0.3–0.4ms at 41–91 characters). "Did not blow up on the body
|
|
315
|
+
* measured" is the honest claim — no adversarial search was run for a worse
|
|
316
|
+
* input — and rejecting them is again the direction that never admits an
|
|
317
|
+
* exponential pattern.
|
|
318
|
+
*
|
|
224
319
|
* The input is guaranteed to compile as a RegExp (checked first by the caller),
|
|
225
320
|
* so this scan assumes valid, balanced JS regex syntax.
|
|
226
321
|
* @param {string} src a compiled regex source string
|
|
227
|
-
* @returns {boolean} true iff a nested unbounded quantifier
|
|
322
|
+
* @returns {boolean} true iff a nested unbounded quantifier, or an unboundedly
|
|
323
|
+
* repeated group with overlapping alternation branches, is present
|
|
228
324
|
*/
|
|
229
325
|
export function hasNestedQuantifier(src) {
|
|
230
|
-
/**
|
|
326
|
+
/** `quant`: the body repeats unboundedly. `alt`: the body (or a group beneath
|
|
327
|
+
* it) carries overlapping alternation branches. `at`: where the body starts,
|
|
328
|
+
* so the branches can be read back when the group closes. The two hazard flags
|
|
329
|
+
* are tracked separately and propagate separately — merging them would let a
|
|
330
|
+
* plain nested repeat be reported as an alternation overlap and vice versa,
|
|
331
|
+
* and the two are different rewrites for the author. */
|
|
332
|
+
/** @type {{ quant: boolean, alt: boolean, at: number }[]} */
|
|
231
333
|
const stack = [];
|
|
232
334
|
for (let i = 0; i < src.length; i++) {
|
|
233
335
|
const c = src[i];
|
|
@@ -254,11 +356,19 @@ export function hasNestedQuantifier(src) {
|
|
|
254
356
|
while (i < src.length && src[i] !== ']') { if (src[i] === '\\') i++; i++; }
|
|
255
357
|
continue;
|
|
256
358
|
}
|
|
257
|
-
if (c === '(') { stack.push({ quant: false }); continue; }
|
|
359
|
+
if (c === '(') { stack.push({ quant: false, alt: false, at: i + groupPrologueLen(src, i) }); continue; }
|
|
258
360
|
if (c === ')') {
|
|
259
361
|
const g = stack.pop();
|
|
260
362
|
const qlen = unboundedQuantLen(src, i + 1);
|
|
261
363
|
if (g && g.quant && qlen) return true; // group repeats unboundedly AND its body did too
|
|
364
|
+
// …and the SECOND class: the body's own top-level branches overlap, so the
|
|
365
|
+
// engine has more than one way to consume the same text and an unbounded
|
|
366
|
+
// repeat multiplies those ways (MEASURED: `(a|aa)+$` 12.1s on a 35-char
|
|
367
|
+
// body). `g.alt` carries an overlap found in a group BENEATH this one, for
|
|
368
|
+
// the redundant-wrapper reason one comment down.
|
|
369
|
+
const overlap = !!g && (g.alt || altBranchesOverlap(src.slice(g.at, i)));
|
|
370
|
+
if (overlap && qlen) return true;
|
|
371
|
+
if (overlap && stack.length) stack[stack.length - 1].alt = true;
|
|
262
372
|
// The group is an unbounded-repeated atom of its parent when it is directly
|
|
263
373
|
// re-quantified (qlen) OR its own body already repeats unboundedly (g.quant):
|
|
264
374
|
// a redundant wrapper — ((a+))+ , (?:(a+))* , (((\d*)))+ — is the SAME
|
|
@@ -276,3 +386,50 @@ export function hasNestedQuantifier(src) {
|
|
|
276
386
|
}
|
|
277
387
|
return false;
|
|
278
388
|
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* The catastrophic-backtracking reject, applied to a WHOLE signed document by
|
|
392
|
+
* tree walk — `sweepSecretLiterals`'s twin, and for its reason: some signed
|
|
393
|
+
* documents carry their regexes at a depth no per-field check enumerates.
|
|
394
|
+
*
|
|
395
|
+
* WHY IT EXISTS (PRD v1.60 §1, an OPEN GAP now closed). v1.55 took F49's reject
|
|
396
|
+
* to the operator's own regex fields, and the admissibility sweep that backed it
|
|
397
|
+
* walked `jobs/*.json`. That is not the population of signed specs: a REGISTRY
|
|
398
|
+
* BRIDGE is signed the same way, ships its plans VERBATIM (reuse stores the
|
|
399
|
+
* whole plan, never a template), and lives outside `jobs/`, so a pathological
|
|
400
|
+
* pattern arriving through a bridge was never swept. Nothing had decided
|
|
401
|
+
* bridge-borne patterns were safe — the sweep was written against the directory
|
|
402
|
+
* that held every spec at the time and was never re-aimed.
|
|
403
|
+
*
|
|
404
|
+
* WHAT is rejected is UNCHANGED: `hasNestedQuantifier`, one inventory, shared
|
|
405
|
+
* with job.js and plan.js. Only WHERE it looks is wider. Widening the DETECTOR
|
|
406
|
+
* is arbiter-adjacent and stays parked (F49); widening the POPULATION is this.
|
|
407
|
+
*
|
|
408
|
+
* KEY-SCOPED, deliberately. `pattern` and `gapKeep` are the exactly-three regex
|
|
409
|
+
* fields F49 names (`artifact-written.pattern`, `judged.pattern`, `gapKeep`);
|
|
410
|
+
* every other string in a signed document is prose, a path or a command, and
|
|
411
|
+
* running a regex-shape scan over prose would red goals for containing `(a+)+`
|
|
412
|
+
* in a sentence. A field carrying a regex under a NEW key is not swept until
|
|
413
|
+
* this list names it — stated so the limit is read rather than rediscovered.
|
|
414
|
+
*
|
|
415
|
+
* COMPILE-FIRST, like both per-field callers: the scan's contract is that its
|
|
416
|
+
* input is valid regex syntax, and a string that does not compile is the OTHER
|
|
417
|
+
* validator's red. Two reds on one string bury the real one.
|
|
418
|
+
* @param {any} root
|
|
419
|
+
* @param {(code: string, path: string, detail?: string) => void} red
|
|
420
|
+
*/
|
|
421
|
+
export function sweepNestedQuantifiers(root, red) {
|
|
422
|
+
(/** @type {(node: any, at: string, key: string) => void} */
|
|
423
|
+
function sweep(node, at, key) {
|
|
424
|
+
if (typeof node === 'string') {
|
|
425
|
+
if (key !== 'pattern' && key !== 'gapKeep') return;
|
|
426
|
+
try { new RegExp(node, 'm'); } catch { return; }
|
|
427
|
+
if (hasNestedQuantifier(node)) {
|
|
428
|
+
red('invalid-value', at, `catastrophic-backtracking risk: a nested unbounded quantifier (${node}) can hang the untimed evaluator on the MAIN event loop, where the in-process stall fuse cannot fire (F49). Rewrite the pattern — drop the outer repeat, or match once.`);
|
|
429
|
+
}
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
if (Array.isArray(node)) node.forEach((v, i) => sweep(v, `${at}.${i}`, key));
|
|
433
|
+
else if (isObj(node)) for (const [k, v] of Object.entries(node)) sweep(v, at ? `${at}.${k}` : k, k);
|
|
434
|
+
})(root, '', '');
|
|
435
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// THE WORK BRANCH — runner mechanics, arbiter side. PRD Addendum v1.57 §3,
|
|
2
|
+
// hamr's ruling verbatim:
|
|
3
|
+
//
|
|
4
|
+
// "The agent creates a NEW BRANCH before it touches any code — a HARD RULE, no
|
|
5
|
+
// exceptions. Not a default, not a preference: no job edits the branch it was
|
|
6
|
+
// handed, and none edits `main`. Blast radius is bounded by where the work
|
|
7
|
+
// lands, not only by what the fence allows. Named with a meaningful slug —
|
|
8
|
+
// tool and problem, human-readable, of the form `bareloop-typecheck-fix`. A
|
|
9
|
+
// human scanning `git branch` must be able to tell what the run was for
|
|
10
|
+
// without opening the spec, which a run-id slug does not give them."
|
|
11
|
+
//
|
|
12
|
+
// WHAT IT IS AND IS NOT, stated here rather than left to be inferred (v1.57's own
|
|
13
|
+
// known-limitations discipline). bareloop never commits: the run leaves its work
|
|
14
|
+
// in the tree. So the guarantee this module actually delivers is precise —
|
|
15
|
+
//
|
|
16
|
+
// * the run's tree is standing on a branch NOBODY handed it, from the first
|
|
17
|
+
// moment a write-capable worker can exist to the end of the run, and
|
|
18
|
+
// * the handed ref (`main`, or whatever was checked out) is never moved,
|
|
19
|
+
// never written through, and never the thing a later commit attaches to.
|
|
20
|
+
//
|
|
21
|
+
// It is NOT containment: v1.41's local-trust model stands untouched, and a close
|
|
22
|
+
// or worker child can still reach anything the operator's OS user can reach. The
|
|
23
|
+
// branch bounds where the work LANDS. That is the whole claim.
|
|
24
|
+
//
|
|
25
|
+
// THE NAME IS OURS, NEVER THE MODEL'S. A branch name is arbiter bookkeeping: it
|
|
26
|
+
// says which history a run's blast radius belongs to. A model-authored one would
|
|
27
|
+
// be the agent authoring the arbiter's own books, so the name is DERIVED,
|
|
28
|
+
// mechanically and deterministically, from the SIGNED spec's own `job` field
|
|
29
|
+
// (see `workBranchName`).
|
|
30
|
+
//
|
|
31
|
+
// WHY THIS FILE OWNS ITS OWN git SEAM. `src/kinds.js` has one too, and its
|
|
32
|
+
// docstring says what it is: "git, read-only, in a repository" — the CLOSE's
|
|
33
|
+
// measuring instrument, whose faults are close faults. This one MUTATES the
|
|
34
|
+
// repository (it creates and checks out a ref) and its faults are runner
|
|
35
|
+
// instrument stops before a single token is bought. Two verbs, two failure
|
|
36
|
+
// vocabularies, two owners; the read-only one is not exported and could not be
|
|
37
|
+
// shared without widening it into a write seam.
|
|
38
|
+
|
|
39
|
+
import { execFile } from 'node:child_process';
|
|
40
|
+
import { promisify } from 'node:util';
|
|
41
|
+
|
|
42
|
+
const exec = promisify(execFile);
|
|
43
|
+
|
|
44
|
+
/** Every work branch carries it. It is what makes `main`, `master` and every
|
|
45
|
+
* branch a human could plausibly have handed the run INEXPRESSIBLE as a work
|
|
46
|
+
* branch name — a structural guarantee rather than a denylist somebody has to
|
|
47
|
+
* keep current. */
|
|
48
|
+
export const WORK_BRANCH_PREFIX = 'bareloop-';
|
|
49
|
+
|
|
50
|
+
/** The full legal shape of a work branch. Deliberately narrower than git's own
|
|
51
|
+
* `check-ref-format`: the kebab alphabet the job validator already holds every
|
|
52
|
+
* spec slug to (`src/job.js` SLUG_RE), plus the prefix. Everything git forbids —
|
|
53
|
+
* `..`, `@{`, a trailing `.lock`, a leading `-`, a `/` component, control
|
|
54
|
+
* characters — is unreachable inside it, so ref-safety is a property of the
|
|
55
|
+
* alphabet and never a sanitiser somebody has to remember to call. */
|
|
56
|
+
export const WORK_BRANCH_RE = /^bareloop-[a-z0-9][a-z0-9-]*$/;
|
|
57
|
+
|
|
58
|
+
/** The slug's ceiling, before any collision suffix. A ref component has a
|
|
59
|
+
* filesystem-length limit and a human scanning `git branch` has a much smaller
|
|
60
|
+
* one; 60 characters is longer than every job slug in `jobs/` and short enough
|
|
61
|
+
* to read in a column. */
|
|
62
|
+
export const MAX_SLUG_LEN = 60;
|
|
63
|
+
|
|
64
|
+
/** How far the collision walk goes before it STOPS and says so. A run that
|
|
65
|
+
* cannot find a free name in this many attempts is looking at a patient nobody
|
|
66
|
+
* has swept in a very long time, and silently walking to `-500` would be the
|
|
67
|
+
* runner papering over an operator problem. */
|
|
68
|
+
export const MAX_BRANCH_COLLISIONS = 50;
|
|
69
|
+
|
|
70
|
+
/** git, in a repository, WRITE-CLASS. A non-zero exit is a value, never a throw:
|
|
71
|
+
* every caller here decides for itself whether git refusing is a stop.
|
|
72
|
+
* @param {string} cwd @param {string[]} args
|
|
73
|
+
* @returns {Promise<{ok: boolean, out: string, err: string}>}
|
|
74
|
+
*/
|
|
75
|
+
async function git(cwd, args) {
|
|
76
|
+
try {
|
|
77
|
+
const { stdout, stderr } = await exec('git', args, {
|
|
78
|
+
cwd, encoding: 'utf8', timeout: 120_000, maxBuffer: 1024 * 1024,
|
|
79
|
+
});
|
|
80
|
+
return { ok: true, out: String(stdout ?? ''), err: String(stderr ?? '') };
|
|
81
|
+
} catch (e) {
|
|
82
|
+
const err = /** @type {any} */ (e);
|
|
83
|
+
// A spawn failure (no git on PATH, cwd does not exist) has no streams at all;
|
|
84
|
+
// its `message` is the only thing there is to report, and reporting nothing
|
|
85
|
+
// would turn "git is not installed" into a mysterious silent refusal.
|
|
86
|
+
const detail = String(err?.stderr ?? '').trim() || String(err?.message ?? err).trim();
|
|
87
|
+
return { ok: false, out: String(err?.stdout ?? ''), err: detail };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The spec's own words, reduced to the kebab alphabet. Lossy on purpose: the
|
|
93
|
+
* output alphabet IS the ref-safety guarantee, so anything outside it collapses
|
|
94
|
+
* to a separator rather than being escaped into something clever.
|
|
95
|
+
* @param {unknown} s
|
|
96
|
+
* @returns {string} `''` when nothing usable survives
|
|
97
|
+
*/
|
|
98
|
+
function slugify(s) {
|
|
99
|
+
return String(s ?? '')
|
|
100
|
+
.toLowerCase()
|
|
101
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
102
|
+
.replace(/^-+/, '')
|
|
103
|
+
.slice(0, MAX_SLUG_LEN)
|
|
104
|
+
.replace(/-+$/, '');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The branch name a job's run works on, DERIVED from the signed spec.
|
|
109
|
+
*
|
|
110
|
+
* **The source is `spec.job`, and the choice is load-bearing.** It is the one
|
|
111
|
+
* human-authored, REQUIRED, kebab-case name the spec carries; it is inside the
|
|
112
|
+
* spec hash, so the branch name cannot drift from what the operator signed; and
|
|
113
|
+
* it is stable across a resume and across a cold re-run of the same spec, which
|
|
114
|
+
* is precisely why the collision walk below has to exist. In practice it already
|
|
115
|
+
* reads as tool-and-problem (`bareagent-u-types`, `litectx-u-types`), which is
|
|
116
|
+
* the shape hamr's ruling asks for.
|
|
117
|
+
*
|
|
118
|
+
* **The goal prose was REJECTED as a source**, and not for tidiness: a goal is
|
|
119
|
+
* long, is edited freely between runs without changing what the job IS, and
|
|
120
|
+
* opens on boilerplate ("An existing pytest test suite for the SOAR
|
|
121
|
+
* orchestrator…"). Deriving from it would make the branch name unstable under
|
|
122
|
+
* edits that change nothing, and unreadable when it was stable. A run id was
|
|
123
|
+
* rejected by the ruling itself.
|
|
124
|
+
*
|
|
125
|
+
* Deterministic, pure, and total: any input yields a name matching
|
|
126
|
+
* `WORK_BRANCH_RE`, so a caller never has to check.
|
|
127
|
+
* @param {any} spec a job-v1 spec (validated or raw)
|
|
128
|
+
* @returns {string}
|
|
129
|
+
*/
|
|
130
|
+
export function workBranchName(spec) {
|
|
131
|
+
const slug = slugify(spec?.job);
|
|
132
|
+
// `job` is REQUIRED and kebab-validated by `validateJob`, which runs before the
|
|
133
|
+
// runner ever reaches here — so the fallback is unreachable through `runJob`.
|
|
134
|
+
// It exists because this function is exported and total: a name is the wrong
|
|
135
|
+
// thing to throw about, and `bareloop-job` is honest ("a bareloop job") rather
|
|
136
|
+
// than invented.
|
|
137
|
+
return `${WORK_BRANCH_PREFIX}${slug || 'job'}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** does a local branch of this name exist? @param {string} cwd @param {string} name */
|
|
141
|
+
async function branchExists(cwd, name) {
|
|
142
|
+
const r = await git(cwd, ['show-ref', '--verify', '--quiet', `refs/heads/${name}`]);
|
|
143
|
+
return r.ok;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Put the run on its OWN branch, before it can touch any code.
|
|
148
|
+
*
|
|
149
|
+
* The three shapes, and each one's honest answer:
|
|
150
|
+
*
|
|
151
|
+
* - **Cold.** `name` is free → create it at HEAD and switch. The handed branch
|
|
152
|
+
* keeps its ref and its history; a new branch at HEAD moves no commit, so this
|
|
153
|
+
* costs the patient nothing and changes nothing it can measure (the declared
|
|
154
|
+
* close's seed is HEAD either way).
|
|
155
|
+
* - **Cold, and the name is taken.** Suffix `-2`, `-3`… until one is free.
|
|
156
|
+
* Deterministic given the repository, and it NEVER reuses another run's
|
|
157
|
+
* branch — a second run of the same spec is a second blast radius.
|
|
158
|
+
* - **Resume.** `resume` names the branch the killed leg recorded on its own
|
|
159
|
+
* spine. It is checked out, never re-created and never suffixed: the work the
|
|
160
|
+
* operator already paid for is sitting on it, and minting `-2` beside it would
|
|
161
|
+
* strand exactly the progress a resume exists to keep. A `resume` branch that
|
|
162
|
+
* is GONE is a STOP, never a silent fresh start — the run would be grading a
|
|
163
|
+
* tree whose history it does not own.
|
|
164
|
+
*
|
|
165
|
+
* **Every failure is a stop with a name.** Not a git repository, no git on PATH,
|
|
166
|
+
* a checkout git refuses, a crowded namespace, a resume branch a human deleted:
|
|
167
|
+
* each comes back as `stop` and `branch: null`. There is no fallback to "just
|
|
168
|
+
* work on whatever was handed to us" anywhere in this function, because that
|
|
169
|
+
* fallback IS the thing the hard rule forbids.
|
|
170
|
+
*
|
|
171
|
+
* A DETACHED head is not a failure. Creating a branch from it is exactly the
|
|
172
|
+
* bounding the rule asks for (and it attaches a head that was floating), so it
|
|
173
|
+
* succeeds with `from: null` — there was no branch to be handed, and the record
|
|
174
|
+
* says so rather than inventing one.
|
|
175
|
+
*
|
|
176
|
+
* @param {string} workdir the patient (the fence's root)
|
|
177
|
+
* @param {object} o
|
|
178
|
+
* @param {string} o.name the derived name (`workBranchName`); refused unless it
|
|
179
|
+
* matches `WORK_BRANCH_RE`, which is what makes `main` unreachable even from a
|
|
180
|
+
* hand-passed argument
|
|
181
|
+
* @param {string|null} [o.resume] RESUME — the branch this run already owns
|
|
182
|
+
* @returns {Promise<{stop: string, branch: null}
|
|
183
|
+
* | {stop: null, branch: string, created: boolean, resumed: boolean, from: string|null, base: string|null,
|
|
184
|
+
* repo: string, collided: number}>}
|
|
185
|
+
*/
|
|
186
|
+
export async function prepareWorkBranch(workdir, { name, resume = null }) {
|
|
187
|
+
const wanted = String(name ?? '');
|
|
188
|
+
if (!WORK_BRANCH_RE.test(wanted)) {
|
|
189
|
+
return { stop: `the work branch name ${JSON.stringify(wanted)} is not a bareloop work branch (${WORK_BRANCH_RE.source}) — a run never works on a branch it was handed`, branch: null };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Is there a repository at all? Asked FIRST and by its own question, so "this
|
|
193
|
+
// patient is not a git checkout" reads as itself instead of arriving later
|
|
194
|
+
// disguised as a checkout failure.
|
|
195
|
+
//
|
|
196
|
+
// `--show-toplevel` rather than `--git-dir`, and the answer is REPORTED: git
|
|
197
|
+
// resolves upwards, so a workdir that is a SUBDIRECTORY of a repository gets a
|
|
198
|
+
// branch in that enclosing repository — a blast radius wider than the operator
|
|
199
|
+
// handed over. That is the pre-existing semantics of every other git read in
|
|
200
|
+
// the flow (`seedAtHead`, `changedSet`), so it is not changed here; what it must
|
|
201
|
+
// not be is invisible, and the record names the repository that got the branch.
|
|
202
|
+
const inside = await git(workdir, ['rev-parse', '--show-toplevel']);
|
|
203
|
+
if (!inside.ok) {
|
|
204
|
+
return { stop: `the work branch cannot be created in ${workdir}: ${inside.err || 'not a git repository'}`, branch: null };
|
|
205
|
+
}
|
|
206
|
+
const repo = inside.out.trim().split('\n')[0].trim() || workdir;
|
|
207
|
+
|
|
208
|
+
// WHERE the run was handed. A symbolic HEAD names the handed branch; a detached
|
|
209
|
+
// one names nothing, and `null` is the honest reading of that (never the string
|
|
210
|
+
// "HEAD", which a reader cannot tell from a branch called HEAD).
|
|
211
|
+
const sym = await git(workdir, ['symbolic-ref', '--quiet', '--short', 'HEAD']);
|
|
212
|
+
const from = sym.ok ? sym.out.trim() || null : null;
|
|
213
|
+
const rev = await git(workdir, ['rev-parse', 'HEAD']);
|
|
214
|
+
const base = rev.ok ? (rev.out.trim().split('\n')[0].trim() || null) : null;
|
|
215
|
+
|
|
216
|
+
if (resume !== null && resume !== undefined) {
|
|
217
|
+
const own = String(resume);
|
|
218
|
+
if (!WORK_BRANCH_RE.test(own)) {
|
|
219
|
+
return { stop: `the recorded work branch ${JSON.stringify(own)} is not a bareloop work branch (${WORK_BRANCH_RE.source}) — this resume would continue on a branch the run does not own`, branch: null };
|
|
220
|
+
}
|
|
221
|
+
if (!await branchExists(workdir, own)) {
|
|
222
|
+
return { stop: `the work branch ${JSON.stringify(own)} this run was killed on no longer exists in ${workdir} — the resume stops rather than starting a new branch beside work it cannot see`, branch: null };
|
|
223
|
+
}
|
|
224
|
+
// Already standing on it (the ordinary case — a kill leaves the tree where it
|
|
225
|
+
// was) is a NO-OP, deliberately: `git checkout` onto the current branch is
|
|
226
|
+
// harmless today, and not running it at all is what keeps a dirty in-flight
|
|
227
|
+
// tree out of git's hands entirely.
|
|
228
|
+
if (from !== own) {
|
|
229
|
+
const co = await git(workdir, ['checkout', own]);
|
|
230
|
+
if (!co.ok) return { stop: `could not return to the work branch ${JSON.stringify(own)} in ${workdir}: ${co.err}`, branch: null };
|
|
231
|
+
}
|
|
232
|
+
return { stop: null, branch: own, created: false, resumed: true, from, base, repo, collided: 0 };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// The collision walk. `-2` is the first suffix rather than `-1`, so the branch a
|
|
236
|
+
// human reads without a number is the first run and the numbers count runs the
|
|
237
|
+
// way a person does.
|
|
238
|
+
//
|
|
239
|
+
// CHECK-THEN-ACT, named rather than fixed: another process can create the
|
|
240
|
+
// candidate between the `branchExists` read and the `checkout -b` below. It is
|
|
241
|
+
// documented instead of closed because git already fails CLOSED on it — `git
|
|
242
|
+
// checkout -b` REFUSES an existing branch, so the race ends in the named stop
|
|
243
|
+
// right beneath this loop, never in two runs sharing one blast radius. Closing
|
|
244
|
+
// it properly means atomic ref creation, which is a wider change than the
|
|
245
|
+
// failure it would prevent.
|
|
246
|
+
let collided = 0;
|
|
247
|
+
let candidate = wanted;
|
|
248
|
+
while (await branchExists(workdir, candidate)) {
|
|
249
|
+
collided += 1;
|
|
250
|
+
if (collided >= MAX_BRANCH_COLLISIONS) {
|
|
251
|
+
return { stop: `every work-branch name from ${JSON.stringify(wanted)} through ${JSON.stringify(`${wanted}-${MAX_BRANCH_COLLISIONS}`)} is already taken in ${workdir} — sweep the patient's old work branches rather than letting the runner walk further`, branch: null };
|
|
252
|
+
}
|
|
253
|
+
candidate = `${wanted}-${collided + 1}`;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const co = await git(workdir, ['checkout', '-b', candidate]);
|
|
257
|
+
if (!co.ok) {
|
|
258
|
+
return { stop: `could not create the work branch ${JSON.stringify(candidate)} in ${workdir}: ${co.err}`, branch: null };
|
|
259
|
+
}
|
|
260
|
+
return { stop: null, branch: candidate, created: true, resumed: false, from, base, repo, collided };
|
|
261
|
+
}
|