bareloop 0.35.0 → 0.37.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 +50 -0
- package/bareloop.context.md +69 -22
- package/package.json +1 -1
- package/src/authorrun.js +5 -5
- package/src/bundlerun.js +4 -8
- package/src/draftspend.js +114 -1
- package/src/exits.js +3 -0
- package/src/index.js +3 -3
- package/src/legs.js +1 -1
- package/src/movefile.js +22 -0
- package/src/npminstall.js +55 -0
- package/src/panel/authorroutes.js +12 -1
- package/src/panel/authorsession.js +95 -28
- package/src/panel/index.html +317 -76
- package/src/panel/server.js +176 -16
- package/src/panel/settingsroutes.js +52 -2
- package/src/panel/status.js +22 -0
- package/src/planrun.js +10 -7
- package/src/ralph.js +1 -1
- package/src/source.js +55 -4
- package/src/u-patient.js +3 -2
- package/src/userrun.js +56 -10
- package/src/worktree.js +120 -0
- package/types/draftspend.d.ts +88 -0
- package/types/legs.d.ts +1 -1
- package/types/movefile.d.ts +9 -0
- package/types/npminstall.d.ts +30 -0
- package/types/panel/authorroutes.d.ts +4 -1
- package/types/panel/authorsession.d.ts +4 -8
- package/types/panel/server.d.ts +30 -2
- package/types/panel/settingsroutes.d.ts +6 -1
- package/types/panel/status.d.ts +20 -0
- package/types/planrun.d.ts +3 -3
- package/types/ralph.d.ts +19 -0
- package/types/source.d.ts +20 -5
- package/types/userrun.d.ts +10 -0
- package/types/worktree.d.ts +48 -0
|
@@ -27,8 +27,11 @@ import {
|
|
|
27
27
|
} from 'node:fs';
|
|
28
28
|
import { join, resolve, dirname } from 'node:path';
|
|
29
29
|
import {
|
|
30
|
-
prepareSource, proveDestination, missingDependencies, looksLikeRepoSource,
|
|
30
|
+
prepareSource, proveDestination, missingDependencies, looksLikeRepoSource, nearestGitAncestor, nonRepoSourceMessage,
|
|
31
31
|
} from '../source.js';
|
|
32
|
+
import {
|
|
33
|
+
worktreePath, uncommittedCount, shortHead, removeWorktree,
|
|
34
|
+
} from '../worktree.js';
|
|
32
35
|
import { detectLanguage } from '../detectlang.js';
|
|
33
36
|
import {
|
|
34
37
|
validateJob, jobSpecHash, workflowKey, resolveWorkerModel,
|
|
@@ -48,7 +51,8 @@ import { resolveJobJudge, defaultJudgeLoop } from '../judged.js';
|
|
|
48
51
|
import { closeJudges } from '../kinds.js';
|
|
49
52
|
import { redactSecrets } from '../validate.js';
|
|
50
53
|
import { tallyCalls } from '../text.js';
|
|
51
|
-
import { writeDraftSpend } from '../draftspend.js';
|
|
54
|
+
import { writeDraftSpend, appendDraftLog } from '../draftspend.js';
|
|
55
|
+
import { runNpmCi, NPM_CI_LOCKS } from '../npminstall.js';
|
|
52
56
|
|
|
53
57
|
/**
|
|
54
58
|
* THE PROGRESS LIST's one table (hamr 2026-10-04, "one place showing all"): every pipeline step the left Chat panel
|
|
@@ -58,7 +62,7 @@ import { writeDraftSpend } from '../draftspend.js';
|
|
|
58
62
|
*/
|
|
59
63
|
export const STEP_LABELS = Object.freeze({
|
|
60
64
|
setup: 'checking setup',
|
|
61
|
-
copy: '
|
|
65
|
+
copy: 'making worktree',
|
|
62
66
|
check: 'checking source',
|
|
63
67
|
install: 'waiting on install',
|
|
64
68
|
reuse: 'reusing signed workflow',
|
|
@@ -254,17 +258,7 @@ export function validateReuseCard(card, opts = {}) {
|
|
|
254
258
|
/** A Destination written as an absolute path (POSIX or a Windows drive path). @param {string} destination @returns {boolean} */
|
|
255
259
|
const isAbsoluteDestination = (destination) => /^(\/|[a-zA-Z]:[\\/])/.test(destination);
|
|
256
260
|
|
|
257
|
-
|
|
258
|
-
* The $0 refusal for a non-repo source, worded by the kind the source door recorded (`prep.manifest.kind`) — a single
|
|
259
|
-
* file is not a "folder", and Source must be the repo folder with the file named in Destination (hamr 2026-10-05).
|
|
260
|
-
* @param {string} kind `prep.manifest.kind`: 'file' | 'folder' | 'url' (a 'repo' never reaches this)
|
|
261
|
-
* @returns {string}
|
|
262
|
-
*/
|
|
263
|
-
export function nonRepoSourceMessage(kind) {
|
|
264
|
-
if (kind === 'file') return 'Source is a single file. Source must be the repo folder — put the file in Destination, like src/digest.js. Nothing spent.';
|
|
265
|
-
if (kind === 'folder') return "Source is a plain folder, not a code project (no git repo found). bareloop can't check this kind of job yet. Nothing spent.";
|
|
266
|
-
return "Source is not a code project (no git repo found). bareloop can't check this kind of job yet. Nothing spent.";
|
|
267
|
-
}
|
|
261
|
+
export { nonRepoSourceMessage };
|
|
268
262
|
|
|
269
263
|
/**
|
|
270
264
|
* The ONE Destination rule for a REPO source (fresh card and Reuse card alike): there Destination is the write fence,
|
|
@@ -315,7 +309,7 @@ export function validateJobCard(card, opts = {}) {
|
|
|
315
309
|
* @param {any} card the validated job card (see {@link validateJobCard})
|
|
316
310
|
* @param {{env?: Record<string,string|undefined>, sessionsRoot?: string, home?: string, timeoutMs?: number,
|
|
317
311
|
* scout?: any, generate?: Function, confirmGenerate?: Function, authorFn?: Function,
|
|
318
|
-
* prepareSigningFn?: Function, reuse?: {spec: any, workflowKey: string}}} [deps] `scout`..`prepareSigningFn` are
|
|
312
|
+
* prepareSigningFn?: Function, npmCiFn?: Function, reuse?: {spec: any, workflowKey: string}}} [deps] `scout`..`prepareSigningFn` are
|
|
319
313
|
* TEST SEAMS ONLY — see the note just above where each is read, below. `reuse` (Reuse workflow) is NOT a seam: the
|
|
320
314
|
* caller (`/api/author/start`) sets it only when the server's own same-job rule held.
|
|
321
315
|
* @returns {any} the session object
|
|
@@ -378,15 +372,31 @@ export function createSession(card, deps = {}) {
|
|
|
378
372
|
steps: /** @type {{id: string, label: string, status: 'running'|'done'|'failed', detail: string}[]} */ ([]),
|
|
379
373
|
};
|
|
380
374
|
|
|
375
|
+
// The drafting LOG's step events (src/draftspend.js, DRAFT_LOG_FILE): after every progress-list change, diff the list
|
|
376
|
+
// against what was already logged and append the start / end / re-open of each line — the ONE writer of step times.
|
|
377
|
+
/** @type {('running'|'ended')[]} */
|
|
378
|
+
const loggedSteps = [];
|
|
379
|
+
const logSteps = () => {
|
|
380
|
+
const at = new Date().toISOString();
|
|
381
|
+
state.steps.forEach((/** @type {any} */ x, no) => {
|
|
382
|
+
if (loggedSteps[no] === undefined) { appendDraftLog(outDir, { kind: 'step-start', no, id: x.id, label: x.label, at }); loggedSteps[no] = 'running'; }
|
|
383
|
+
if (x.status === 'running' && loggedSteps[no] === 'ended') { appendDraftLog(outDir, { kind: 'step-reopen', no, at }); loggedSteps[no] = 'running'; }
|
|
384
|
+
if (x.status !== 'running' && loggedSteps[no] === 'running') { appendDraftLog(outDir, { kind: 'step-end', no, status: x.status, label: x.label, at }); loggedSteps[no] = 'ended'; }
|
|
385
|
+
});
|
|
386
|
+
};
|
|
387
|
+
|
|
381
388
|
/** @param {string} id @param {string} [detail] (absent = keep what the step has) start (or restart) a step; whatever step was running is finished */
|
|
382
389
|
const stepStart = (id, detail) => {
|
|
383
390
|
advanceSteps(state.steps, id, detail);
|
|
384
391
|
state.progressLabel = STEP_LABELS[id] ?? id;
|
|
392
|
+
logSteps();
|
|
385
393
|
};
|
|
386
394
|
/** @param {string} id @param {string} detail set a step's latest line's detail without changing its status */
|
|
387
395
|
const stepDetail = (id, detail) => { const x = latestStep(state.steps, id); if (x) x.detail = detail; };
|
|
396
|
+
/** @param {string} id @param {string} label the latest line of `id` reads `label` instead of its table label (the install step: bareloop installing vs the person) */
|
|
397
|
+
const stepLabel = (id, label) => { const x = latestStep(state.steps, id); if (x) x.label = label; };
|
|
388
398
|
/** finish the running step as done (or the latest line of `id`, when given) */
|
|
389
|
-
const stepDone = (id) => { for (const x of state.steps) if (x.status === 'running' && (id === undefined || x.id === id)) x.status = 'done'; };
|
|
399
|
+
const stepDone = (id) => { for (const x of state.steps) if (x.status === 'running' && (id === undefined || x.id === id)) x.status = 'done'; logSteps(); };
|
|
390
400
|
/** @param {string} reason the running step (else the last one) fails with the code-owned reason on its own line */
|
|
391
401
|
const stepFail = (reason) => {
|
|
392
402
|
let x = state.steps.find((/** @type {any} */ y) => y.status === 'running');
|
|
@@ -396,6 +406,7 @@ export function createSession(card, deps = {}) {
|
|
|
396
406
|
}
|
|
397
407
|
x.status = 'failed';
|
|
398
408
|
x.detail = reason;
|
|
409
|
+
logSteps();
|
|
399
410
|
};
|
|
400
411
|
|
|
401
412
|
/** @param {'bot'|'you'|'system'} role @param {string} text */
|
|
@@ -470,6 +481,13 @@ export function createSession(card, deps = {}) {
|
|
|
470
481
|
// person needs to read; the step-by-step detail lives in one line.
|
|
471
482
|
// ONE owner of the drafting line's detail (model + cap): the drafting line is started here, by the real author phase
|
|
472
483
|
if (PHASE_STEP[name]) stepStart(PHASE_STEP[name], PHASE_STEP[name] === 'draft' ? `${card.model}, $${card.capUsd.toFixed(2)} cap` : undefined);
|
|
484
|
+
// a drafting call after the first (`author-call` i >= 1, of up to `of + 1`) is a RETRY: the line says so in plain words, so a
|
|
485
|
+
// long silence on call 2 of 3 does not read as a hang. The same one line, relabelled — never a second line.
|
|
486
|
+
if (name === 'author-call' && Number.isInteger(data.i) && data.i >= 1 && Number.isInteger(data.of)) {
|
|
487
|
+
const label = `${STEP_LABELS.draft} (retry ${data.i + 1} of ${data.of + 1})`;
|
|
488
|
+
stepLabel('draft', label);
|
|
489
|
+
state.progressLabel = label;
|
|
490
|
+
}
|
|
473
491
|
};
|
|
474
492
|
// ABANDON (hamr's ruling 2026-10-05): once abandoned the phase is frozen at 'abandoned' — whatever the in-flight
|
|
475
493
|
// run() does afterwards (a late refuse, a phase write) never overwrites it, so the one-at-a-time lock stays released.
|
|
@@ -498,6 +516,14 @@ export function createSession(card, deps = {}) {
|
|
|
498
516
|
// tally, so an abandoned / refused / restarted session's drafting spend still counts (src/draftspend.js)
|
|
499
517
|
const nowIso = new Date().toISOString();
|
|
500
518
|
draftStartedAt ??= nowIso;
|
|
519
|
+
// the same call, one line in the drafting LOG (the part a run's views show first): the step running right now,
|
|
520
|
+
// the call's label, the drafting model, its cost (null stays null: unpriced is never $0). One owner: this onCall.
|
|
521
|
+
const runningNo = state.steps.findLastIndex((/** @type {any} */ x) => x.status === 'running');
|
|
522
|
+
const callNo = runningNo === -1 ? state.steps.length - 1 : runningNo;
|
|
523
|
+
appendDraftLog(outDir, {
|
|
524
|
+
kind: 'call', no: callNo, step: state.steps[callNo]?.id ?? null, label: call.label, model: draftIdentity?.model ?? null,
|
|
525
|
+
costUsd: call.costUsd ?? null, unpricedRounds: call.unpricedRounds ?? 0, at: nowIso,
|
|
526
|
+
});
|
|
501
527
|
try {
|
|
502
528
|
writeDraftSpend(outDir, {
|
|
503
529
|
sessionId: id, spentUsd: t.knownUsd, spendComplete: t.spendComplete, provider: draftIdentity?.provider ?? null,
|
|
@@ -507,6 +533,15 @@ export function createSession(card, deps = {}) {
|
|
|
507
533
|
};
|
|
508
534
|
/** @type {string|null} */
|
|
509
535
|
let draftStartedAt = null;
|
|
536
|
+
/** P6 item 1: the worktree this session made in the person's repo (null until made, and again once removed) @type {{repo: string, dir: string}|null} */
|
|
537
|
+
let madeWorktree = null;
|
|
538
|
+
/** a session that ends WITHOUT ever running (refused, errored, abandoned) takes its worktree with it; a signed one keeps it for the run */
|
|
539
|
+
const dropWorktree = () => {
|
|
540
|
+
if (madeWorktree === null) return;
|
|
541
|
+
const w = madeWorktree;
|
|
542
|
+
madeWorktree = null;
|
|
543
|
+
removeWorktree(w.repo, w.dir);
|
|
544
|
+
};
|
|
510
545
|
/** @type {{provider: string, baseUrl: string|null, model: string}|null} the worker the drafting calls ran on, set once the Model Name resolves */
|
|
511
546
|
let draftIdentity = null;
|
|
512
547
|
|
|
@@ -515,6 +550,7 @@ export function createSession(card, deps = {}) {
|
|
|
515
550
|
/** @param {string} message @param {string} [phase] */
|
|
516
551
|
const refuse = (message, phase = 'refused') => {
|
|
517
552
|
if (abandoned) return;
|
|
553
|
+
dropWorktree();
|
|
518
554
|
state.phase = phase;
|
|
519
555
|
state.error = message;
|
|
520
556
|
stepFail(message);
|
|
@@ -556,6 +592,16 @@ export function createSession(card, deps = {}) {
|
|
|
556
592
|
|
|
557
593
|
state.phase = 'preparing-source';
|
|
558
594
|
stepStart('copy');
|
|
595
|
+
// P6 item 1 (hamr 2026-10-05, Q1 = A / Q3 = A): a REPO source gets a worktree in the person's own repo, made now at
|
|
596
|
+
// drafting start — `.bareloop/wt/<session id>` at their current commit. Edits they have not committed are not in it,
|
|
597
|
+
// and the progress line says so (a notice, never a refusal).
|
|
598
|
+
const repoRoot = isRepoLike ? nearestGitAncestor(resolve(card.source))?.dir ?? null : null;
|
|
599
|
+
if (repoRoot !== null) {
|
|
600
|
+
const dirty = uncommittedCount(repoRoot);
|
|
601
|
+
if (dirty > 0) {
|
|
602
|
+
stepDetail('copy', `${dirty} uncommitted change(s) in your repo are not in this job — it starts from commit ${shortHead(repoRoot) ?? 'HEAD'}.`);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
559
605
|
// ONE rule for Destination (the same for a fresh card and a reuse card): for a REPO source it is the write fence
|
|
560
606
|
// (writeScope globs, relative to the repo — set into the spec below, never proven as a directory); for a FOLDER source
|
|
561
607
|
// it is an absolute output directory, proven by `prepareSource` -> `proveDestination`. The door routes on a peek at
|
|
@@ -565,7 +611,10 @@ export function createSession(card, deps = {}) {
|
|
|
565
611
|
const destIsDir = isAbsoluteDestination(card.destination);
|
|
566
612
|
const prep = await prepareSource({
|
|
567
613
|
source: card.source, into, ...(isRepoLike || destIsDir ? { destination: card.destination } : {}),
|
|
614
|
+
...(repoRoot === null ? {} : { worktree: worktreePath(repoRoot, id) }),
|
|
568
615
|
});
|
|
616
|
+
if (repoRoot !== null && prep.stop === null && prep.manifest?.worktree) madeWorktree = { repo: repoRoot, dir: prep.manifest.worktree };
|
|
617
|
+
if (abandoned) dropWorktree();
|
|
569
618
|
if (prep.stop !== null) {
|
|
570
619
|
// reaching the source and checking it are two lines: a scan/freeze refusal means the copy itself worked
|
|
571
620
|
if (SOURCE_CHECK_CODES.has(/** @type {any} */ (prep).code)) stepStart('check');
|
|
@@ -587,21 +636,38 @@ export function createSession(card, deps = {}) {
|
|
|
587
636
|
LANG = langResult.kind === 'resolved' ? langResult.lang : (langResult.kind === 'ambiguous' ? langResult.candidates[0] : 'none-detected');
|
|
588
637
|
|
|
589
638
|
// ── the install gap, WAITED FOR, never a dead end (build item 1,
|
|
590
|
-
// mirroring F182's fix in `src/interviewrun.js`) ── the
|
|
591
|
-
// session works from (`prep.tree`) is
|
|
592
|
-
//
|
|
593
|
-
//
|
|
594
|
-
//
|
|
595
|
-
//
|
|
596
|
-
//
|
|
597
|
-
//
|
|
598
|
-
// (
|
|
599
|
-
// `src/interviewrun.js` uses) on demand, via the "Check again" button
|
|
600
|
-
// (`checkDeps()`, below), instead of ending the session.
|
|
639
|
+
// mirroring F182's fix in `src/interviewrun.js`) ── the tree this
|
|
640
|
+
// session works from (`prep.tree`) is the job's own WORKTREE (P6 item 1:
|
|
641
|
+
// a detached checkout of the person's current commit, inside their
|
|
642
|
+
// repo), so a JS/TS repo's tree never carries `node_modules` until
|
|
643
|
+
// someone installs there. Refusing outright here was ITSELF the F182
|
|
644
|
+
// class of bug one layer up. bareloop still never runs an install
|
|
645
|
+
// itself — this only re-checks the SAME worktree on demand, via the
|
|
646
|
+
// "Check again" button (`checkDeps()`, below), instead of ending the
|
|
647
|
+
// session. (P6 item 3: for an npm lock file bareloop runs `npm ci` itself first, above.)
|
|
601
648
|
let depsGap = missingDependencies(prep.tree, prep.manifest.sourceSubdir ?? '');
|
|
649
|
+
// P6 item 3: a committed npm lock file -> bareloop runs `npm ci --ignore-scripts` itself, $0, before
|
|
650
|
+
// any model call. Any failure (or another lock file) falls through to the wait below, unchanged.
|
|
651
|
+
/** @type {string|null} */
|
|
652
|
+
let installFailed = null;
|
|
653
|
+
if (depsGap && depsGap.lockFile && NPM_CI_LOCKS.includes(depsGap.lockFile)) {
|
|
654
|
+
stepStart('install', 'Installing packages (npm ci)…');
|
|
655
|
+
stepLabel('install', 'installing packages');
|
|
656
|
+
state.progressLabel = 'installing packages';
|
|
657
|
+
const ci = await (deps.npmCiFn ?? runNpmCi)(depsGap.dir, {});
|
|
658
|
+
if (ci.ok) {
|
|
659
|
+
depsGap = missingDependencies(prep.tree, prep.manifest.sourceSubdir ?? '');
|
|
660
|
+
if (!depsGap) {
|
|
661
|
+
stepDetail('install', 'Installed packages (npm ci).');
|
|
662
|
+
stepDone('install');
|
|
663
|
+
stepStart('check');
|
|
664
|
+
} else installFailed = 'it ran but node_modules is still missing';
|
|
665
|
+
} else installFailed = ci.reason;
|
|
666
|
+
}
|
|
602
667
|
if (depsGap) {
|
|
603
668
|
state.phase = 'install-needed';
|
|
604
|
-
|
|
669
|
+
stepLabel('install', STEP_LABELS.install); // the person is the one installing now
|
|
670
|
+
stepStart('install', `${installFailed ? `Installing packages (npm ci) failed: ${redactSecrets(installFailed)}. ` : ''}Packages missing in the job's worktree. Run: cd ${prep.tree} && ${depsGap.command}`);
|
|
605
671
|
for (;;) {
|
|
606
672
|
state.pendingAsk = { kind: 'install-needed', tree: prep.tree, command: depsGap.command, reason: depsGap.reason };
|
|
607
673
|
// eslint-disable-next-line no-await-in-loop
|
|
@@ -834,6 +900,7 @@ export function createSession(card, deps = {}) {
|
|
|
834
900
|
if (['refused', 'abandoned', 'error', 'signed', 'signing-failed'].includes(state.phase)) return { ok: false, error: 'this session is not live — nothing to abandon' };
|
|
835
901
|
abandoned = true;
|
|
836
902
|
phaseValue = 'abandoned';
|
|
903
|
+
dropWorktree();
|
|
837
904
|
state.error = 'Abandoned by you — money already spent stays booked.';
|
|
838
905
|
state.pendingAsk = null;
|
|
839
906
|
if (resolvePending) { const r = resolvePending; resolvePending = null; r(null); }
|