bareloop 0.35.0 → 0.36.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.
@@ -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,
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: 'copying source',
65
+ copy: 'making worktree',
62
66
  check: 'checking source',
63
67
  install: 'waiting on install',
64
68
  reuse: 'reusing signed workflow',
@@ -315,7 +319,7 @@ export function validateJobCard(card, opts = {}) {
315
319
  * @param {any} card the validated job card (see {@link validateJobCard})
316
320
  * @param {{env?: Record<string,string|undefined>, sessionsRoot?: string, home?: string, timeoutMs?: number,
317
321
  * scout?: any, generate?: Function, confirmGenerate?: Function, authorFn?: Function,
318
- * prepareSigningFn?: Function, reuse?: {spec: any, workflowKey: string}}} [deps] `scout`..`prepareSigningFn` are
322
+ * prepareSigningFn?: Function, npmCiFn?: Function, reuse?: {spec: any, workflowKey: string}}} [deps] `scout`..`prepareSigningFn` are
319
323
  * TEST SEAMS ONLY — see the note just above where each is read, below. `reuse` (Reuse workflow) is NOT a seam: the
320
324
  * caller (`/api/author/start`) sets it only when the server's own same-job rule held.
321
325
  * @returns {any} the session object
@@ -378,15 +382,31 @@ export function createSession(card, deps = {}) {
378
382
  steps: /** @type {{id: string, label: string, status: 'running'|'done'|'failed', detail: string}[]} */ ([]),
379
383
  };
380
384
 
385
+ // The drafting LOG's step events (src/draftspend.js, DRAFT_LOG_FILE): after every progress-list change, diff the list
386
+ // against what was already logged and append the start / end / re-open of each line — the ONE writer of step times.
387
+ /** @type {('running'|'ended')[]} */
388
+ const loggedSteps = [];
389
+ const logSteps = () => {
390
+ const at = new Date().toISOString();
391
+ state.steps.forEach((/** @type {any} */ x, no) => {
392
+ if (loggedSteps[no] === undefined) { appendDraftLog(outDir, { kind: 'step-start', no, id: x.id, label: x.label, at }); loggedSteps[no] = 'running'; }
393
+ if (x.status === 'running' && loggedSteps[no] === 'ended') { appendDraftLog(outDir, { kind: 'step-reopen', no, at }); loggedSteps[no] = 'running'; }
394
+ if (x.status !== 'running' && loggedSteps[no] === 'running') { appendDraftLog(outDir, { kind: 'step-end', no, status: x.status, label: x.label, at }); loggedSteps[no] = 'ended'; }
395
+ });
396
+ };
397
+
381
398
  /** @param {string} id @param {string} [detail] (absent = keep what the step has) start (or restart) a step; whatever step was running is finished */
382
399
  const stepStart = (id, detail) => {
383
400
  advanceSteps(state.steps, id, detail);
384
401
  state.progressLabel = STEP_LABELS[id] ?? id;
402
+ logSteps();
385
403
  };
386
404
  /** @param {string} id @param {string} detail set a step's latest line's detail without changing its status */
387
405
  const stepDetail = (id, detail) => { const x = latestStep(state.steps, id); if (x) x.detail = detail; };
406
+ /** @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) */
407
+ const stepLabel = (id, label) => { const x = latestStep(state.steps, id); if (x) x.label = label; };
388
408
  /** 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'; };
409
+ const stepDone = (id) => { for (const x of state.steps) if (x.status === 'running' && (id === undefined || x.id === id)) x.status = 'done'; logSteps(); };
390
410
  /** @param {string} reason the running step (else the last one) fails with the code-owned reason on its own line */
391
411
  const stepFail = (reason) => {
392
412
  let x = state.steps.find((/** @type {any} */ y) => y.status === 'running');
@@ -396,6 +416,7 @@ export function createSession(card, deps = {}) {
396
416
  }
397
417
  x.status = 'failed';
398
418
  x.detail = reason;
419
+ logSteps();
399
420
  };
400
421
 
401
422
  /** @param {'bot'|'you'|'system'} role @param {string} text */
@@ -498,6 +519,14 @@ export function createSession(card, deps = {}) {
498
519
  // tally, so an abandoned / refused / restarted session's drafting spend still counts (src/draftspend.js)
499
520
  const nowIso = new Date().toISOString();
500
521
  draftStartedAt ??= nowIso;
522
+ // the same call, one line in the drafting LOG (the part a run's views show first): the step running right now,
523
+ // the call's label, the drafting model, its cost (null stays null: unpriced is never $0). One owner: this onCall.
524
+ const runningNo = state.steps.findLastIndex((/** @type {any} */ x) => x.status === 'running');
525
+ const callNo = runningNo === -1 ? state.steps.length - 1 : runningNo;
526
+ appendDraftLog(outDir, {
527
+ kind: 'call', no: callNo, step: state.steps[callNo]?.id ?? null, label: call.label, model: draftIdentity?.model ?? null,
528
+ costUsd: call.costUsd ?? null, unpricedRounds: call.unpricedRounds ?? 0, at: nowIso,
529
+ });
501
530
  try {
502
531
  writeDraftSpend(outDir, {
503
532
  sessionId: id, spentUsd: t.knownUsd, spendComplete: t.spendComplete, provider: draftIdentity?.provider ?? null,
@@ -507,6 +536,15 @@ export function createSession(card, deps = {}) {
507
536
  };
508
537
  /** @type {string|null} */
509
538
  let draftStartedAt = null;
539
+ /** 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} */
540
+ let madeWorktree = null;
541
+ /** a session that ends WITHOUT ever running (refused, errored, abandoned) takes its worktree with it; a signed one keeps it for the run */
542
+ const dropWorktree = () => {
543
+ if (madeWorktree === null) return;
544
+ const w = madeWorktree;
545
+ madeWorktree = null;
546
+ removeWorktree(w.repo, w.dir);
547
+ };
510
548
  /** @type {{provider: string, baseUrl: string|null, model: string}|null} the worker the drafting calls ran on, set once the Model Name resolves */
511
549
  let draftIdentity = null;
512
550
 
@@ -515,6 +553,7 @@ export function createSession(card, deps = {}) {
515
553
  /** @param {string} message @param {string} [phase] */
516
554
  const refuse = (message, phase = 'refused') => {
517
555
  if (abandoned) return;
556
+ dropWorktree();
518
557
  state.phase = phase;
519
558
  state.error = message;
520
559
  stepFail(message);
@@ -556,6 +595,16 @@ export function createSession(card, deps = {}) {
556
595
 
557
596
  state.phase = 'preparing-source';
558
597
  stepStart('copy');
598
+ // 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
599
+ // drafting start — `.bareloop/wt/<session id>` at their current commit. Edits they have not committed are not in it,
600
+ // and the progress line says so (a notice, never a refusal).
601
+ const repoRoot = isRepoLike ? nearestGitAncestor(resolve(card.source))?.dir ?? null : null;
602
+ if (repoRoot !== null) {
603
+ const dirty = uncommittedCount(repoRoot);
604
+ if (dirty > 0) {
605
+ stepDetail('copy', `${dirty} uncommitted change(s) in your repo are not in this job — it starts from commit ${shortHead(repoRoot) ?? 'HEAD'}.`);
606
+ }
607
+ }
559
608
  // ONE rule for Destination (the same for a fresh card and a reuse card): for a REPO source it is the write fence
560
609
  // (writeScope globs, relative to the repo — set into the spec below, never proven as a directory); for a FOLDER source
561
610
  // it is an absolute output directory, proven by `prepareSource` -> `proveDestination`. The door routes on a peek at
@@ -565,7 +614,10 @@ export function createSession(card, deps = {}) {
565
614
  const destIsDir = isAbsoluteDestination(card.destination);
566
615
  const prep = await prepareSource({
567
616
  source: card.source, into, ...(isRepoLike || destIsDir ? { destination: card.destination } : {}),
617
+ ...(repoRoot === null ? {} : { worktree: worktreePath(repoRoot, id) }),
568
618
  });
619
+ if (repoRoot !== null && prep.stop === null && prep.manifest?.worktree) madeWorktree = { repo: repoRoot, dir: prep.manifest.worktree };
620
+ if (abandoned) dropWorktree();
569
621
  if (prep.stop !== null) {
570
622
  // reaching the source and checking it are two lines: a scan/freeze refusal means the copy itself worked
571
623
  if (SOURCE_CHECK_CODES.has(/** @type {any} */ (prep).code)) stepStart('check');
@@ -587,21 +639,38 @@ export function createSession(card, deps = {}) {
587
639
  LANG = langResult.kind === 'resolved' ? langResult.lang : (langResult.kind === 'ambiguous' ? langResult.candidates[0] : 'none-detected');
588
640
 
589
641
  // ── the install gap, WAITED FOR, never a dead end (build item 1,
590
- // mirroring F182's fix in `src/interviewrun.js`) ── the copy this
591
- // session works from (`prep.tree`) is `prepareSource`'s own hidden
592
- // seed, tracked-files-only, so a JS/TS repo's copy never carries
593
- // `node_modules`. Refusing outright here was ITSELF the F182 class of
594
- // bug one layer up: a person who ran the printed command and then
595
- // reloaded the page got a BRAND NEW seed with no `node_modules`
596
- // either, so the refusal could never be satisfied. bareloop still
597
- // never runs an install itself — this only re-checks the SAME copy
598
- // (`missingDependencies` over `prep.tree`, the identical rule
599
- // `src/interviewrun.js` uses) on demand, via the "Check again" button
600
- // (`checkDeps()`, below), instead of ending the session.
642
+ // mirroring F182's fix in `src/interviewrun.js`) ── the tree this
643
+ // session works from (`prep.tree`) is the job's own WORKTREE (P6 item 1:
644
+ // a detached checkout of the person's current commit, inside their
645
+ // repo), so a JS/TS repo's tree never carries `node_modules` until
646
+ // someone installs there. Refusing outright here was ITSELF the F182
647
+ // class of bug one layer up. bareloop still never runs an install
648
+ // itself — this only re-checks the SAME worktree on demand, via the
649
+ // "Check again" button (`checkDeps()`, below), instead of ending the
650
+ // session. (P6 item 3: for an npm lock file bareloop runs `npm ci` itself first, above.)
601
651
  let depsGap = missingDependencies(prep.tree, prep.manifest.sourceSubdir ?? '');
652
+ // P6 item 3: a committed npm lock file -> bareloop runs `npm ci --ignore-scripts` itself, $0, before
653
+ // any model call. Any failure (or another lock file) falls through to the wait below, unchanged.
654
+ /** @type {string|null} */
655
+ let installFailed = null;
656
+ if (depsGap && depsGap.lockFile && NPM_CI_LOCKS.includes(depsGap.lockFile)) {
657
+ stepStart('install', 'Installing packages (npm ci)…');
658
+ stepLabel('install', 'installing packages');
659
+ state.progressLabel = 'installing packages';
660
+ const ci = await (deps.npmCiFn ?? runNpmCi)(depsGap.dir, {});
661
+ if (ci.ok) {
662
+ depsGap = missingDependencies(prep.tree, prep.manifest.sourceSubdir ?? '');
663
+ if (!depsGap) {
664
+ stepDetail('install', 'Installed packages (npm ci).');
665
+ stepDone('install');
666
+ stepStart('check');
667
+ } else installFailed = 'it ran but node_modules is still missing';
668
+ } else installFailed = ci.reason;
669
+ }
602
670
  if (depsGap) {
603
671
  state.phase = 'install-needed';
604
- stepStart('install', `Packages missing in the copy. Run: cd ${prep.tree} && ${depsGap.command}`);
672
+ stepLabel('install', STEP_LABELS.install); // the person is the one installing now
673
+ stepStart('install', `${installFailed ? `Installing packages (npm ci) failed: ${redactSecrets(installFailed)}. ` : ''}Packages missing in the job's worktree. Run: cd ${prep.tree} && ${depsGap.command}`);
605
674
  for (;;) {
606
675
  state.pendingAsk = { kind: 'install-needed', tree: prep.tree, command: depsGap.command, reason: depsGap.reason };
607
676
  // eslint-disable-next-line no-await-in-loop
@@ -834,6 +903,7 @@ export function createSession(card, deps = {}) {
834
903
  if (['refused', 'abandoned', 'error', 'signed', 'signing-failed'].includes(state.phase)) return { ok: false, error: 'this session is not live — nothing to abandon' };
835
904
  abandoned = true;
836
905
  phaseValue = 'abandoned';
906
+ dropWorktree();
837
907
  state.error = 'Abandoned by you — money already spent stays booked.';
838
908
  state.pendingAsk = null;
839
909
  if (resolvePending) { const r = resolvePending; resolvePending = null; r(null); }