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
package/src/panel/server.js
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
} from 'node:path';
|
|
32
32
|
import { fileURLToPath } from 'node:url';
|
|
33
33
|
import { readRunList, DIED_MTIME_MS, runIsAlive } from '../runlist.js';
|
|
34
|
+
import { draftingPartFor } from '../draftspend.js';
|
|
34
35
|
import {
|
|
35
36
|
replayOne, parseJsonl, resolveSiblings, isSidecarByName,
|
|
36
37
|
} from '../replayio.js';
|
|
@@ -52,7 +53,7 @@ import { rowsForHome, findRow, modelChoiceFor } from '../providerrows.js';
|
|
|
52
53
|
import { resolveProvider } from '../providers.js';
|
|
53
54
|
import { REUSE_LOCKED_FIELDS, REUSE_OPEN_FIELDS } from './authorsession.js';
|
|
54
55
|
import { readResume, checkpointAgeGate, CHECKPOINT_OUTCOMES } from '../reuse.js';
|
|
55
|
-
import { statusFor, GOAL_MET_LINE } from './status.js';
|
|
56
|
+
import { statusFor, partStatusFor, GOAL_MET_LINE } from './status.js';
|
|
56
57
|
import { createSettingsRoutes } from './settingsroutes.js';
|
|
57
58
|
|
|
58
59
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
@@ -321,6 +322,9 @@ export function resumePlanFor(row, records) {
|
|
|
321
322
|
if (!age.ok) return { ok: false, why: String(age.detail ?? 'its checkpoint has expired') };
|
|
322
323
|
const near = sourceNearSpine(row.spine);
|
|
323
324
|
if (!near.specPath) return { ok: false, why: 'the signed job file is not beside this run' };
|
|
325
|
+
// P6 item 1: a worktree run resumes IN its worktree — a folder that is gone is a resume the engine would refuse
|
|
326
|
+
const wt = worktreeOfRun(row, records);
|
|
327
|
+
if (wt && !wt.exists) return { ok: false, why: `its worktree folder ${wt.folder} is gone` };
|
|
324
328
|
const dir = dirname(near.specPath);
|
|
325
329
|
/** @type {string[]} */
|
|
326
330
|
let names = [];
|
|
@@ -407,10 +411,29 @@ const GOAL_NOT_MET = new Set(['plan-red', 'check-red', 'step-red', 'escalated'])
|
|
|
407
411
|
* (replaces P5 item 3, 2026-10-03) is offered on green rows only, and starts a NEW run.
|
|
408
412
|
* @param {{outcome: string|null, stopReason: string|null, spentUsd: number|null, budgetUsd: number|null, lastEscalation?: any}} summary
|
|
409
413
|
* @param {{died: boolean, lastThing: string|null}} death
|
|
410
|
-
* @param {{resume?: {ok: boolean, why?: string}|null, destinationRefused?: string|null, moneyHalt?: boolean
|
|
414
|
+
* @param {{resume?: {ok: boolean, why?: string}|null, destinationRefused?: string|null, moneyHalt?: boolean,
|
|
415
|
+
* worktree?: {folder: string, repo: string, branch: string|null, exists: boolean}|null}} [o]
|
|
416
|
+
* `worktree` (P6 item 1): the run's worktree in the person's repo, from {@link worktreeOfRun}; absent for every other run
|
|
411
417
|
* @returns {{reason: string, next: string, line: string, actions: {id: string, label: string}[]}|null}
|
|
412
418
|
*/
|
|
413
419
|
export function endedFor(summary, death, o = {}) {
|
|
420
|
+
const base = endedBase(summary, death, o);
|
|
421
|
+
const wt = o.worktree;
|
|
422
|
+
if (!base || !wt) return base;
|
|
423
|
+
// P6 item 1 — the run worked on a worktree in the person's own repo. GREEN: the branch carries the work (the folder is
|
|
424
|
+
// removed by the engine) and the merge stays the person's — the two commands are shown as TEXT, never run by a button.
|
|
425
|
+
// Any other ending: the worktree folder stays (Resume needs it), so the block names it.
|
|
426
|
+
const green = !death.died && (summary.outcome === 'green' || summary.outcome === 'already-green' || summary.outcome === 'satisfied');
|
|
427
|
+
if (green) {
|
|
428
|
+
if (!wt.branch) return base;
|
|
429
|
+
const kept = wt.exists ? ` The worktree folder ${wt.folder} is still there.` : '';
|
|
430
|
+
return { ...base, next: `In ${wt.repo}: git merge ${wt.branch} — or throw the work away: git branch -D ${wt.branch}.${kept}` };
|
|
431
|
+
}
|
|
432
|
+
return wt.exists ? { ...base, next: `${base.next} Your work is in ${wt.folder}.` } : base;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** @param {any} summary @param {any} death @param {any} o */
|
|
436
|
+
function endedBase(summary, death, o) {
|
|
414
437
|
const resumeOk = !!(o.resume && o.resume.ok);
|
|
415
438
|
/** @type {{id: string, label: string}[]} */
|
|
416
439
|
const RESUME = [{ id: 'resume', label: 'Resume' }];
|
|
@@ -572,7 +595,7 @@ function lastJobEndTs(/** @type {any[]} */ records) {
|
|
|
572
595
|
return null;
|
|
573
596
|
}
|
|
574
597
|
|
|
575
|
-
function endedForRow(row, records, summary, death) {
|
|
598
|
+
function endedForRow(row, records, summary, death, withWorktree = false) {
|
|
576
599
|
const out = summary.outcome;
|
|
577
600
|
const maybeResumable = death.died || out === 'escalated' || (typeof out === 'string' && CHECKPOINT_OUTCOMES.includes(out));
|
|
578
601
|
const resume = maybeResumable ? resumePlanFor(row, records) : null;
|
|
@@ -581,6 +604,7 @@ function endedForRow(row, records, summary, death) {
|
|
|
581
604
|
resume,
|
|
582
605
|
moneyHalt: records.some((r) => r && r.type === 'money-halt'),
|
|
583
606
|
destinationRefused: refused ? String(refused.detail ?? refused.code ?? 'the destination refused it') : null,
|
|
607
|
+
worktree: withWorktree ? worktreeOfRun(row, records) : null,
|
|
584
608
|
});
|
|
585
609
|
const esc = summary.lastEscalation;
|
|
586
610
|
const status = statusFor({
|
|
@@ -731,6 +755,40 @@ export function getStopContext(runid, opts = {}) {
|
|
|
731
755
|
return { row, live: runIsAlive(row), spineExists: existsSync(row.spine) };
|
|
732
756
|
}
|
|
733
757
|
|
|
758
|
+
/**
|
|
759
|
+
* @param {any} r
|
|
760
|
+
* @returns {number}
|
|
761
|
+
*/
|
|
762
|
+
function atMs(r) {
|
|
763
|
+
const t = typeof r?.at === 'string' ? Date.parse(r.at) : NaN;
|
|
764
|
+
return Number.isFinite(t) ? t : 0;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* The ONE owner of run names (P6 item 7): runs of the same job (`row.job`) are numbered oldest `at` first, 1-based
|
|
769
|
+
* (ties keep file order). A resume is one row, so it counts once; died and stopped runs are rows too. Every view
|
|
770
|
+
* reads this number — the page never counts on its own.
|
|
771
|
+
* @param {any[]} rows
|
|
772
|
+
* @returns {Map<string, number>} runid → N
|
|
773
|
+
*/
|
|
774
|
+
function runNumbers(rows) {
|
|
775
|
+
/** @type {Map<string, any[]>} */
|
|
776
|
+
const byJob = new Map();
|
|
777
|
+
rows.forEach((row, index) => {
|
|
778
|
+
if (!row || typeof row.runid !== 'string') return;
|
|
779
|
+
const g = byJob.get(row.job) || [];
|
|
780
|
+
g.push({ row, index });
|
|
781
|
+
byJob.set(row.job, g);
|
|
782
|
+
});
|
|
783
|
+
/** @type {Map<string, number>} */
|
|
784
|
+
const out = new Map();
|
|
785
|
+
for (const g of byJob.values()) {
|
|
786
|
+
g.sort((a, b) => (atMs(a.row) - atMs(b.row)) || (a.index - b.index));
|
|
787
|
+
g.forEach(({ row }, i) => out.set(row.runid, i + 1));
|
|
788
|
+
}
|
|
789
|
+
return out;
|
|
790
|
+
}
|
|
791
|
+
|
|
734
792
|
/**
|
|
735
793
|
* `GET /api/runs` — every listed run, NEWEST FIRST BY `at` (never by file/
|
|
736
794
|
* append order — a backfill scan appends rows in sorted-PATH order, which
|
|
@@ -749,14 +807,29 @@ export function getStopContext(runid, opts = {}) {
|
|
|
749
807
|
*/
|
|
750
808
|
export function listRuns(opts = {}) {
|
|
751
809
|
const { rows } = readRunList(opts);
|
|
752
|
-
const
|
|
753
|
-
const t = typeof r?.at === 'string' ? Date.parse(r.at) : NaN;
|
|
754
|
-
return Number.isFinite(t) ? t : 0;
|
|
755
|
-
};
|
|
810
|
+
const nos = runNumbers(rows);
|
|
756
811
|
return rows
|
|
757
812
|
.map((row, index) => ({ row, index })) // index: stable tie-break, see doc above
|
|
758
813
|
.sort((a, b) => (atMs(b.row) - atMs(a.row)) || (a.index - b.index))
|
|
759
|
-
.map(({ row }) => summarizeRow(row));
|
|
814
|
+
.map(({ row }) => ({ ...summarizeRow(row), runNo: nos.get(row.runid) }));
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* The run's DRAFTING PART (hamr 2026-10-06, option A), or null: it exists only when the run came out of a panel
|
|
819
|
+
* session whose drafting log carries a metered call (src/draftspend.js, `draftingPartFor`) and the spine carries the
|
|
820
|
+
* drafting figure. It is the FIRST element of `detail.parts`, so EVERY index a reader is handed — `detail.parts`,
|
|
821
|
+
* `legDividers.beforePart`, the flat Audit rows' `partIndex`, the rounds endpoint's `part` — lives in ONE index space
|
|
822
|
+
* (the shown list), shifted by {@link draftingShift} in exactly those four places and nowhere else.
|
|
823
|
+
* @param {any} row @param {any} summary a `replayOne` summary of the row's spine @param {{ home?: string }} opts
|
|
824
|
+
* @returns {any|null}
|
|
825
|
+
*/
|
|
826
|
+
function draftingFor(row, summary, opts) {
|
|
827
|
+
return draftingPartFor(row, { draftSpentUsd: summary.draftSpentUsd, draftSpendComplete: summary.draftSpendComplete }, opts);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/** @param {any} row @param {any} summary @param {{ home?: string }} opts @returns {0|1} how far the drafting part pushes every other part's index */
|
|
831
|
+
function draftingShift(row, summary, opts) {
|
|
832
|
+
return draftingFor(row, summary, opts) ? 1 : 0;
|
|
760
833
|
}
|
|
761
834
|
|
|
762
835
|
/**
|
|
@@ -789,6 +862,18 @@ function findRunRow(runid, opts) {
|
|
|
789
862
|
* @returns {any|null}
|
|
790
863
|
*/
|
|
791
864
|
export function getRunDetail(runid, opts = {}) {
|
|
865
|
+
const d = getRunDetailBody(runid, opts);
|
|
866
|
+
if (!d) return d;
|
|
867
|
+
const runNo = runNumbers(readRunList(opts).rows).get(runid);
|
|
868
|
+
return runNo === undefined ? d : { ...d, runNo };
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* @param {string} runid
|
|
873
|
+
* @param {{ home?: string }} opts
|
|
874
|
+
* @returns {any|null}
|
|
875
|
+
*/
|
|
876
|
+
function getRunDetailBody(runid, opts) {
|
|
792
877
|
const row = findRunRow(runid, opts);
|
|
793
878
|
if (!row) return null;
|
|
794
879
|
if (!existsSync(row.spine)) {
|
|
@@ -810,8 +895,12 @@ export function getRunDetail(runid, opts = {}) {
|
|
|
810
895
|
const auditPaths = resolveAuditPathsForRow(row, rawSpineRecords);
|
|
811
896
|
const summary = replayOne(row.spine, { auditPathOverride: auditPaths.length > 0 ? auditPaths : null });
|
|
812
897
|
const timelineKind = summary.timelineKind;
|
|
898
|
+
// the drafting part (see draftingFor) heads the parts list; the enrichment below touches the spine's own parts only
|
|
899
|
+
const draftingPartHere = draftingFor(row, summary, opts);
|
|
900
|
+
const draftingShiftHere = draftingShift(row, summary, opts);
|
|
901
|
+
const enrichedParts = enrichPartsWithStageKind(summary.parts, stageKindMetaFromSpec(resolveSpecForRow(row)));
|
|
813
902
|
const death = deriveDeath(row, rawSpineRecords, summary.outcome);
|
|
814
|
-
const { ended, resume, status } = endedForRow(row, rawSpineRecords, summary, death);
|
|
903
|
+
const { ended, resume, status } = endedForRow(row, rawSpineRecords, summary, death, true);
|
|
815
904
|
// judgeModel (Summary box "judge:" line): the FIRST `judge-round`'s own
|
|
816
905
|
// `model` field (src/planrun.js:1704's `onJudgeCost` emit) — a soft-green
|
|
817
906
|
// close's own paid judge seam, distinct from the worker `model` above.
|
|
@@ -980,7 +1069,8 @@ export function getRunDetail(runid, opts = {}) {
|
|
|
980
1069
|
// P5-R: the run's legs in order (`after` = how the leg before ended) and the resume count — the Audit tab's
|
|
981
1070
|
// dividers and the map's connectors read these; never a second derivation of where a leg starts
|
|
982
1071
|
legs: summary.legs,
|
|
983
|
-
|
|
1072
|
+
// drafting belongs to leg 1: a divider's `beforePart` moves with the parts list it indexes
|
|
1073
|
+
legDividers: legDividersFor(rawSpineRecords, summary.parts).map((d) => ({ ...d, beforePart: d.beforePart + draftingShiftHere })),
|
|
984
1074
|
resumedCount: Math.max(0, summary.legs.length - 1),
|
|
985
1075
|
resume: resume && resume.ok ? {
|
|
986
1076
|
budgetUsd: resume.budgetUsd, maxWallMin: resume.maxWallMin, spentUsd: resume.spentUsd, spendComplete: resume.spendComplete,
|
|
@@ -1027,7 +1117,9 @@ export function getRunDetail(runid, opts = {}) {
|
|
|
1027
1117
|
// {@link enrichPartsWithStageKind}. `kindMeta` is `null` (every stage
|
|
1028
1118
|
// passes through unchanged) whenever no spec resolves at all, so the two
|
|
1029
1119
|
// tabs still never disagree about order/counts/blocked-call figures.
|
|
1030
|
-
|
|
1120
|
+
// a run that DIED (no job-end, runner gone — `death`, decided once above) leaves its last part in flight: that part reads
|
|
1121
|
+
// `died`, never the "happened, so passed" default. Replay alone cannot tell died from still running, so the verdict lives here.
|
|
1122
|
+
parts: (draftingPartHere && Array.isArray(enrichedParts) ? [draftingPartHere, ...enrichedParts] : enrichedParts)?.map((p, i, all) => ({ ...p, status: partStatusFor(death.died && i === all.length - 1 && p.kind !== 'drafting' && p.outcome == null && !p.stopReason ? { ...p, stopReason: 'died' } : p) })),
|
|
1031
1123
|
replans: summary.replans,
|
|
1032
1124
|
close: summary.close,
|
|
1033
1125
|
branch: summary.branch,
|
|
@@ -1379,6 +1471,7 @@ export function getRunAudit(runid, opts = {}) {
|
|
|
1379
1471
|
skipAudit: true,
|
|
1380
1472
|
});
|
|
1381
1473
|
const partOf = makePartLookup(summary);
|
|
1474
|
+
const shift = draftingShift(row, summary, opts); // the drafting part heads the shown list: every index moves with it
|
|
1382
1475
|
|
|
1383
1476
|
const { records } = { records: auditPaths.flatMap((ap) => parseJsonl(ap).records) };
|
|
1384
1477
|
const windowed = records.filter((r) => r && typeof r === 'object' && typeof r.ts === 'string' && inAnyWindow(r.ts, windows));
|
|
@@ -1419,7 +1512,7 @@ export function getRunAudit(runid, opts = {}) {
|
|
|
1419
1512
|
path,
|
|
1420
1513
|
pathShort: shortenPath(path, treeRoot),
|
|
1421
1514
|
decision: typeof r.decision === 'string' ? r.decision : null,
|
|
1422
|
-
partIndex,
|
|
1515
|
+
partIndex: partIndex === null ? null : partIndex + shift,
|
|
1423
1516
|
partLabel,
|
|
1424
1517
|
attemptN,
|
|
1425
1518
|
reason,
|
|
@@ -1488,7 +1581,11 @@ export function getRunRounds(runid, query = {}, opts = {}) {
|
|
|
1488
1581
|
? Math.min(query.limit, ROUNDS_PAGE_MAX) : ROUNDS_PAGE_DEFAULT;
|
|
1489
1582
|
|
|
1490
1583
|
const summary = replayOne(row.spine);
|
|
1491
|
-
|
|
1584
|
+
// `part` is an index into the SHOWN list (`detail.parts`): when the drafting part heads it, index 0 is the drafting
|
|
1585
|
+
// part (it has no rounds table) and the spine's own part k is shown at k + 1
|
|
1586
|
+
const shift = draftingShift(row, summary, opts);
|
|
1587
|
+
if (shift && partIndex === 0) return null;
|
|
1588
|
+
const part = Array.isArray(summary.parts) ? summary.parts[partIndex - shift] : null;
|
|
1492
1589
|
if (!part) return null;
|
|
1493
1590
|
const a = Array.isArray(part.attempts) ? part.attempts[attempt - 1] : null;
|
|
1494
1591
|
if (!a) return null;
|
|
@@ -1616,6 +1713,25 @@ function sourceNearSpine(spinePath) {
|
|
|
1616
1713
|
};
|
|
1617
1714
|
}
|
|
1618
1715
|
|
|
1716
|
+
/**
|
|
1717
|
+
* P6 item 1 — the worktree a run worked on in the person's own repo, read off the run's own `source.json` (the manifest
|
|
1718
|
+
* the source door wrote: `worktree` + `repo`) and its spine's `work-branch` record. `null` for every run that has none
|
|
1719
|
+
* (a copied-tree run, a bundle run, an import). `exists` is whether the folder is still on disk: a green run's folder is
|
|
1720
|
+
* removed by the engine, any other ending leaves it.
|
|
1721
|
+
* @param {{ spine: string }} row
|
|
1722
|
+
* @param {any[]} records the run's raw spine records
|
|
1723
|
+
* @returns {{folder: string, repo: string, branch: string|null, exists: boolean}|null}
|
|
1724
|
+
*/
|
|
1725
|
+
function worktreeOfRun(row, records) {
|
|
1726
|
+
const near = sourceNearSpine(row.spine);
|
|
1727
|
+
if (!near.sourceJsonPath) return null;
|
|
1728
|
+
let m;
|
|
1729
|
+
try { m = JSON.parse(readFileSync(near.sourceJsonPath, 'utf8')); } catch { return null; }
|
|
1730
|
+
if (typeof m?.worktree !== 'string' || m.worktree === '' || typeof m?.repo !== 'string') return null;
|
|
1731
|
+
const wb = records.findLast((r) => r && r.type === 'work-branch' && typeof r.branch === 'string') ?? null;
|
|
1732
|
+
return { folder: m.worktree, repo: m.repo, branch: wb ? wb.branch : null, exists: existsSync(m.worktree) };
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1619
1735
|
/**
|
|
1620
1736
|
* The resolved SPEC OBJECT for a run — routes (a) bundle `spec.json`, (b)
|
|
1621
1737
|
* `jobs/<job>.json`, (c) the run's own `resolved-spec.json` beside the spine
|
|
@@ -2345,6 +2461,39 @@ export function getStartFrom(runid, opts = {}) {
|
|
|
2345
2461
|
};
|
|
2346
2462
|
}
|
|
2347
2463
|
|
|
2464
|
+
/**
|
|
2465
|
+
* The Reuse workflow picker's list: one entry per JOB (same-job identity = `workflowKey`, the one `getStartFrom` uses)
|
|
2466
|
+
* that has at least one GREEN local run, newest green first. `runid` = that job's newest green run, so the page hands
|
|
2467
|
+
* it to the existing start-from flow unchanged; `line` is `getStartFrom`'s own estimate line (never a second
|
|
2468
|
+
* spelling). Local runs only — an imported job keeps its Reuse button in the Import view. $0, read-only.
|
|
2469
|
+
* @param {{ home?: string, model?: string }} [opts] `model` passes through to the estimate line, as start-from's does
|
|
2470
|
+
* @returns {{job: string, runid: string, checkType: 'deterministic'|'rubric', line: string}[]}
|
|
2471
|
+
*/
|
|
2472
|
+
export function listReuseJobs(opts = {}) {
|
|
2473
|
+
const { rows } = readRunList(opts);
|
|
2474
|
+
const seen = new Set();
|
|
2475
|
+
/** @type {{job: string, runid: string, checkType: 'deterministic'|'rubric', line: string}[]} */
|
|
2476
|
+
const jobs = [];
|
|
2477
|
+
for (let i = rows.length - 1; i >= 0; i -= 1) {
|
|
2478
|
+
const row = rows[i];
|
|
2479
|
+
if (!row || !existsSync(row.spine)) continue;
|
|
2480
|
+
let records;
|
|
2481
|
+
try { records = parseJsonl(row.spine).records; } catch { continue; }
|
|
2482
|
+
const outcome = legsOf(records).at(-1)?.outcome ?? null;
|
|
2483
|
+
// the same endings the Run tab's own Reuse button is offered on (`endedBase`)
|
|
2484
|
+
if (outcome !== 'green' && outcome !== 'already-green' && outcome !== 'satisfied') continue;
|
|
2485
|
+
const signed = signedSpecForRun(row, records);
|
|
2486
|
+
if (!signed) continue;
|
|
2487
|
+
const key = workflowKey(signed.spec);
|
|
2488
|
+
if (seen.has(key)) continue;
|
|
2489
|
+
const pre = getStartFrom(row.runid, opts);
|
|
2490
|
+
if (!pre || !pre.ok) continue;
|
|
2491
|
+
seen.add(key);
|
|
2492
|
+
jobs.push({ job: pre.card.jobName, runid: row.runid, checkType: pre.card.checkType === 'rubric' ? 'rubric' : 'deterministic', line: pre.line });
|
|
2493
|
+
}
|
|
2494
|
+
return jobs;
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2348
2497
|
/**
|
|
2349
2498
|
* The reuse card for a signed spec: locked boxes from the spec through the Job tab's own readers, open boxes from
|
|
2350
2499
|
* the spec's own fence and caps (Source and the saved text from the caller — a spec carries neither).
|
|
@@ -2522,11 +2671,19 @@ export function handleRequest(req, res, opts) {
|
|
|
2522
2671
|
/** @type {any} */
|
|
2523
2672
|
let body = null;
|
|
2524
2673
|
if (raw.length > 0) { try { body = JSON.parse(raw); } catch { body = null; } }
|
|
2525
|
-
|
|
2674
|
+
// this callback runs OUTSIDE createServer's try/catch (which covers only the synchronous GET path), so a
|
|
2675
|
+
// synchronous throw in a route handler (a full disk, a permission fault) would be an uncaught exception that
|
|
2676
|
+
// takes the whole panel down: answer 500 JSON instead and keep serving
|
|
2677
|
+
try {
|
|
2678
|
+
routes.handle(req, res, pathname, body);
|
|
2679
|
+
} catch (e) {
|
|
2680
|
+
if (res.headersSent) { res.end(); return; }
|
|
2681
|
+
sendJson(res, 500, { ok: false, error: `internal error: ${/** @type {Error} */ (e)?.message ?? String(e)}` });
|
|
2682
|
+
}
|
|
2526
2683
|
});
|
|
2527
2684
|
return;
|
|
2528
2685
|
}
|
|
2529
|
-
sendText(res, 405, 'method not allowed — this
|
|
2686
|
+
sendText(res, 405, 'method not allowed — this route does not accept this method (GET/HEAD only)');
|
|
2530
2687
|
return;
|
|
2531
2688
|
}
|
|
2532
2689
|
const getRoutes = routesFor(opts, pathname);
|
|
@@ -2635,7 +2792,8 @@ export function handleRequest(req, res, opts) {
|
|
|
2635
2792
|
* this package's own `bin/bareloop.mjs`).
|
|
2636
2793
|
* @param {{ port?: number, home?: string, env?: Record<string,string|undefined>,
|
|
2637
2794
|
* sessionsRoot?: string, spawnFn?: (...a: any[]) => any, bareloopBin?: string,
|
|
2638
|
-
* fetchImpl?: typeof fetch, settleMs?: number, userHome?: string
|
|
2795
|
+
* fetchImpl?: typeof fetch, settleMs?: number, userHome?: string,
|
|
2796
|
+
* openFolderSpawn?: (cmd: string, args: string[], o: object) => any, platform?: string }} [opts]
|
|
2639
2797
|
* @returns {Promise<{ server: import('node:http').Server, port: number, token: string, close: () => Promise<void> }>}
|
|
2640
2798
|
*/
|
|
2641
2799
|
export function createPanelServer(opts = {}) {
|
|
@@ -2686,10 +2844,12 @@ export function createPanelServer(opts = {}) {
|
|
|
2686
2844
|
startFrom: {
|
|
2687
2845
|
get: (runid, o = {}) => getStartFrom(runid, { home, ...o }),
|
|
2688
2846
|
getImport: (id, o = {}) => getStartFromImport(id, { home, ...o }),
|
|
2847
|
+
listJobs: (o = {}) => listReuseJobs({ home, ...o }),
|
|
2689
2848
|
},
|
|
2690
2849
|
});
|
|
2691
2850
|
settingsRoutes = createSettingsRoutes({
|
|
2692
2851
|
port: boundPort, token, home, env: opts.env, fetchImpl: opts.fetchImpl,
|
|
2852
|
+
openFolderSpawn: opts.openFolderSpawn, platform: opts.platform,
|
|
2693
2853
|
});
|
|
2694
2854
|
runRoutes = createRunRoutes({
|
|
2695
2855
|
port: boundPort,
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
import { checkHumanGuard } from './authorroutes.js';
|
|
15
15
|
import { readConfig, updateConfig, ConfigError } from '../config.js';
|
|
16
16
|
import { spendSummary, monthlyLimitOf } from '../monthly.js';
|
|
17
|
-
import {
|
|
17
|
+
import { spawn as realSpawn } from 'node:child_process';
|
|
18
|
+
import { loadKeysEnv, keysFilePath, keysHome, filledKeyNames, ensureKeysFile } from '../keysfile.js';
|
|
18
19
|
import { SHAPES, PRESET_KEY_NAMES, keyRows, endpointOf, defaultUrlOf, usableKey } from '../providerrows.js';
|
|
19
20
|
import { apiKeyProblem, checkProviderReachable } from '../providers.js';
|
|
20
21
|
|
|
@@ -22,8 +23,11 @@ import { apiKeyProblem, checkProviderReachable } from '../providers.js';
|
|
|
22
23
|
const DEEPSEEK_BALANCE_URL = 'https://api.deepseek.com/user/balance';
|
|
23
24
|
|
|
24
25
|
/**
|
|
26
|
+
* `openFolderSpawn` / `platform` are test seams for the Open keys folder button (a real caller passes
|
|
27
|
+
* neither: `child_process.spawn` and `process.platform`).
|
|
25
28
|
* @param {{ port: number, token: string, home?: string, env?: Record<string,string|undefined>,
|
|
26
|
-
* fetchImpl?: typeof fetch, now?: () => number
|
|
29
|
+
* fetchImpl?: typeof fetch, now?: () => number, openFolderSpawn?: (cmd: string, args: string[], o: object) => any,
|
|
30
|
+
* platform?: string }} opts
|
|
27
31
|
*/
|
|
28
32
|
export function createSettingsRoutes(opts) {
|
|
29
33
|
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
@@ -134,6 +138,8 @@ export function createSettingsRoutes(opts) {
|
|
|
134
138
|
placeholder: defaultUrlOf(r.provider),
|
|
135
139
|
keyStatus: !raw ? 'not set' : (raw === 'null' ? 'no key needed' : (problem ? `bad shape (${problem})` : 'found')),
|
|
136
140
|
canTest: r.provider !== 'gemini-api',
|
|
141
|
+
priceInPerM: typeof r.priceInPerM === 'number' ? r.priceInPerM : null,
|
|
142
|
+
priceOutPerM: typeof r.priceOutPerM === 'number' ? r.priceOutPerM : null,
|
|
137
143
|
tokens: s.tokensByRow[r.envName] ?? 0,
|
|
138
144
|
balance: r.provider === 'anthropic-api'
|
|
139
145
|
? { kind: 'note', usd: typeof note === 'number' && Number.isFinite(note) ? note : null }
|
|
@@ -148,11 +154,32 @@ export function createSettingsRoutes(opts) {
|
|
|
148
154
|
const row = currentRows(readConfig({ home: opts.home }).config).find((r) => r.envName === body?.envName);
|
|
149
155
|
if (!row) { send(400, { ok: false, error: 'unknown key — add NAME=key to your keys file and reload keys' }); return true; }
|
|
150
156
|
// only the three settings a row has; each is optional in the body, the whole triple is stored
|
|
157
|
+
/** @type {Record<string, string|number|null>} */
|
|
151
158
|
const patch = {
|
|
152
159
|
name: typeof body?.name === 'string' ? body.name.trim() : row.name,
|
|
153
160
|
shape: typeof body?.shape === 'string' ? body.shape : row.provider,
|
|
154
161
|
baseUrl: typeof body?.baseUrl === 'string' ? body.baseUrl.trim().replace(/\/+$/, '') : row.baseUrl,
|
|
155
162
|
};
|
|
163
|
+
// the two prices (USD per 1M tokens): absent = unchanged; blank / null = not set (the field is removed,
|
|
164
|
+
// never written as 0); otherwise a finite number >= 0 — anything else is refused and nothing is saved
|
|
165
|
+
for (const f of /** @type {const} */ (['priceInPerM', 'priceOutPerM'])) {
|
|
166
|
+
if (!Object.hasOwn(body ?? {}, f)) continue;
|
|
167
|
+
const v = body[f];
|
|
168
|
+
const label = f === 'priceInPerM' ? 'In' : 'Out';
|
|
169
|
+
if (v === null || (typeof v === 'string' && v.trim() === '')) { patch[f] = null; continue; }
|
|
170
|
+
const n = typeof v === 'number' ? v : (typeof v === 'string' && /^\d+(\.\d+)?$/.test(v.trim()) ? Number(v.trim()) : NaN);
|
|
171
|
+
if (!(Number.isFinite(n) && n >= 0)) {
|
|
172
|
+
send(400, { ok: false, error: `the ${label} price must be a number, 0 or more (USD per 1M tokens), or blank for not set` });
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
patch[f] = n;
|
|
176
|
+
}
|
|
177
|
+
// a price is both fields or neither — ratesFor refuses a half-set one at run time, so never save half
|
|
178
|
+
const after = (/** @type {'priceInPerM'|'priceOutPerM'} */ f) => (Object.hasOwn(patch, f) ? patch[f] !== null : row[f] !== undefined);
|
|
179
|
+
if (after('priceInPerM') !== after('priceOutPerM')) {
|
|
180
|
+
send(400, { ok: false, error: 'set both prices (In and Out, USD per 1M tokens) or neither — a half-set price is refused' });
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
156
183
|
try {
|
|
157
184
|
updateConfig({ keys: { [row.envName]: patch } }, { home: opts.home });
|
|
158
185
|
} catch (e) {
|
|
@@ -164,6 +191,29 @@ export function createSettingsRoutes(opts) {
|
|
|
164
191
|
return true;
|
|
165
192
|
}
|
|
166
193
|
|
|
194
|
+
if (pathname === '/api/settings/open-keys-folder' && req.method === 'POST') {
|
|
195
|
+
// opens the FOLDER (never the file, never an editor) in the file manager; argv array, no shell, detached.
|
|
196
|
+
// Another OS, or a failed spawn: say so and hand back the path, which the page then shows as text.
|
|
197
|
+
const dir = keysHome(opts.home);
|
|
198
|
+
const platform = opts.platform ?? process.platform;
|
|
199
|
+
const cmd = platform === 'linux' ? 'xdg-open' : (platform === 'darwin' ? 'open' : null);
|
|
200
|
+
const cannot = () => send(200, { ok: true, opened: false, path: dir });
|
|
201
|
+
if (!cmd) { cannot(); return true; }
|
|
202
|
+
let child;
|
|
203
|
+
try {
|
|
204
|
+
child = (opts.openFolderSpawn ?? realSpawn)(cmd, [dir], { detached: true, stdio: 'ignore' });
|
|
205
|
+
} catch { cannot(); return true; }
|
|
206
|
+
let settled = false;
|
|
207
|
+
child.once('error', () => { if (!settled) { settled = true; cannot(); } });
|
|
208
|
+
child.once('spawn', () => {
|
|
209
|
+
if (settled) return;
|
|
210
|
+
settled = true;
|
|
211
|
+
if (typeof child.unref === 'function') child.unref();
|
|
212
|
+
send(200, { ok: true, opened: true, path: dir });
|
|
213
|
+
});
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
|
|
167
217
|
if (pathname === '/api/settings/providers/balance-note' && req.method === 'POST') {
|
|
168
218
|
// the Anthropic balance is a NOTE the person types; no check ever reads it
|
|
169
219
|
const raw = body?.usd;
|
package/src/panel/status.js
CHANGED
|
@@ -49,3 +49,25 @@ export function statusFor(o) {
|
|
|
49
49
|
else if (out === 'escalated' && ((o.category === 'cap-halt' && o.moneyHalt) || o.category === 'wall-halt')) key = 'capped';
|
|
50
50
|
return { key, ...STATUS[key] };
|
|
51
51
|
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The word one PART of a run wears on the step map, the step cards and the Audit rows (hamr 2026-10-07: `[✓] passed` /
|
|
55
|
+
* `[✗] failed`, never "done"). Reads the same table; `running` is the page's call (it knows which part is live), so a
|
|
56
|
+
* part with no verdict and no cut-off is `passed` here.
|
|
57
|
+
* drafting, green/satisfied/already-green outcome, or a part that simply happened (scout/plan/replan/judge) -> passed
|
|
58
|
+
* a red outcome -> its run-outcome word (failed; a cap/stop outcome keeps capped/stopped)
|
|
59
|
+
* a part its leg's halt cut off (`stopReason`): 'money cap' / 'time cap' -> capped, 'you stopped it' -> stopped,
|
|
60
|
+
* 'died' -> died, anything else (provider failed, step stalled, a raw outcome) -> failed
|
|
61
|
+
* @param {{kind?: string, outcome?: string|null, stopReason?: string|null}} part
|
|
62
|
+
* @returns {{key: StatusKey, sign: string, word: string}}
|
|
63
|
+
*/
|
|
64
|
+
export function partStatusFor(part) {
|
|
65
|
+
if (part.kind === 'drafting') return statusFor({ outcome: 'green' });
|
|
66
|
+
if (typeof part.outcome === 'string') return statusFor({ outcome: part.outcome });
|
|
67
|
+
const why = part.stopReason;
|
|
68
|
+
if (why) {
|
|
69
|
+
const key = why === 'money cap' || why === 'time cap' ? 'capped' : why === 'you stopped it' ? 'stopped' : why === 'died' ? 'died' : 'failed';
|
|
70
|
+
return { key, ...STATUS[key] };
|
|
71
|
+
}
|
|
72
|
+
return statusFor({ outcome: 'green' });
|
|
73
|
+
}
|
package/src/planrun.js
CHANGED
|
@@ -970,10 +970,10 @@ ${scoutBlob || '(no scout notes)'}`;
|
|
|
970
970
|
* the resume-plan-red option, the fix-loop terminal) say "resume is `run-u`-only
|
|
971
971
|
* in v1" instead of naming a flag that would fail if typed.
|
|
972
972
|
* @param {string|null} [opts.stopFile=null] P5 item 5 — the run's STOP REQUEST file
|
|
973
|
-
* (`stopFilePath(spine)`, src/legs.js). Read at ONE seam, the round boundary of
|
|
974
|
-
* (the `metered` callback, where the money cap binds): present = emit `stop-requested`, consume
|
|
973
|
+
* (`stopFilePath(spine)`, src/legs.js). Read at ONE seam, the round boundary of EVERY worker
|
|
974
|
+
* (scout, plan/replan drafter, step, close-fix: the `metered` callback, where the money cap binds): present = emit `stop-requested`, consume
|
|
975
975
|
* the file, end the Loop after that round, and end the leg `stopped` (a checkpoint outcome, filed
|
|
976
|
-
* the way a mid-step cap-halt is; resume re-enters that step). Nothing is in flight at that
|
|
976
|
+
* the way a mid-step cap-halt is; resume re-enters that step, or continues the phase). Nothing is in flight at that
|
|
977
977
|
* point, so the spend is exact. `null` = no stop surface (the default).
|
|
978
978
|
* @returns {Promise<string>} 'green' | 'already-green' | 'escalated' | 'plan-red' |
|
|
979
979
|
* 'check-red' | 'close-red' | 'close-unsupported' | 'recipe-stale' | 'pricing-red' |
|
|
@@ -2745,11 +2745,12 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
2745
2745
|
// (nothing in flight, the round just metered). It is the ONE owner of the stop request. The file is
|
|
2746
2746
|
// consumed here; `self.stop()` ends the Loop after this round exactly as a bound does; `ask`/`askFrom`
|
|
2747
2747
|
// then throw category `stopped`, which the step loop files as the checkpoint outcome `stopped` the way a
|
|
2748
|
-
// mid-step cap-halt is filed.
|
|
2749
|
-
|
|
2748
|
+
// mid-step cap-halt is filed. EVERY phase that runs rounds reads it here (scout, plan/replan drafter, step,
|
|
2749
|
+
// close-fix) — the one seam the money cap binds at; `relay` files the non-ralph phases' throw as `stopped`.
|
|
2750
|
+
if (stopFile !== null && !stopHit && existsSync(stopFile)) {
|
|
2750
2751
|
try { unlinkSync(stopFile); } catch { /* already consumed */ }
|
|
2751
2752
|
stopHit = true;
|
|
2752
|
-
emit('stop-requested', { phase, step: phase.slice('step:'.length), iteration: roundIteration, round: roundsThisAttempt, meaning: 'the person asked to stop; the leg ends after this round and a resume
|
|
2753
|
+
emit('stop-requested', { phase, ...(phase.startsWith('step:') ? { step: phase.slice('step:'.length) } : {}), iteration: roundIteration, round: roundsThisAttempt, meaning: 'the person asked to stop; the leg ends after this round and a resume continues the run' });
|
|
2753
2754
|
self.stop();
|
|
2754
2755
|
} else if (clock.expired()) {
|
|
2755
2756
|
attemptBounded = { iteration: roundIteration, cause: 'wall', reason: null };
|
|
@@ -2903,6 +2904,8 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
2903
2904
|
// instead and every such fault came back `provider-red` — a broken runner
|
|
2904
2905
|
// reported as the provider's fault, and an upstream ask aimed at a library
|
|
2905
2906
|
// for our own missing wiring (the step-stalled lesson).
|
|
2907
|
+
// P5 item 5 — the PERSON's stop, honoured at a round boundary outside a step (scout, plan drafting, replan): nothing failed
|
|
2908
|
+
'stopped': [`You stopped the run during ${phase}. Nothing is discarded: the work on disk stands, and a resume continues the run.`, ['resume (the same run continues)', 'abandon the task']],
|
|
2906
2909
|
'interpreter-red': [`The ${phase} phase could not be set up — the runner or a primitive it was handed is not correctly bound.`, ['fix the wiring the detail names', 'retry the run', 'abandon the run']],
|
|
2907
2910
|
};
|
|
2908
2911
|
const [decision, options] = (Object.hasOwn(DECIDE, category) ? DECIDE[category] : undefined)
|
|
@@ -2914,7 +2917,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
|
|
|
2914
2917
|
// `step-stalled` rides out as ITSELF, not as provider-red. Naming the
|
|
2915
2918
|
// escalation while returning a casualty label would launder a governance stop
|
|
2916
2919
|
// into transport noise at exactly the layer the readout reads.
|
|
2917
|
-
return category === 'cap-halt' || category === 'wall-halt' || category === 'step-stalled' || category === 'interpreter-red'
|
|
2920
|
+
return category === 'cap-halt' || category === 'wall-halt' || category === 'step-stalled' || category === 'interpreter-red' || category === 'stopped'
|
|
2918
2921
|
? category : 'provider-red';
|
|
2919
2922
|
};
|
|
2920
2923
|
|
package/src/ralph.js
CHANGED
|
@@ -49,7 +49,7 @@ const CLOSE_KILL_GRACE_MS = 2000;
|
|
|
49
49
|
* @param {{env: NodeJS.ProcessEnv, cwd?: string, timeoutMs?: number}} o
|
|
50
50
|
* @returns {Promise<{error: (Error & {code?: string})|null, status: number|null, signal: string|null, stdout: string, stderr: string}>}
|
|
51
51
|
*/
|
|
52
|
-
function spawnClose(cmd, args, { env, cwd, timeoutMs }) {
|
|
52
|
+
export function spawnClose(cmd, args, { env, cwd, timeoutMs }) {
|
|
53
53
|
return new Promise((resolve) => {
|
|
54
54
|
const child = spawn(cmd, args, { env, cwd });
|
|
55
55
|
/** @type {Buffer[]} */ const outChunks = [];
|
package/src/source.js
CHANGED
|
@@ -27,6 +27,7 @@ import { existsSync, lstatSync, readFileSync } from 'node:fs';
|
|
|
27
27
|
import { mkdir, readdir, readFile, writeFile, lstat, stat, chmod, access, copyFile, cp, open, rm, realpath, readlink, symlink, constants as fsConstants } from 'node:fs/promises';
|
|
28
28
|
import { dirname, join, relative, resolve, sep, basename } from 'node:path';
|
|
29
29
|
import { git } from './kinds.js';
|
|
30
|
+
import { addWorktree, hideBareloopDir } from './worktree.js';
|
|
30
31
|
import { MAX_BUFFER } from './kinds.js';
|
|
31
32
|
import { PROVIDER_TIMEOUT_MS } from './clock.js';
|
|
32
33
|
import { scanSecrets, SECRET_PATTERNS, SECRET_PATTERN_NAMES } from './validate.js';
|
|
@@ -440,16 +441,21 @@ async function fetchOnce(url, timeoutMs) {
|
|
|
440
441
|
* as declared, unvalidated by `proveDestination`, and `frontDoorFromManifest`
|
|
441
442
|
* never hands a repo source's destination to `copyOut` — wiring it into
|
|
442
443
|
* `writeScope` is M3/M4's job, not this one.
|
|
443
|
-
*
|
|
444
|
+
* `worktree` (P6 item 1, repo sources ONLY — ignored for every other kind): instead of COPYING the repo, make the
|
|
445
|
+
* run's tree a detached git worktree of the person's own repo at that directory (the shared
|
|
446
|
+
* `src/worktree.js`, the bundle door's own spelling), hide `.bareloop/` from their `git status`, and record the
|
|
447
|
+
* worktree and the repo root in the manifest. `into` then holds only `source.json`; the tree is `worktree`, at the
|
|
448
|
+
* repo's current commit (uncommitted edits are NOT in it). No seed commit is made — the seed IS the repo's HEAD.
|
|
449
|
+
* @param {{source: string, into: string, destination?: string, worktree?: string,
|
|
444
450
|
* fetchTimeoutMs?: number, afterScan?: () => (void|Promise<void>)}} args
|
|
445
451
|
* `fetchTimeoutMs` is test-only — production callers omit it and get
|
|
446
452
|
* `PROVIDER_TIMEOUT_MS`. `afterScan` is a test seam too: called exactly once,
|
|
447
453
|
* after the secret scan finished and before the freeze loop re-reads any
|
|
448
454
|
* file's bytes, so a test can swap a file inside that window
|
|
449
455
|
* deterministically. Production never sets it; absent, behaviour is unchanged.
|
|
450
|
-
* @returns {Promise<{stop: null, into: string, tree: string, manifestPath: string, manifest:
|
|
456
|
+
* @returns {Promise<{stop: null, into: string, tree: string, manifestPath: string, manifest: Record<string, any>}|SourceRefusal>}
|
|
451
457
|
*/
|
|
452
|
-
export async function prepareSource({ source, into, destination, fetchTimeoutMs = PROVIDER_TIMEOUT_MS, afterScan }) {
|
|
458
|
+
export async function prepareSource({ source, into, destination, worktree, fetchTimeoutMs = PROVIDER_TIMEOUT_MS, afterScan }) {
|
|
453
459
|
const intoAbs = resolve(into);
|
|
454
460
|
if (existsSync(intoAbs)) {
|
|
455
461
|
return refuse('into-exists', `${intoAbs} already exists — a source door writes a FRESH tree, never reuses one (the export worktree rule)`);
|
|
@@ -634,6 +640,9 @@ export async function prepareSource({ source, into, destination, fetchTimeoutMs
|
|
|
634
640
|
const buf = f.buf ?? (f.symlinkTarget !== undefined ? Buffer.from(f.symlinkTarget, 'utf8') : await readFile(/** @type {string} */ (f.abs)));
|
|
635
641
|
fileMeta.push({ path: f.rel, bytes: buf.length, sha256: sha256Hex(buf) });
|
|
636
642
|
const isBinary = f.symlinkTarget === undefined && hasNulByte(buf.subarray(0, 8192));
|
|
643
|
+
// P6 item 2 (hamr 2026-10-05 Q2=A): a repo going onto a WORKTREE is the person's OWN repo, left in place — a secret
|
|
644
|
+
// already in it is MASKED wherever bareloop records it (`redactSecrets`), not a refusal. Every other kind keeps the scan.
|
|
645
|
+
if (worktree !== undefined && frozen.kind === 'repo') continue;
|
|
637
646
|
const names = secretPatternNames(buf.toString(isBinary ? 'latin1' : 'utf8'));
|
|
638
647
|
if (names.length) secretHits.push({ rel: f.rel, names });
|
|
639
648
|
}
|
|
@@ -642,6 +651,34 @@ export async function prepareSource({ source, into, destination, fetchTimeoutMs
|
|
|
642
651
|
return refuse('source-carries-secret', `${secretHits.length} file(s) carry a known secret shape — refused before anything was written (hard line #3, secrets never enter the tree): ${detail}`);
|
|
643
652
|
}
|
|
644
653
|
|
|
654
|
+
if (worktree !== undefined && frozen.kind === 'repo') {
|
|
655
|
+
const repoRoot = /** @type {string} */ (frozen.root);
|
|
656
|
+
const wt = resolve(worktree);
|
|
657
|
+
try {
|
|
658
|
+
hideBareloopDir(repoRoot);
|
|
659
|
+
addWorktree(repoRoot, wt);
|
|
660
|
+
} catch (e) {
|
|
661
|
+
return refuse('source-worktree-failed', `could not make the job's worktree in ${repoRoot}: ${/** @type {Error} */ (e).message} (the repo needs at least one commit)`);
|
|
662
|
+
}
|
|
663
|
+
const headAt = await git(wt, ['rev-parse', 'HEAD']);
|
|
664
|
+
if (!headAt.ok) return refuse('source-git-failed', `git rev-parse HEAD failed in ${wt}: ${headAt.err}`);
|
|
665
|
+
const wtManifest = {
|
|
666
|
+
kind: 'repo',
|
|
667
|
+
source,
|
|
668
|
+
...(frozen.sourceSubdir === undefined ? {} : { sourceSubdir: frozen.sourceSubdir }),
|
|
669
|
+
fetchedAt: new Date().toISOString(),
|
|
670
|
+
files: fileMeta,
|
|
671
|
+
seed: headAt.out.trim(),
|
|
672
|
+
destination: destination ?? null,
|
|
673
|
+
worktree: wt,
|
|
674
|
+
repo: repoRoot,
|
|
675
|
+
};
|
|
676
|
+
await mkdir(intoAbs, { recursive: true });
|
|
677
|
+
const wtManifestPath = join(intoAbs, 'source.json');
|
|
678
|
+
await writeFile(wtManifestPath, `${JSON.stringify(wtManifest, null, 2)}\n`);
|
|
679
|
+
return { stop: null, into: intoAbs, tree: wt, manifestPath: wtManifestPath, manifest: wtManifest };
|
|
680
|
+
}
|
|
681
|
+
|
|
645
682
|
const treeDir = join(intoAbs, 'tree');
|
|
646
683
|
const outputDir = join(treeDir, 'output');
|
|
647
684
|
await mkdir(treeDir, { recursive: true });
|
|
@@ -809,7 +846,7 @@ const LOCKFILE_COMMANDS = Object.freeze([
|
|
|
809
846
|
* manifest)
|
|
810
847
|
* @param {string} [sourceSubdir] the manifest's own `sourceSubdir` ('' or
|
|
811
848
|
* undefined when Source IS the repo root)
|
|
812
|
-
* @returns {{manager: string, command: string, reason: string}|null} `null`
|
|
849
|
+
* @returns {{dir: string, lockFile: string|null, manager: string, command: string, reason: string}|null} `null`
|
|
813
850
|
* when there is nothing to report: no JS/TS manifest found walking up from
|
|
814
851
|
* `sourceSubdir` to `treeDir`, the nearest one found has no dependencies,
|
|
815
852
|
* `node_modules` is already there, or the manifest cannot even be read as
|
|
@@ -846,6 +883,8 @@ export function missingDependencies(treeDir, sourceSubdir = '') {
|
|
|
846
883
|
const installCmd = lock ? lock.command : 'npm install';
|
|
847
884
|
const rel = relative(root, d).split(sep).join('/');
|
|
848
885
|
return {
|
|
886
|
+
dir: d,
|
|
887
|
+
lockFile: lock ? lock.file : null,
|
|
849
888
|
manager: installCmd.split(' ')[0],
|
|
850
889
|
command: rel === '' ? installCmd : `cd ${rel} && ${installCmd}`,
|
|
851
890
|
reason: `${rel === '' ? 'package.json' : `${rel}/package.json`} lists dependencies but the copy has no node_modules`,
|
|
@@ -1109,3 +1148,15 @@ export function frontDoorFromManifest(read) {
|
|
|
1109
1148
|
if (typeof destination !== 'string' || !destination) return null;
|
|
1110
1149
|
return { destination };
|
|
1111
1150
|
}
|
|
1151
|
+
|
|
1152
|
+
/**
|
|
1153
|
+
* The $0 refusal for a non-repo source, worded by the kind the source door recorded (`prep.manifest.kind`) — a single
|
|
1154
|
+
* file is not a "folder", and Source must be the repo folder with the file named in Destination (hamr 2026-10-05).
|
|
1155
|
+
* @param {string} kind `prep.manifest.kind`: 'file' | 'folder' | 'url' (a 'repo' never reaches this)
|
|
1156
|
+
* @returns {string}
|
|
1157
|
+
*/
|
|
1158
|
+
export function nonRepoSourceMessage(kind) {
|
|
1159
|
+
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.';
|
|
1160
|
+
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.";
|
|
1161
|
+
return "Source is not a code project (no git repo found). bareloop can't check this kind of job yet. Nothing spent.";
|
|
1162
|
+
}
|