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.
- package/CHANGELOG.md +27 -0
- package/bareloop.context.md +36 -8
- package/package.json +1 -1
- package/src/authorrun.js +3 -2
- package/src/bundlerun.js +4 -8
- package/src/draftspend.js +116 -1
- 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/authorsession.js +87 -17
- package/src/panel/index.html +137 -43
- package/src/panel/server.js +130 -14
- package/src/panel/settingsroutes.js +52 -2
- package/src/ralph.js +1 -1
- package/src/source.js +43 -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/authorsession.d.ts +2 -1
- package/types/panel/server.d.ts +13 -2
- package/types/panel/settingsroutes.d.ts +6 -1
- package/types/ralph.d.ts +19 -0
- package/types/source.d.ts +13 -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';
|
|
@@ -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 = draftingPartHere ? 1 : 0;
|
|
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,7 @@ 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
|
-
parts:
|
|
1120
|
+
parts: draftingPartHere && Array.isArray(enrichedParts) ? [draftingPartHere, ...enrichedParts] : enrichedParts,
|
|
1031
1121
|
replans: summary.replans,
|
|
1032
1122
|
close: summary.close,
|
|
1033
1123
|
branch: summary.branch,
|
|
@@ -1379,6 +1469,7 @@ export function getRunAudit(runid, opts = {}) {
|
|
|
1379
1469
|
skipAudit: true,
|
|
1380
1470
|
});
|
|
1381
1471
|
const partOf = makePartLookup(summary);
|
|
1472
|
+
const shift = draftingShift(row, summary, opts); // the drafting part heads the shown list: every index moves with it
|
|
1382
1473
|
|
|
1383
1474
|
const { records } = { records: auditPaths.flatMap((ap) => parseJsonl(ap).records) };
|
|
1384
1475
|
const windowed = records.filter((r) => r && typeof r === 'object' && typeof r.ts === 'string' && inAnyWindow(r.ts, windows));
|
|
@@ -1419,7 +1510,7 @@ export function getRunAudit(runid, opts = {}) {
|
|
|
1419
1510
|
path,
|
|
1420
1511
|
pathShort: shortenPath(path, treeRoot),
|
|
1421
1512
|
decision: typeof r.decision === 'string' ? r.decision : null,
|
|
1422
|
-
partIndex,
|
|
1513
|
+
partIndex: partIndex === null ? null : partIndex + shift,
|
|
1423
1514
|
partLabel,
|
|
1424
1515
|
attemptN,
|
|
1425
1516
|
reason,
|
|
@@ -1488,7 +1579,11 @@ export function getRunRounds(runid, query = {}, opts = {}) {
|
|
|
1488
1579
|
? Math.min(query.limit, ROUNDS_PAGE_MAX) : ROUNDS_PAGE_DEFAULT;
|
|
1489
1580
|
|
|
1490
1581
|
const summary = replayOne(row.spine);
|
|
1491
|
-
|
|
1582
|
+
// `part` is an index into the SHOWN list (`detail.parts`): when the drafting part heads it, index 0 is the drafting
|
|
1583
|
+
// part (it has no rounds table) and the spine's own part k is shown at k + 1
|
|
1584
|
+
const shift = draftingShift(row, summary, opts);
|
|
1585
|
+
if (shift && partIndex === 0) return null;
|
|
1586
|
+
const part = Array.isArray(summary.parts) ? summary.parts[partIndex - shift] : null;
|
|
1492
1587
|
if (!part) return null;
|
|
1493
1588
|
const a = Array.isArray(part.attempts) ? part.attempts[attempt - 1] : null;
|
|
1494
1589
|
if (!a) return null;
|
|
@@ -1616,6 +1711,25 @@ function sourceNearSpine(spinePath) {
|
|
|
1616
1711
|
};
|
|
1617
1712
|
}
|
|
1618
1713
|
|
|
1714
|
+
/**
|
|
1715
|
+
* 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
|
|
1716
|
+
* the source door wrote: `worktree` + `repo`) and its spine's `work-branch` record. `null` for every run that has none
|
|
1717
|
+
* (a copied-tree run, a bundle run, an import). `exists` is whether the folder is still on disk: a green run's folder is
|
|
1718
|
+
* removed by the engine, any other ending leaves it.
|
|
1719
|
+
* @param {{ spine: string }} row
|
|
1720
|
+
* @param {any[]} records the run's raw spine records
|
|
1721
|
+
* @returns {{folder: string, repo: string, branch: string|null, exists: boolean}|null}
|
|
1722
|
+
*/
|
|
1723
|
+
function worktreeOfRun(row, records) {
|
|
1724
|
+
const near = sourceNearSpine(row.spine);
|
|
1725
|
+
if (!near.sourceJsonPath) return null;
|
|
1726
|
+
let m;
|
|
1727
|
+
try { m = JSON.parse(readFileSync(near.sourceJsonPath, 'utf8')); } catch { return null; }
|
|
1728
|
+
if (typeof m?.worktree !== 'string' || m.worktree === '' || typeof m?.repo !== 'string') return null;
|
|
1729
|
+
const wb = records.findLast((r) => r && r.type === 'work-branch' && typeof r.branch === 'string') ?? null;
|
|
1730
|
+
return { folder: m.worktree, repo: m.repo, branch: wb ? wb.branch : null, exists: existsSync(m.worktree) };
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1619
1733
|
/**
|
|
1620
1734
|
* The resolved SPEC OBJECT for a run — routes (a) bundle `spec.json`, (b)
|
|
1621
1735
|
* `jobs/<job>.json`, (c) the run's own `resolved-spec.json` beside the spine
|
|
@@ -2526,7 +2640,7 @@ export function handleRequest(req, res, opts) {
|
|
|
2526
2640
|
});
|
|
2527
2641
|
return;
|
|
2528
2642
|
}
|
|
2529
|
-
sendText(res, 405, 'method not allowed — this
|
|
2643
|
+
sendText(res, 405, 'method not allowed — this route does not accept this method (GET/HEAD only)');
|
|
2530
2644
|
return;
|
|
2531
2645
|
}
|
|
2532
2646
|
const getRoutes = routesFor(opts, pathname);
|
|
@@ -2635,7 +2749,8 @@ export function handleRequest(req, res, opts) {
|
|
|
2635
2749
|
* this package's own `bin/bareloop.mjs`).
|
|
2636
2750
|
* @param {{ port?: number, home?: string, env?: Record<string,string|undefined>,
|
|
2637
2751
|
* sessionsRoot?: string, spawnFn?: (...a: any[]) => any, bareloopBin?: string,
|
|
2638
|
-
* fetchImpl?: typeof fetch, settleMs?: number, userHome?: string
|
|
2752
|
+
* fetchImpl?: typeof fetch, settleMs?: number, userHome?: string,
|
|
2753
|
+
* openFolderSpawn?: (cmd: string, args: string[], o: object) => any, platform?: string }} [opts]
|
|
2639
2754
|
* @returns {Promise<{ server: import('node:http').Server, port: number, token: string, close: () => Promise<void> }>}
|
|
2640
2755
|
*/
|
|
2641
2756
|
export function createPanelServer(opts = {}) {
|
|
@@ -2690,6 +2805,7 @@ export function createPanelServer(opts = {}) {
|
|
|
2690
2805
|
});
|
|
2691
2806
|
settingsRoutes = createSettingsRoutes({
|
|
2692
2807
|
port: boundPort, token, home, env: opts.env, fetchImpl: opts.fetchImpl,
|
|
2808
|
+
openFolderSpawn: opts.openFolderSpawn, platform: opts.platform,
|
|
2693
2809
|
});
|
|
2694
2810
|
runRoutes = createRunRoutes({
|
|
2695
2811
|
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/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`,
|
package/src/u-patient.js
CHANGED
|
@@ -16,9 +16,10 @@
|
|
|
16
16
|
// mechanical preparation of a tree, and both callers print what it did.
|
|
17
17
|
import { execFileSync } from 'node:child_process';
|
|
18
18
|
import {
|
|
19
|
-
rmSync, existsSync,
|
|
19
|
+
rmSync, existsSync,
|
|
20
20
|
} from 'node:fs';
|
|
21
21
|
import { join } from 'node:path';
|
|
22
|
+
import { moveFile } from './movefile.js';
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* Reset a patient to its frozen seed and drop its `.litectx` store.
|
|
@@ -68,6 +69,6 @@ export function moveStaleGateAudit(wd, spineDir, runid) {
|
|
|
68
69
|
const staleAudit = join(wd, 'gate-audit.jsonl');
|
|
69
70
|
if (!existsSync(staleAudit)) return null;
|
|
70
71
|
const preFile = join(spineDir, `pre-${runid}-gate-audit.jsonl`);
|
|
71
|
-
|
|
72
|
+
moveFile(staleAudit, preFile);
|
|
72
73
|
return preFile;
|
|
73
74
|
}
|