bareloop 0.7.0 → 0.9.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 +704 -0
- package/README.md +13 -4
- package/bareloop.context.md +351 -32
- package/package.json +1 -1
- package/src/authorflow.js +1280 -0
- package/src/authoring.js +1296 -0
- package/src/authorjob.js +793 -0
- package/src/authorscout.js +706 -0
- package/src/bridges.js +2 -2
- package/src/declaredclose.js +519 -0
- package/src/exits.js +8 -2
- package/src/index.js +72 -0
- package/src/job.js +91 -10
- package/src/kinds.js +1354 -0
- package/src/ledger.js +70 -9
- package/src/plan.js +57 -101
- package/src/planrun.js +554 -75
- package/src/ralph.js +27 -5
- package/src/reuse.js +135 -11
- package/src/root.js +8 -1
- package/src/run.js +29 -3
- package/src/text.js +120 -0
- package/src/validate.js +121 -0
- package/src/workbranch.js +261 -0
- package/types/authorflow.d.ts +720 -0
- package/types/authoring.d.ts +447 -0
- package/types/authorjob.d.ts +241 -0
- package/types/authorscout.d.ts +307 -0
- package/types/declaredclose.d.ts +174 -0
- package/types/exits.d.ts +14 -0
- package/types/index.d.ts +9 -1
- package/types/job.d.ts +14 -2
- package/types/kinds.d.ts +343 -0
- package/types/plan.d.ts +41 -38
- package/types/planrun.d.ts +125 -18
- package/types/ralph.d.ts +17 -0
- package/types/reuse.d.ts +5 -1
- package/types/run.d.ts +23 -2
- package/types/text.d.ts +84 -0
- package/types/validate.d.ts +50 -0
- package/types/workbranch.d.ts +102 -0
package/src/validate.js
CHANGED
|
@@ -155,3 +155,124 @@ export function sweepSecretLiterals(root, red) {
|
|
|
155
155
|
}
|
|
156
156
|
})(root, '');
|
|
157
157
|
}
|
|
158
|
+
|
|
159
|
+
// ── the catastrophic-backtracking reject (F49) ────────────────────────────────
|
|
160
|
+
// Housed here, with the other primitives BOTH validators share, because THREE
|
|
161
|
+
// regex fields reach an untimed evaluator and they are split across the two
|
|
162
|
+
// documents: the agent's `artifact-written.pattern` (plan.js) and the
|
|
163
|
+
// operator's `judged.pattern` + `gapKeep` (job.js). plan.js already imports
|
|
164
|
+
// job.js, so job.js cannot import plan.js — and a second copy here would be the
|
|
165
|
+
// drift class SECRET_PATTERNS exists to prevent: a shape one side rejects and
|
|
166
|
+
// the other admits. ONE inventory, imported by both.
|
|
167
|
+
|
|
168
|
+
/** Length of an UNBOUNDED quantifier token at `src[i]` (`*`, `+`, or `{n,}`,
|
|
169
|
+
* plus an optional trailing lazy `?`), or 0. Bounded forms (`?`, `{n}`,
|
|
170
|
+
* `{n,m}`) return 0: they cannot drive exponential backtracking (a bounded
|
|
171
|
+
* outer repeat is polynomial at worst, and the body is self-authored — F49).
|
|
172
|
+
* @param {string} src @param {number} i */
|
|
173
|
+
function unboundedQuantLen(src, i) {
|
|
174
|
+
let len = src[i] === '*' || src[i] === '+' ? 1 : 0;
|
|
175
|
+
if (!len && src[i] === '{') { const m = /^\{\d+,\}/.exec(src.slice(i)); if (m) len = m[0].length; }
|
|
176
|
+
if (len && src[i + len] === '?') len++; // the lazy modifier is part of the same token
|
|
177
|
+
return len;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* F49 — a heuristic reject for the catastrophic-backtracking footgun: an
|
|
182
|
+
* unbounded quantifier applied to a group whose body itself repeats unboundedly
|
|
183
|
+
* (`(a+)+`, `(\d*)*`, `([a-z]+){1,}`) — including through a redundant wrapping
|
|
184
|
+
* group (`((a+))+`, `(?:(a+))*`), which is the SAME exponential class as `(a+)+`
|
|
185
|
+
* and is caught by propagating the inner repeat up through the wrapper. Such a
|
|
186
|
+
* pattern can hang `RegExp.test` for seconds on a short crafted body, and THREE
|
|
187
|
+
* fields feed it to an evaluator with NO timeout — one per document:
|
|
188
|
+
* - the AGENT's `artifact-written.pattern`, run by `evalExits` (F49, the
|
|
189
|
+
* original site). Self-DoS only: the agent authors both the pattern and
|
|
190
|
+
* (via the worker) the artifact, so a hang burns only its own run's
|
|
191
|
+
* wall-clock, and there is no arbiter compromise — it cannot escape the
|
|
192
|
+
* fence, forge a green, or leak a secret.
|
|
193
|
+
* - the OPERATOR's `judged.pattern` (`runClose`) and `gapKeep` (`boundGap`),
|
|
194
|
+
* compiled and run against close output in BARELOOP'S OWN PROCESS. These
|
|
195
|
+
* are worse than an operator self-DoS, and the reason is F67's lesson: the
|
|
196
|
+
* regex blocks the MAIN EVENT LOOP, so the in-process stall fuse — a timer
|
|
197
|
+
* in that same loop — cannot fire. The run dies to the OUTSIDE watchdog and
|
|
198
|
+
* is read as a model stall, so a bad regex in a SIGNED spec presents as a
|
|
199
|
+
* provider problem. MEASURED: `boundGap(stream, '(a+)+$')` on a 40-char
|
|
200
|
+
* body does not finish in 20s.
|
|
201
|
+
* So the reject targets the dominant exponential class and fails it as a
|
|
202
|
+
* mechanical gap at the validation gate, before any tokens burn. A full ReDoS
|
|
203
|
+
* analyzer needs a real regex engine (an external native dep we do not take for
|
|
204
|
+
* a LOW issue); exotic overlapping-alternation blowup is out of scope by
|
|
205
|
+
* decision (PARKED to the close-authoring rung — PRD v1.55).
|
|
206
|
+
*
|
|
207
|
+
* 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
|
+
* - false POSITIVE: a group whose repetitions are disambiguated by a literal
|
|
215
|
+
* anchor/delimiter (`(?:^- .+$\n?)+`, `(?:CHANGELOG:.+\n)+`) is FLAGGED even
|
|
216
|
+
* though a real engine runs it linearly — the scan sees the nested-quantifier
|
|
217
|
+
* SHAPE, not the disambiguation. Rejecting it is the FAIL-SAFE direction (it
|
|
218
|
+
* never admits an unsafe pattern), and the cost is bounded: the plan drafter
|
|
219
|
+
* gets a mechanical gap and rewrites (drop the outer `+`, or match once).
|
|
220
|
+
* Detecting "safe because anchored" needs the same real engine we declined,
|
|
221
|
+
* and guessing it wrong would ADMIT an exponential pattern — so the shape
|
|
222
|
+
* reject stands, and the over-rejection is a named, accepted limitation.
|
|
223
|
+
*
|
|
224
|
+
* The input is guaranteed to compile as a RegExp (checked first by the caller),
|
|
225
|
+
* so this scan assumes valid, balanced JS regex syntax.
|
|
226
|
+
* @param {string} src a compiled regex source string
|
|
227
|
+
* @returns {boolean} true iff a nested unbounded quantifier is present
|
|
228
|
+
*/
|
|
229
|
+
export function hasNestedQuantifier(src) {
|
|
230
|
+
/** @type {{ quant: boolean }[]} */
|
|
231
|
+
const stack = [];
|
|
232
|
+
for (let i = 0; i < src.length; i++) {
|
|
233
|
+
const c = src[i];
|
|
234
|
+
if (c === '\\') { i++; continue; } // escaped atom — the next char is a literal
|
|
235
|
+
if (c === '[') { // character class: quantifier chars inside are literals
|
|
236
|
+
i++;
|
|
237
|
+
if (src[i] === '^') i++;
|
|
238
|
+
// NO POSIX leading-`]`-is-a-literal rule: JS is not POSIX here. `[]` is the
|
|
239
|
+
// EMPTY class (matches nothing) and `[^]` the any-char idiom — both close at
|
|
240
|
+
// that first `]`. Skipping it ran the scan past the class's real end and
|
|
241
|
+
// swallowed every quantifier after it, so `x[^](a+)+$` read as safe while
|
|
242
|
+
// RegExp.test on it does not finish in 15s on a 31-char body. A false
|
|
243
|
+
// NEGATIVE is the dangerous direction (F49), so the scan follows JS.
|
|
244
|
+
//
|
|
245
|
+
// Honest limit on the monotonicity claim: this is a PARSE correction, not
|
|
246
|
+
// the "add rejections only" tightening F49's rule contemplates, and it is
|
|
247
|
+
// not strictly rejection-monotonic. Measured over 14,862 compilable
|
|
248
|
+
// generated patterns (2,369 previously rejected): 2 rejections disappear,
|
|
249
|
+
// both pathological `[]`-bearing shapes whose old `true` came from the very
|
|
250
|
+
// misparse being fixed, and both verified to finish RegExp.test in ~0ms.
|
|
251
|
+
// Every entry of the checked-in REDOS_BAD/GOOD/OVERREJECTED corpus is
|
|
252
|
+
// unchanged. So no real hazard was un-rejected — but the claim is
|
|
253
|
+
// "measured, no hazard lost", never "monotonic by construction".
|
|
254
|
+
while (i < src.length && src[i] !== ']') { if (src[i] === '\\') i++; i++; }
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (c === '(') { stack.push({ quant: false }); continue; }
|
|
258
|
+
if (c === ')') {
|
|
259
|
+
const g = stack.pop();
|
|
260
|
+
const qlen = unboundedQuantLen(src, i + 1);
|
|
261
|
+
if (g && g.quant && qlen) return true; // group repeats unboundedly AND its body did too
|
|
262
|
+
// The group is an unbounded-repeated atom of its parent when it is directly
|
|
263
|
+
// re-quantified (qlen) OR its own body already repeats unboundedly (g.quant):
|
|
264
|
+
// a redundant wrapper — ((a+))+ , (?:(a+))* , (((\d*)))+ — is the SAME
|
|
265
|
+
// exponential class as (a+)+, so an inner repeat must propagate THROUGH the
|
|
266
|
+
// enclosing group or the outer quantifier's close never sees it. Propagation
|
|
267
|
+
// is MONOTONIC (it only ever SETS quant=true → only ever adds rejections): it
|
|
268
|
+
// can widen over-rejection — the named FAIL-SAFE direction — but can never
|
|
269
|
+
// introduce a false negative, which is the dangerous one (F49).
|
|
270
|
+
if ((qlen || (g && g.quant)) && stack.length) stack[stack.length - 1].quant = true;
|
|
271
|
+
i += qlen;
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
const qlen = unboundedQuantLen(src, i); // a quantifier applying to the preceding atom, inside the current group
|
|
275
|
+
if (qlen) { if (stack.length) stack[stack.length - 1].quant = true; i += qlen - 1; }
|
|
276
|
+
}
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
@@ -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
|
+
}
|