flowviant 0.77.5 → 0.78.1
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/bin/lib/authproxy.mjs +101 -32
- package/bin/lib/claude.mjs +12 -19
- package/bin/lib/deploy.mjs +150 -15
- package/bin/lib/env.mjs +37 -24
- package/bin/lib/fleet.mjs +136 -46
- package/bin/lib/git.mjs +0 -263
- package/bin/lib/grant.mjs +24 -3
- package/bin/lib/landed.mjs +69 -22
- package/bin/lib/localSessions.mjs +85 -3
- package/bin/lib/preflight.mjs +1 -1
- package/bin/lib/preview.mjs +35 -16
- package/bin/lib/prompts.mjs +40 -23
- package/bin/lib/shipSweep.mjs +16 -5
- package/bin/lib/work.mjs +364 -55
- package/bin/lib/worktreeDiff.mjs +55 -20
- package/package.json +1 -1
package/bin/lib/fleet.mjs
CHANGED
|
@@ -38,7 +38,6 @@ import { handleVersionSignal } from './update.mjs';
|
|
|
38
38
|
import {
|
|
39
39
|
git,
|
|
40
40
|
resetWorktree,
|
|
41
|
-
ensureWorktree,
|
|
42
41
|
repoRootOrDie,
|
|
43
42
|
detectBaseRef,
|
|
44
43
|
originSlug,
|
|
@@ -46,7 +45,6 @@ import {
|
|
|
46
45
|
isValidPrUrl,
|
|
47
46
|
isValidBranch,
|
|
48
47
|
isSafePathSegment,
|
|
49
|
-
worktreeDiffstat,
|
|
50
48
|
} from './git.mjs';
|
|
51
49
|
import { c, info, note, ok, warn, fail } from './ui.mjs';
|
|
52
50
|
import { revertPatch, withPatchLock } from './patch.mjs';
|
|
@@ -85,7 +83,7 @@ import {
|
|
|
85
83
|
RUNTIMES,
|
|
86
84
|
} from './runtimes.mjs';
|
|
87
85
|
import { createWorkManager } from './work.mjs';
|
|
88
|
-
import { scanLocalSessions } from './localSessions.mjs';
|
|
86
|
+
import { scanLocalSessions, ourConversationIds } from './localSessions.mjs';
|
|
89
87
|
import { repoState } from './repoState.mjs';
|
|
90
88
|
|
|
91
89
|
async function fetchRoster(
|
|
@@ -253,7 +251,7 @@ let localSessionsUnsupported = false; // the server 404'd — quiet until restar
|
|
|
253
251
|
let localSessionsScanAt = 0;
|
|
254
252
|
let localSessionsSent = null; // last payload the server ACCEPTED, stringified
|
|
255
253
|
let localSessionsSentAt = 0;
|
|
256
|
-
async function maybeReportLocalSessions({ repoRoot, excludeDirs }) {
|
|
254
|
+
async function maybeReportLocalSessions({ repoRoot, excludeDirs, excludeIds }) {
|
|
257
255
|
if (localSessionsUnsupported) return;
|
|
258
256
|
if (Date.now() - localSessionsScanAt < LOCAL_SESSIONS_SCAN_MS) return;
|
|
259
257
|
localSessionsScanAt = Date.now();
|
|
@@ -261,7 +259,9 @@ async function maybeReportLocalSessions({ repoRoot, excludeDirs }) {
|
|
|
261
259
|
try {
|
|
262
260
|
// scanLocalSessions orders deterministically, so this string only changes
|
|
263
261
|
// when the facts on disk do — the dedup below compares whole payloads.
|
|
264
|
-
payload = JSON.stringify({
|
|
262
|
+
payload = JSON.stringify({
|
|
263
|
+
sessions: scanLocalSessions({ repoRoot, excludeDirs, excludeIds }),
|
|
264
|
+
});
|
|
265
265
|
} catch {
|
|
266
266
|
return; // presence must never throw into the poll loop
|
|
267
267
|
}
|
|
@@ -319,7 +319,13 @@ async function maybeReportRepoState({ repoRoot, baseRef }) {
|
|
|
319
319
|
repoStateScanAt = Date.now();
|
|
320
320
|
let payload;
|
|
321
321
|
try {
|
|
322
|
-
|
|
322
|
+
// The PARAM, and nothing else: this function sits at MODULE scope, where
|
|
323
|
+
// runFleetDaemon's `getBaseRef` closure does not exist. An earlier version
|
|
324
|
+
// reached for it anyway, the ReferenceError landed in the catch below —
|
|
325
|
+
// written for an unreadable repo, silent by design — and this endpoint was
|
|
326
|
+
// never once posted to. The caller reads the loop's live `baseRef` at call
|
|
327
|
+
// time, so the value here is always current.
|
|
328
|
+
const state = repoState(repoRoot, baseRef);
|
|
323
329
|
if (!state) return; // not readable — say nothing rather than say "none"
|
|
324
330
|
payload = JSON.stringify(state);
|
|
325
331
|
} catch {
|
|
@@ -771,33 +777,6 @@ export async function runFleetDaemon() {
|
|
|
771
777
|
return run;
|
|
772
778
|
};
|
|
773
779
|
|
|
774
|
-
/** A clean detached checkout at base — what "the real code" has to mean for a
|
|
775
|
-
* question about the repo, rather than whatever half-finished state an agent
|
|
776
|
-
* worktree happens to be in. Shared by the plan check and consults. */
|
|
777
|
-
const ensureWikiWorktree = () => {
|
|
778
|
-
if (!existsSync(wikiWt)) {
|
|
779
|
-
try {
|
|
780
|
-
git(['worktree', 'add', '--detach', wikiWt, baseRef], repoRoot);
|
|
781
|
-
} catch {
|
|
782
|
-
git(['worktree', 'prune'], repoRoot);
|
|
783
|
-
git(['worktree', 'add', '--detach', wikiWt, baseRef], repoRoot);
|
|
784
|
-
}
|
|
785
|
-
return;
|
|
786
|
-
}
|
|
787
|
-
// It already exists — which means it is pinned to whatever base pointed at
|
|
788
|
-
// when it was FIRST created, possibly weeks ago. "Reads the real code" has
|
|
789
|
-
// to mean the current base, so re-point it. Best-effort: a stale answer
|
|
790
|
-
// beats no answer, and the next turn tries again.
|
|
791
|
-
try {
|
|
792
|
-
git(['fetch', 'origin', '--quiet'], repoRoot);
|
|
793
|
-
git(['checkout', '--detach', baseRef], wikiWt);
|
|
794
|
-
git(['reset', '--hard', baseRef], wikiWt);
|
|
795
|
-
git(['clean', '-fd'], wikiWt);
|
|
796
|
-
} catch {
|
|
797
|
-
/* offline, or a turn left it dirty — read what we have */
|
|
798
|
-
}
|
|
799
|
-
};
|
|
800
|
-
|
|
801
780
|
// Machine telemetry — what the box is doing with itself, for the admin view.
|
|
802
781
|
const MACHINE_URL = FLEET_URL.replace(/\/agents\/?$/, '/machine');
|
|
803
782
|
|
|
@@ -892,6 +871,7 @@ export async function runFleetDaemon() {
|
|
|
892
871
|
execFileSync('gh', ['pr', 'edit', job.prUrl, '--base', baseBranchName(baseRef)], {
|
|
893
872
|
cwd: repoRoot,
|
|
894
873
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
874
|
+
timeout: 30_000,
|
|
895
875
|
});
|
|
896
876
|
} catch (e) {
|
|
897
877
|
// Already targeting base is the common no-op; anything else is
|
|
@@ -912,6 +892,7 @@ export async function runFleetDaemon() {
|
|
|
912
892
|
execFileSync('gh', ['pr', 'merge', job.prUrl, '--squash', '--delete-branch'], {
|
|
913
893
|
cwd: repoRoot,
|
|
914
894
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
895
|
+
timeout: 120_000,
|
|
915
896
|
});
|
|
916
897
|
merged = true;
|
|
917
898
|
} catch (e) {
|
|
@@ -989,7 +970,7 @@ export async function runFleetDaemon() {
|
|
|
989
970
|
'Task restarted in Flowviant — this attempt was discarded.',
|
|
990
971
|
'--delete-branch',
|
|
991
972
|
],
|
|
992
|
-
{ cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'] }
|
|
973
|
+
{ cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'], timeout: 60_000 }
|
|
993
974
|
);
|
|
994
975
|
} catch (e) {
|
|
995
976
|
// Already closed/merged/missing = fine; anything else we still
|
|
@@ -1031,10 +1012,22 @@ export async function runFleetDaemon() {
|
|
|
1031
1012
|
const REGROUND_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/reground-done');
|
|
1032
1013
|
const WIKI_VAULT_URL = FLEET_URL.replace(/\/agents\/?$/, '/wiki-vault');
|
|
1033
1014
|
const WIKI_PROGRESS_URL = FLEET_URL.replace(/\/agents\/?$/, '/wiki-progress');
|
|
1015
|
+
const WIKI_ABANDONED_URL = FLEET_URL.replace(/\/agents\/?$/, '/wiki-abandoned');
|
|
1034
1016
|
const wikiQueue = [];
|
|
1035
1017
|
let wikiBusy = false;
|
|
1036
1018
|
let wikiChild = null; // the wiki turn's Claude process — tracked so teardown can kill it
|
|
1037
1019
|
let lastSweepAt = null; // dedup: run each Regenerate request once
|
|
1020
|
+
// …UNLESS IT FAILED. A sweep that ends without WIKI_DONE never finalizes, so
|
|
1021
|
+
// the server's `regen_requested_at` stays set and the roster keeps offering
|
|
1022
|
+
// the same `requestedAt` — which this dedup then swallowed forever. The
|
|
1023
|
+
// console said "retry from the app", to a console nobody reads. Bounded the
|
|
1024
|
+
// same way the re-ground path is: a full sweep is an expensive model turn, so
|
|
1025
|
+
// a repo that fails one every time must not be able to loop-burn quota.
|
|
1026
|
+
let sweepAttempts = 0;
|
|
1027
|
+
// Which request the counter belongs to, so a NEW Regenerate click starts with
|
|
1028
|
+
// a full budget rather than inheriting an exhausted one.
|
|
1029
|
+
let sweepAttemptsFor = null;
|
|
1030
|
+
const MAX_SWEEP_ATTEMPTS = 3;
|
|
1038
1031
|
const groundedIntents = new Set(); // dedup: re-ground each delivery once
|
|
1039
1032
|
// The vault is keyed by the server project this fleet credential serves
|
|
1040
1033
|
// (learned from the roster); until the first poll names it, fall back to a
|
|
@@ -1077,8 +1070,38 @@ export async function runFleetDaemon() {
|
|
|
1077
1070
|
}
|
|
1078
1071
|
};
|
|
1079
1072
|
|
|
1073
|
+
/**
|
|
1074
|
+
* Tell the server this daemon has stopped retrying the pending sweep.
|
|
1075
|
+
*
|
|
1076
|
+
* The retry budget is a `let` in this process; `regen_requested_at` is a
|
|
1077
|
+
* durable column with a 24-hour TTL. Without this the two disagreed — the
|
|
1078
|
+
* daemon had permanently given up while every surface went on calling the
|
|
1079
|
+
* sweep queued, for the rest of the day. Best-effort: an older server 404s
|
|
1080
|
+
* once and the TTL is still the backstop it always was.
|
|
1081
|
+
*/
|
|
1082
|
+
const postWikiAbandoned = async () => {
|
|
1083
|
+
try {
|
|
1084
|
+
await fetch(WIKI_ABANDONED_URL, {
|
|
1085
|
+
method: 'POST',
|
|
1086
|
+
headers: {
|
|
1087
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
1088
|
+
'User-Agent': USER_AGENT,
|
|
1089
|
+
'Content-Type': 'application/json',
|
|
1090
|
+
},
|
|
1091
|
+
signal: AbortSignal.timeout(15_000),
|
|
1092
|
+
body: '{}',
|
|
1093
|
+
});
|
|
1094
|
+
} catch {
|
|
1095
|
+
/* best-effort — the request's own TTL still expires it */
|
|
1096
|
+
}
|
|
1097
|
+
};
|
|
1098
|
+
|
|
1080
1099
|
const enqueueSweep = (job) => {
|
|
1081
1100
|
if (!job || job.requestedAt === lastSweepAt) return;
|
|
1101
|
+
if (job.requestedAt !== sweepAttemptsFor) {
|
|
1102
|
+
sweepAttemptsFor = job.requestedAt;
|
|
1103
|
+
sweepAttempts = 0;
|
|
1104
|
+
}
|
|
1082
1105
|
lastSweepAt = job.requestedAt;
|
|
1083
1106
|
// A full sweep is expensive — never stack two. One queued sweep already
|
|
1084
1107
|
// covers any newer Regenerate click (it reads the repo fresh when it runs).
|
|
@@ -1100,7 +1123,24 @@ export async function runFleetDaemon() {
|
|
|
1100
1123
|
// the turn still reads the real changed files; this catches pages whose
|
|
1101
1124
|
// frontmatter file list has drifted, or that document a concept rather
|
|
1102
1125
|
// than a directory.
|
|
1103
|
-
|
|
1126
|
+
//
|
|
1127
|
+
// SANITIZED AT THE INTAKE, because every entry is server-supplied text
|
|
1128
|
+
// that ends up interpolated into a prompt and printed by the drain's
|
|
1129
|
+
// narration: a control byte can repaint the console it lands on, a
|
|
1130
|
+
// newline can break out of the prompt's own list framing, and an
|
|
1131
|
+
// unbounded array of unbounded strings is an unbounded prompt. This is
|
|
1132
|
+
// the belt at the intake; the prompt keeps its own fence at the
|
|
1133
|
+
// interpolation.
|
|
1134
|
+
dirtiesPages: (Array.isArray(dirtiesPages) ? dirtiesPages : [])
|
|
1135
|
+
.filter((p) => typeof p === 'string')
|
|
1136
|
+
.slice(0, 40)
|
|
1137
|
+
.map((p) =>
|
|
1138
|
+
p
|
|
1139
|
+
.replace(/[\u0000-\u001f\u007f]/g, ' ')
|
|
1140
|
+
.trim()
|
|
1141
|
+
.slice(0, 300)
|
|
1142
|
+
)
|
|
1143
|
+
.filter(Boolean),
|
|
1104
1144
|
// THE COMMITS THAT SHIPPED — what changedFilesForShas resolves against.
|
|
1105
1145
|
// Dropping this here was the whole 0.54.0/0.54.1 defect: the server sent
|
|
1106
1146
|
// shas on every reground job, this function never stored them, and the
|
|
@@ -1224,6 +1264,18 @@ export async function runFleetDaemon() {
|
|
|
1224
1264
|
// thinking block or slow tool (which emit nothing until they finish) —
|
|
1225
1265
|
// otherwise the cover would flap back to the empty state mid-sweep.
|
|
1226
1266
|
let heartbeat = null;
|
|
1267
|
+
/**
|
|
1268
|
+
* Did THIS sweep finish? Read by the `finally` below, which owns the
|
|
1269
|
+
* retry decision for every way out of this block.
|
|
1270
|
+
*
|
|
1271
|
+
* It used to be decided inline on the one branch where the turn
|
|
1272
|
+
* returned without its sentinel — so the two OTHER ways a sweep fails,
|
|
1273
|
+
* `pickRuntimeFor` finding no CLI and the catch around the whole turn,
|
|
1274
|
+
* left the request pinned and never retried at all. Those are the
|
|
1275
|
+
* failures most worth retrying: a missing runtime is fixed by
|
|
1276
|
+
* installing one, and a thrown turn is exactly the transient case.
|
|
1277
|
+
*/
|
|
1278
|
+
let sweepCompleted = false;
|
|
1227
1279
|
try {
|
|
1228
1280
|
// Immediate frame so the cover shows the daemon feed right away (the
|
|
1229
1281
|
// "reading your code" phase), not a static message, while Claude warms up.
|
|
@@ -1283,6 +1335,7 @@ export async function runFleetDaemon() {
|
|
|
1283
1335
|
}
|
|
1284
1336
|
const wikiLabel = RUNTIMES[wikiRt].label;
|
|
1285
1337
|
if (task.type === 'sweep') {
|
|
1338
|
+
sweepAttempts++;
|
|
1286
1339
|
note(`${c.cyan('wiki')} ${c.dim(`— regenerating: your ${wikiLabel} is reading the repo…`)}`);
|
|
1287
1340
|
const out = await runTurn({
|
|
1288
1341
|
prompt: WIKI_KICKOFF(sha, vaultDir),
|
|
@@ -1307,9 +1360,12 @@ export async function runFleetDaemon() {
|
|
|
1307
1360
|
},
|
|
1308
1361
|
});
|
|
1309
1362
|
const complete = sawSentinel(out, 'WIKI_DONE');
|
|
1310
|
-
if (complete)
|
|
1311
|
-
|
|
1312
|
-
|
|
1363
|
+
if (complete) {
|
|
1364
|
+
sweepCompleted = true;
|
|
1365
|
+
ok(`${c.cyan('wiki')} ${c.dim('— vault regenerated from your code.')}`);
|
|
1366
|
+
} else {
|
|
1367
|
+
warn('wiki sweep ended without WIKI_DONE — partial pages synced');
|
|
1368
|
+
}
|
|
1313
1369
|
await runSync(complete);
|
|
1314
1370
|
} else {
|
|
1315
1371
|
const files = changedFilesForShas(task.shas);
|
|
@@ -1376,6 +1432,34 @@ export async function runFleetDaemon() {
|
|
|
1376
1432
|
} finally {
|
|
1377
1433
|
wikiChild = null;
|
|
1378
1434
|
if (heartbeat) clearInterval(heartbeat);
|
|
1435
|
+
/**
|
|
1436
|
+
* THE RETRY DECISION, IN ONE PLACE, FOR EVERY WAY OUT OF THIS BLOCK.
|
|
1437
|
+
*
|
|
1438
|
+
* Deciding it inline on the no-sentinel branch covered one of the
|
|
1439
|
+
* three ways a sweep fails and silently declined the other two. Here
|
|
1440
|
+
* it covers the thrown turn and the no-runtime return as well, which
|
|
1441
|
+
* are the two most worth retrying.
|
|
1442
|
+
*
|
|
1443
|
+
* A successful sweep resets the budget. A failed one with budget left
|
|
1444
|
+
* clears `lastSweepAt` so the roster's next offer of the SAME request
|
|
1445
|
+
* is accepted — partial pages are synced without pruning either way,
|
|
1446
|
+
* so a retry resumes rather than starting over. A failed one with the
|
|
1447
|
+
* budget spent tells the SERVER, because the counter is process-local
|
|
1448
|
+
* and the request it bounds is durable for 24 hours.
|
|
1449
|
+
*/
|
|
1450
|
+
if (task.type === 'sweep') {
|
|
1451
|
+
if (sweepCompleted) {
|
|
1452
|
+
sweepAttempts = 0;
|
|
1453
|
+
} else if (sweepAttempts < MAX_SWEEP_ATTEMPTS) {
|
|
1454
|
+
lastSweepAt = null;
|
|
1455
|
+
warn(`wiki sweep failed — retrying (${sweepAttempts}/${MAX_SWEEP_ATTEMPTS})`);
|
|
1456
|
+
} else {
|
|
1457
|
+
warn(
|
|
1458
|
+
`wiki sweep failed ${sweepAttempts} times — giving up on this request; press Regenerate to try again.`
|
|
1459
|
+
);
|
|
1460
|
+
await postWikiAbandoned();
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1379
1463
|
// Terminal frame so the app cover clears promptly (don't wait for the
|
|
1380
1464
|
// freshness window to lapse). force-sent past the throttle.
|
|
1381
1465
|
await postWikiProgress(frame({ done: true }), true);
|
|
@@ -1596,12 +1680,7 @@ export async function runFleetDaemon() {
|
|
|
1596
1680
|
// tab. Without this the daemon that lost the lease removes the worktree the
|
|
1597
1681
|
// winner is working in — absence would mean "somebody else won" instead of
|
|
1598
1682
|
// "the tab closed".
|
|
1599
|
-
retireWorkSessions(
|
|
1600
|
-
Array.isArray(roster.activeWorkSessions)
|
|
1601
|
-
? roster.activeWorkSessions
|
|
1602
|
-
: roster.activeWorkSessions,
|
|
1603
|
-
roster.sessionsHeldElsewhere
|
|
1604
|
-
);
|
|
1683
|
+
retireWorkSessions(roster.activeWorkSessions, roster.sessionsHeldElsewhere);
|
|
1605
1684
|
// Diffs somebody has open and is waiting on. Project-scoped rather than
|
|
1606
1685
|
// per-session: `git show` runs from the repo ROOT, which can see a closed
|
|
1607
1686
|
// tab's branch and a shipped commit on main alike.
|
|
@@ -1643,7 +1722,15 @@ export async function runFleetDaemon() {
|
|
|
1643
1722
|
// Terminal-session presence, throttled + dedup'd inside; never awaited —
|
|
1644
1723
|
// the daemon's own worktrees are carved out (a session the daemon spawned
|
|
1645
1724
|
// is already a tab, not something to offer adopting).
|
|
1646
|
-
void maybeReportLocalSessions({
|
|
1725
|
+
void maybeReportLocalSessions({
|
|
1726
|
+
repoRoot,
|
|
1727
|
+
excludeDirs: [baseDir],
|
|
1728
|
+
// …and our OWN tabs' conversations. Only the CHECKOUT needs this: every
|
|
1729
|
+
// other place is under `baseDir` and already fenced by directory, while
|
|
1730
|
+
// the operator's tabs share the checkout with real terminal sessions and
|
|
1731
|
+
// cannot be. See `ourConversationIds`.
|
|
1732
|
+
excludeIds: ourConversationIds(repoRoot),
|
|
1733
|
+
});
|
|
1647
1734
|
// …and the repo itself: every worktree and every branch, ours and not.
|
|
1648
1735
|
// Never awaited, throttled inside, and silent on an older server.
|
|
1649
1736
|
void maybeReportRepoState({ repoRoot, baseRef });
|
|
@@ -1736,7 +1823,10 @@ export async function runFleetDaemon() {
|
|
|
1736
1823
|
// runs queued deploy jobs (the server only sends deployJobs to authorized
|
|
1737
1824
|
// machines). Config report is cheap + dedup'd; jobs are single-flight.
|
|
1738
1825
|
if (roster.env?.deployAuthorized) {
|
|
1739
|
-
|
|
1826
|
+
// The BASE branch's copy, not the working tree's — see readDeployConfig.
|
|
1827
|
+
// Reporting the working tree would advertise targets the runner will not
|
|
1828
|
+
// find, which is the same lie in the other direction.
|
|
1829
|
+
void reportDeployConfig(repoRoot, getBaseRef());
|
|
1740
1830
|
processDeployJobs(roster.deployJobs, { repoRoot, baseRef: getBaseRef(), myPubB64 });
|
|
1741
1831
|
}
|
|
1742
1832
|
|
package/bin/lib/git.mjs
CHANGED
|
@@ -1,11 +1,6 @@
|
|
|
1
1
|
/** Git worktree helpers (fleet & static-fleet modes). */
|
|
2
2
|
|
|
3
3
|
import { execFileSync } from 'node:child_process';
|
|
4
|
-
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
5
|
-
import { resolve, join } from 'node:path';
|
|
6
|
-
import { rmSync } from 'node:fs';
|
|
7
|
-
import { tmpdir } from 'node:os';
|
|
8
|
-
import { materializedFiles } from './env.mjs';
|
|
9
4
|
|
|
10
5
|
export function git(args, cwd) {
|
|
11
6
|
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
@@ -144,161 +139,6 @@ export function baseBranchName(baseRef) {
|
|
|
144
139
|
return String(baseRef || '').replace(/^origin\//, '') || 'main';
|
|
145
140
|
}
|
|
146
141
|
|
|
147
|
-
/**
|
|
148
|
-
* Get a detached worktree at `wt`, creating it at `ref` if it isn't there.
|
|
149
|
-
*
|
|
150
|
-
* Returns `{ path, fresh }` — `fresh` is the whole point. A worktree that
|
|
151
|
-
* ALREADY existed is one somebody was mid-way through, and the caller must not
|
|
152
|
-
* reset it; a freshly created one is at base by construction and has nothing to
|
|
153
|
-
* preserve. That single bit replaces the in-memory `resuming` flag and the
|
|
154
|
-
* on-disk task marker for the common case, because once a worktree is named
|
|
155
|
-
* after its task, "does this directory exist" IS "am I resuming".
|
|
156
|
-
*
|
|
157
|
-
* The prune-and-retry is not paranoia: `git worktree add` refuses a path that
|
|
158
|
-
* is still REGISTERED even when the directory is gone (`flowviant clean` rm's
|
|
159
|
-
* the dirs, `git worktree list` keeps the stale entries), and that failure is
|
|
160
|
-
* permanent until pruned.
|
|
161
|
-
*/
|
|
162
|
-
export function ensureWorktree(repoRoot, wt, ref) {
|
|
163
|
-
// Resolve first. `existsSync` answers relative to THIS process's cwd while
|
|
164
|
-
// `git worktree add` answers relative to repoRoot, so a relative path makes
|
|
165
|
-
// the two disagree: the check says "not there", the add says "already
|
|
166
|
-
// exists", and the prune-and-retry can't fix a path that was never the one
|
|
167
|
-
// we looked at. Callers pass absolute paths today; this makes that not matter.
|
|
168
|
-
wt = resolve(wt);
|
|
169
|
-
if (existsSync(wt)) return { path: wt, fresh: false };
|
|
170
|
-
try {
|
|
171
|
-
git(['worktree', 'add', '--detach', wt, ref], repoRoot);
|
|
172
|
-
} catch {
|
|
173
|
-
git(['worktree', 'prune'], repoRoot);
|
|
174
|
-
git(['worktree', 'add', '--detach', wt, ref], repoRoot);
|
|
175
|
-
}
|
|
176
|
-
return { path: wt, fresh: true };
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// ── WIP checkpoints: the sandbox's state, on the remote ────────────────────
|
|
180
|
-
//
|
|
181
|
-
// A task's uncommitted work used to exist in exactly one place — a directory on
|
|
182
|
-
// whichever machine claimed it. That made the checkout precious: losing the box
|
|
183
|
-
// lost the work, so a task was pinned to a host, the host had to be named in the
|
|
184
|
-
// UI, and a container could never be thrown away. Pushing the work somewhere
|
|
185
|
-
// durable inverts all of that. The sandbox becomes a cache.
|
|
186
|
-
//
|
|
187
|
-
// These snapshots go to `refs/flowviant-wip/<intentId>`, NOT to a branch: they
|
|
188
|
-
// are machine state, not history, and they must never appear in a PR, a branch
|
|
189
|
-
// listing, or anyone's `git log`. Force-pushed, because only the latest matters.
|
|
190
|
-
|
|
191
|
-
const wipRef = (intentId) => `refs/flowviant-wip/${intentId}`;
|
|
192
|
-
|
|
193
|
-
/** git, with extra environment — for GIT_INDEX_FILE and a committer identity we
|
|
194
|
-
* can't assume the machine has configured. */
|
|
195
|
-
function gitWithEnv(args, cwd, extraEnv) {
|
|
196
|
-
return execFileSync('git', args, {
|
|
197
|
-
cwd,
|
|
198
|
-
encoding: 'utf8',
|
|
199
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
200
|
-
env: { ...process.env, ...extraEnv },
|
|
201
|
-
}).trim();
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Snapshot everything in the worktree — staged, unstaged and untracked — and
|
|
206
|
-
* push it, WITHOUT touching the agent's HEAD, index or files.
|
|
207
|
-
*
|
|
208
|
-
* That constraint is why this doesn't just commit. The agent is a live process
|
|
209
|
-
* with its own git intentions; committing under it would rewrite state it is
|
|
210
|
-
* mid-way through reasoning about, and `git stash` would rip the files out from
|
|
211
|
-
* under an editor. Building the tree in a throwaway index leaves the agent's
|
|
212
|
-
* world untouched — it cannot tell this happened.
|
|
213
|
-
*
|
|
214
|
-
* Returns the commit sha, or null if there was nothing dirty / no remote.
|
|
215
|
-
*
|
|
216
|
-
* This PUSHES, so what it stages is a security boundary, not a detail: the
|
|
217
|
-
* daemon materializes plaintext env-vault secrets into this same worktree.
|
|
218
|
-
* They are gitignored (materializeInto refuses to write them otherwise), and
|
|
219
|
-
* `git add -A` honours .gitignore — but the whole point of the bug this guards
|
|
220
|
-
* against was an exclusion mechanism that silently did nothing, so the paths
|
|
221
|
-
* are ALSO subtracted by pathspec here. Two independent mechanisms, because one
|
|
222
|
-
* of them failing quietly is exactly what put secrets on a remote branch.
|
|
223
|
-
*/
|
|
224
|
-
export function checkpointWip(wt, intentId, baseRef) {
|
|
225
|
-
if (!isSafePathSegment(intentId)) return null;
|
|
226
|
-
const idx = join(tmpdir(), `flowviant-idx-${intentId}-${process.pid}`);
|
|
227
|
-
const env = {
|
|
228
|
-
GIT_INDEX_FILE: idx,
|
|
229
|
-
// A snapshot must never fail because the machine has no user.name — this is
|
|
230
|
-
// ours, not the user's, and it never lands in history anyone reads.
|
|
231
|
-
GIT_AUTHOR_NAME: 'Flowviant',
|
|
232
|
-
GIT_AUTHOR_EMAIL: 'daemon@flowviant.com',
|
|
233
|
-
GIT_COMMITTER_NAME: 'Flowviant',
|
|
234
|
-
GIT_COMMITTER_EMAIL: 'daemon@flowviant.com',
|
|
235
|
-
};
|
|
236
|
-
try {
|
|
237
|
-
const head = git(['rev-parse', 'HEAD'], wt);
|
|
238
|
-
gitWithEnv(['read-tree', head], wt, env);
|
|
239
|
-
gitWithEnv(
|
|
240
|
-
['add', '-A', '--', '.', ...materializedFiles(wt).map((p) => `:(exclude)${p}`)],
|
|
241
|
-
wt,
|
|
242
|
-
env
|
|
243
|
-
);
|
|
244
|
-
const tree = gitWithEnv(['write-tree'], wt, env);
|
|
245
|
-
// Nothing changed since HEAD — no snapshot worth pushing.
|
|
246
|
-
if (tree === git(['rev-parse', `${head}^{tree}`], wt)) return null;
|
|
247
|
-
const commit = gitWithEnv(
|
|
248
|
-
['commit-tree', tree, '-p', head, '-m', `flowviant wip ${intentId}`],
|
|
249
|
-
wt,
|
|
250
|
-
env
|
|
251
|
-
);
|
|
252
|
-
git(['push', '--force', 'origin', `${commit}:${wipRef(intentId)}`], wt);
|
|
253
|
-
return commit;
|
|
254
|
-
} catch {
|
|
255
|
-
// Offline, no push rights, a repo with no origin — a checkpoint is an
|
|
256
|
-
// optimisation, never a reason to fail a task.
|
|
257
|
-
return null;
|
|
258
|
-
} finally {
|
|
259
|
-
try {
|
|
260
|
-
rmSync(idx, { force: true });
|
|
261
|
-
} catch {
|
|
262
|
-
/* best-effort */
|
|
263
|
-
}
|
|
264
|
-
void baseRef;
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
/**
|
|
269
|
-
* Rebuild a worktree from its last pushed checkpoint. Returns true if one was
|
|
270
|
-
* found and applied.
|
|
271
|
-
*
|
|
272
|
-
* The reset is MIXED on purpose: it leaves the snapshot's content in the files
|
|
273
|
-
* with HEAD back at the parent, which is what the agent had before — dirty
|
|
274
|
-
* working tree, nothing staged it didn't stage itself. A soft reset would hand
|
|
275
|
-
* it a fully-staged index it never created.
|
|
276
|
-
*/
|
|
277
|
-
export function restoreWip(wt, intentId) {
|
|
278
|
-
if (!isSafePathSegment(intentId)) return false;
|
|
279
|
-
const ref = wipRef(intentId);
|
|
280
|
-
try {
|
|
281
|
-
git(['fetch', 'origin', `+${ref}:${ref}`], wt);
|
|
282
|
-
const commit = git(['rev-parse', ref], wt);
|
|
283
|
-
const parent = git(['rev-parse', `${commit}^`], wt);
|
|
284
|
-
git(['checkout', '--detach', commit], wt);
|
|
285
|
-
git(['reset', parent], wt);
|
|
286
|
-
return true;
|
|
287
|
-
} catch {
|
|
288
|
-
return false; // no checkpoint for this task, or it's unreachable
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
/** Drop a task's checkpoint once its work has landed somewhere real. */
|
|
293
|
-
export function clearWip(wt, intentId) {
|
|
294
|
-
if (!isSafePathSegment(intentId)) return;
|
|
295
|
-
try {
|
|
296
|
-
git(['push', 'origin', '--delete', wipRef(intentId)], wt);
|
|
297
|
-
} catch {
|
|
298
|
-
/* already gone, or no remote */
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
|
|
302
142
|
export function resetWorktree(wt, baseRef) {
|
|
303
143
|
try {
|
|
304
144
|
git(['fetch', 'origin', '--quiet'], wt);
|
|
@@ -314,106 +154,3 @@ export function resetWorktree(wt, baseRef) {
|
|
|
314
154
|
}
|
|
315
155
|
}
|
|
316
156
|
|
|
317
|
-
/**
|
|
318
|
-
* What has changed in this worktree since `baseRef` — committed or not.
|
|
319
|
-
*
|
|
320
|
-
* The definition matters. `git diff --numstat <base>` (no `..HEAD`) compares the
|
|
321
|
-
* base against the WORKING TREE, so it covers commits the agent has made, staged
|
|
322
|
-
* work, and edits it has not committed yet. Anything narrower would go blank at
|
|
323
|
-
* the exact moments you look: right after a commit, or before the first one.
|
|
324
|
-
*
|
|
325
|
-
* Untracked files are added separately — they are invisible to `git diff` and
|
|
326
|
-
* are usually the most interesting thing an agent has done (a new module, a new
|
|
327
|
-
* test). Their line counts are read here rather than inferred; a file too large
|
|
328
|
-
* to be source is reported as a path with no counts instead of being read into
|
|
329
|
-
* memory.
|
|
330
|
-
*
|
|
331
|
-
* PATHS AND COUNTS ONLY. Nothing in here returns file content.
|
|
332
|
-
*
|
|
333
|
-
* Both git calls are `-z`, for the reason gitRaw exists: git's line-based output
|
|
334
|
-
* QUOTES any path that is not plain ASCII, so an accented filename arrives as
|
|
335
|
-
* "n\303\251w.txt" — a string that is not the path, cannot be stat'd, and reads
|
|
336
|
-
* as garbage in the tray. `-z` emits paths verbatim.
|
|
337
|
-
*/
|
|
338
|
-
export function worktreeDiffstat(cwd, baseRef, { maxFiles = 200 } = {}) {
|
|
339
|
-
const files = [];
|
|
340
|
-
let additions = 0;
|
|
341
|
-
let deletions = 0;
|
|
342
|
-
|
|
343
|
-
const add = (path, added, removed) => {
|
|
344
|
-
additions += added;
|
|
345
|
-
deletions += removed;
|
|
346
|
-
files.push({ path, added, removed });
|
|
347
|
-
};
|
|
348
|
-
|
|
349
|
-
try {
|
|
350
|
-
// `--numstat -z` frames a normal change as one field, "added\tdeleted\tpath",
|
|
351
|
-
// but a RENAME as three: "added\tdeleted\t" (empty path), then the old path,
|
|
352
|
-
// then the new one. An empty path is therefore the rename marker, and the
|
|
353
|
-
// next two fields belong to it — read line-wise instead, a rename would
|
|
354
|
-
// report a file literally named "old => new".
|
|
355
|
-
const fields = splitNul(gitRaw(['diff', '--numstat', '-z', baseRef, '--'], cwd));
|
|
356
|
-
for (let i = 0; i < fields.length; i++) {
|
|
357
|
-
const [a, d, ...rest] = fields[i].split('\t');
|
|
358
|
-
let path = rest.join('\t');
|
|
359
|
-
if (!path) {
|
|
360
|
-
path = fields[i + 2] ?? fields[i + 1]; // the post-rename name is what exists now
|
|
361
|
-
i += 2;
|
|
362
|
-
if (!path) continue;
|
|
363
|
-
}
|
|
364
|
-
// Binary files report '-' for both counts; they changed, but not by lines.
|
|
365
|
-
add(path, a === '-' ? 0 : Number(a) || 0, d === '-' ? 0 : Number(d) || 0);
|
|
366
|
-
}
|
|
367
|
-
} catch {
|
|
368
|
-
// No base ref yet, or not a repo — nothing to report rather than a crash.
|
|
369
|
-
return null;
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
try {
|
|
373
|
-
const untracked = splitNul(
|
|
374
|
-
gitRaw(['ls-files', '--others', '--exclude-standard', '-z'], cwd)
|
|
375
|
-
);
|
|
376
|
-
for (const path of untracked) {
|
|
377
|
-
let added = 0;
|
|
378
|
-
// Past the cap this path will not be shown, so do not pay to read it.
|
|
379
|
-
// This is the one place the totals can undercount, and reaching it takes
|
|
380
|
-
// an untracked tree bigger than the list itself — a generated directory
|
|
381
|
-
// .gitignore missed. Statting and reading all of it on a 20s interval
|
|
382
|
-
// would block the roster poll and every other lane on this daemon.
|
|
383
|
-
if (files.length < maxFiles) {
|
|
384
|
-
try {
|
|
385
|
-
const st = statSync(join(cwd, path));
|
|
386
|
-
// Regular files ONLY. `git ls-files --others` will happily name a
|
|
387
|
-
// symlink or a fifo, and readFileSync on a fifo or a character device
|
|
388
|
-
// BLOCKS — on a 20s interval, on the daemon's single thread, that is
|
|
389
|
-
// the whole process wedged waiting for a device that may never write.
|
|
390
|
-
// 2 MB: past that it is a build artifact or a binary, and reading it
|
|
391
|
-
// to count newlines would be the most expensive thing this daemon does.
|
|
392
|
-
if (st.isFile() && st.size <= 2_000_000) {
|
|
393
|
-
const text = readFileSync(join(cwd, path), 'utf8');
|
|
394
|
-
// A NUL byte means binary. Counting "lines" in a PNG produces a
|
|
395
|
-
// number that is not wrong so much as meaningless, and it was being
|
|
396
|
-
// summed into the total shown beside git's real counts.
|
|
397
|
-
if (text.includes('\0')) throw new Error('binary');
|
|
398
|
-
// Lines, not segments. A file ending in a newline — i.e. essentially
|
|
399
|
-
// every source file an agent writes — splits into one more piece
|
|
400
|
-
// than it has lines, and that +1 was landing in the totals shown
|
|
401
|
-
// beside git's own counts.
|
|
402
|
-
added = text ? text.split('\n').length - (text.endsWith('\n') ? 1 : 0) : 0;
|
|
403
|
-
}
|
|
404
|
-
} catch {
|
|
405
|
-
/* vanished between listing and reading — report the path, no counts */
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
add(path, added, 0);
|
|
409
|
-
}
|
|
410
|
-
} catch {
|
|
411
|
-
/* untracked listing failed — the tracked half still stands */
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
if (files.length === 0) return null;
|
|
415
|
-
// Totals stay whole while the LIST is capped: a truncated list must never
|
|
416
|
-
// quietly shrink the number printed beside it.
|
|
417
|
-
const truncated = Math.max(0, files.length - maxFiles);
|
|
418
|
-
return { files: files.slice(0, maxFiles), additions, deletions, truncated };
|
|
419
|
-
}
|
package/bin/lib/grant.mjs
CHANGED
|
@@ -171,12 +171,33 @@ export function safePathname(url) {
|
|
|
171
171
|
* `//evil.com` and `/\evil.com` are protocol-relative to a browser, so a naive
|
|
172
172
|
* "starts with /" check turns the callback into an open redirect on the tunnel
|
|
173
173
|
* origin. Anything that is not a single-slash relative path becomes '/'.
|
|
174
|
+
*
|
|
175
|
+
* STRUCTURAL, NOT LEXICAL, and that distinction is the whole fix. This used to
|
|
176
|
+
* be `/^\/(?![/\\])/` plus a CR/LF check — and an audit walked through it with
|
|
177
|
+
* a TAB: browsers STRIP tab, CR and LF out of a URL before parsing, so
|
|
178
|
+
* `"/\t/evil.com"` passed the guard, was written verbatim into Location by the
|
|
179
|
+
* callback, and navigated to `//evil.com`. Parsing against a dummy base and
|
|
180
|
+
* re-serialising reproduces exactly what the browser will do — there is no
|
|
181
|
+
* second implementation of URL parsing here to drift, and the value returned
|
|
182
|
+
* is the one the parser produced. The lexical checks stay in front of it as
|
|
183
|
+
* the cheap first line; the origin comparison is the guarantee. Ported from
|
|
184
|
+
* the server twins (previewAuthorize.routes.ts, preview-relay util.ts), which
|
|
185
|
+
* closed the same hole the same way.
|
|
174
186
|
*/
|
|
175
187
|
export function safeRelative(raw) {
|
|
176
188
|
if (!raw) return '/';
|
|
177
189
|
const s = String(raw);
|
|
178
190
|
if (s.length > 512) return '/';
|
|
179
|
-
if (/
|
|
180
|
-
if (
|
|
181
|
-
|
|
191
|
+
if (!s.startsWith('/')) return '/';
|
|
192
|
+
if (/^\/[/\\]/.test(s)) return '/';
|
|
193
|
+
// Every C0 control, space and DEL — not just CR/LF.
|
|
194
|
+
// eslint-disable-next-line no-control-regex
|
|
195
|
+
if (/[\x00-\x20\x7f]/.test(s)) return '/';
|
|
196
|
+
try {
|
|
197
|
+
const u = new URL(s, 'https://x.invalid');
|
|
198
|
+
if (u.origin !== 'https://x.invalid') return '/';
|
|
199
|
+
return u.pathname + u.search + u.hash;
|
|
200
|
+
} catch {
|
|
201
|
+
return '/';
|
|
202
|
+
}
|
|
182
203
|
}
|