flowviant 0.77.2 → 0.77.5
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/preview.mjs +57 -11
- package/bin/lib/work.mjs +114 -8
- package/package.json +2 -2
package/bin/lib/preview.mjs
CHANGED
|
@@ -254,18 +254,53 @@ function forgetPreviewPid(pid) {
|
|
|
254
254
|
mutateRegistry((list) => list.filter((e) => e.pid !== pid));
|
|
255
255
|
}
|
|
256
256
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
257
|
+
/**
|
|
258
|
+
* Only kill a pid we can VERIFY is still one of ours — its command line must
|
|
259
|
+
* still contain the signature we stored. A reused pid belonging to something
|
|
260
|
+
* unrelated won't match, so we never kill a stranger.
|
|
261
|
+
*
|
|
262
|
+
* MACOS READS IT THROUGH `ps`, because "Linux-only, elsewhere just clear the
|
|
263
|
+
* registry" was the worst of both. A macOS daemon killed ungracefully leaves
|
|
264
|
+
* cloudflared running — it is spawned detached — so the public hostname keeps
|
|
265
|
+
* resolving to this machine while the gate dies with the daemon. The reaper
|
|
266
|
+
* then verified nothing, killed nothing, and DELETED the entry, so no later run
|
|
267
|
+
* could ever find that process. The tunnel served 502 until anything else on
|
|
268
|
+
* the box bound the gate's old port, which `startAuthProxy` obtained with
|
|
269
|
+
* `listen(0)` and is therefore squarely inside the kernel's ephemeral reuse
|
|
270
|
+
* pool — at which point a live public hostname forwarded straight to an
|
|
271
|
+
* unrelated local service with no gate, no password and no grant check. Exactly
|
|
272
|
+
* what this file's header says the reap exists to prevent.
|
|
273
|
+
*
|
|
274
|
+
* Three states, not two: `true` (ours), `false` (verified NOT ours, or gone),
|
|
275
|
+
* and `null` (we could not look — the caller keeps the record rather than
|
|
276
|
+
* dropping it).
|
|
277
|
+
*/
|
|
261
278
|
function stillOurs(pid, sig) {
|
|
262
|
-
if (
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
279
|
+
if (typeof sig !== 'string' || sig.length === 0) return false;
|
|
280
|
+
if (platform() === 'linux') {
|
|
281
|
+
try {
|
|
282
|
+
const cmd = readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ');
|
|
283
|
+
return cmd.includes(sig);
|
|
284
|
+
} catch {
|
|
285
|
+
return false; // process gone / unreadable
|
|
286
|
+
}
|
|
268
287
|
}
|
|
288
|
+
if (platform() === 'darwin') {
|
|
289
|
+
try {
|
|
290
|
+
const cmd = execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
|
|
291
|
+
encoding: 'utf8',
|
|
292
|
+
timeout: 5_000,
|
|
293
|
+
});
|
|
294
|
+
return cmd.includes(sig);
|
|
295
|
+
} catch {
|
|
296
|
+
// `ps` exits non-zero when the pid is gone — which is a real answer.
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
// Windows: no way to check from here. UNKNOWN, never "not ours" — see the
|
|
301
|
+
// caller, which keeps the record so a later run on a platform that can look
|
|
302
|
+
// is still able to.
|
|
303
|
+
return null;
|
|
269
304
|
}
|
|
270
305
|
|
|
271
306
|
/** Reap tunnel process groups left behind by a previously-crashed daemon.
|
|
@@ -283,8 +318,19 @@ export function reapOrphanPreviews(log) {
|
|
|
283
318
|
const handled = new Set();
|
|
284
319
|
for (const { pid, sig, owner } of list) {
|
|
285
320
|
if (Number.isInteger(owner) && owner !== process.pid && processAlive(owner)) continue;
|
|
321
|
+
const ours = stillOurs(pid, sig);
|
|
322
|
+
/**
|
|
323
|
+
* THE RECORD OUTLIVES A REAP THAT COULD NOT LOOK. `handled.add` ran BEFORE
|
|
324
|
+
* this check, so an entry was dropped whether or not anything was killed —
|
|
325
|
+
* and on any platform `stillOurs` could not read, that deleted the only
|
|
326
|
+
* trace of a tunnel still serving the public internet. Kept on `null`
|
|
327
|
+
* (unknown); removed on `true` (we killed it) and on `false` (the process
|
|
328
|
+
* is gone, or the pid now belongs to somebody else and the entry is stale
|
|
329
|
+
* either way).
|
|
330
|
+
*/
|
|
331
|
+
if (ours === null) continue;
|
|
286
332
|
handled.add(pid);
|
|
287
|
-
if (!
|
|
333
|
+
if (!ours) continue;
|
|
288
334
|
try {
|
|
289
335
|
process.kill(-pid, 'SIGKILL'); // whole group
|
|
290
336
|
killed++;
|
package/bin/lib/work.mjs
CHANGED
|
@@ -567,6 +567,21 @@ export function createWorkManager({
|
|
|
567
567
|
const worktreeSeen = new Set();
|
|
568
568
|
let lastWorktreeFetch = 0;
|
|
569
569
|
let sweepingWorktrees = false;
|
|
570
|
+
/**
|
|
571
|
+
* WHERE THE NEXT SWEEP STARTS.
|
|
572
|
+
*
|
|
573
|
+
* A safety valve with a rotation, and the rotation is the load-bearing half.
|
|
574
|
+
* The sweep used to take `activeIds.slice(0, 20)` — a silent truncation of a
|
|
575
|
+
* list the server builds tabs-first and agent places LAST, so on a project
|
|
576
|
+
* with twenty live tabs no agent was EVER measured: no branch diff, no head
|
|
577
|
+
* sha, and none of their trailered commits ever reached a card. Permanently,
|
|
578
|
+
* because the same twenty won every pass.
|
|
579
|
+
*
|
|
580
|
+
* Chunking removes the cut for any realistic project (see below). The cursor
|
|
581
|
+
* is what makes the residual cap fair rather than arbitrary: past it, the
|
|
582
|
+
* places that missed one sweep are the ones that lead the next.
|
|
583
|
+
*/
|
|
584
|
+
let worktreeCursor = 0;
|
|
570
585
|
const postWorktrees = async (reports) => {
|
|
571
586
|
if (!reports.length) return;
|
|
572
587
|
try {
|
|
@@ -800,12 +815,46 @@ export function createWorkManager({
|
|
|
800
815
|
// landed. Best-effort like everything in this sweep.
|
|
801
816
|
void landed.observe().catch(() => {});
|
|
802
817
|
}
|
|
818
|
+
/**
|
|
819
|
+
* MEASURED IN CHUNKS, not truncated to one.
|
|
820
|
+
*
|
|
821
|
+
* The server's endpoint takes twenty entries per request, and this read
|
|
822
|
+
* that bound as "measure twenty places" — so everything past the
|
|
823
|
+
* twentieth was silently never measured, and the server builds that
|
|
824
|
+
* list with agent places at the END. A project with twenty live tabs
|
|
825
|
+
* therefore measured no agent at all: their review pane showed no
|
|
826
|
+
* branch diff, `checkIsStale` had no head to compare against, and their
|
|
827
|
+
* commits never reached the cards they name.
|
|
828
|
+
*
|
|
829
|
+
* The cap on a REQUEST is not a cap on the WORK. Several requests of
|
|
830
|
+
* twenty cost several round trips and each is validated per entry
|
|
831
|
+
* exactly as before, so no contract changes and no floor is needed.
|
|
832
|
+
*
|
|
833
|
+
* `SWEEP_MAX_PLACES` is a bound on the MACHINE — each place costs a
|
|
834
|
+
* `git diff`, a listener scan and a process scan — and the cursor
|
|
835
|
+
* rotates so a project past it still measures everything, just across
|
|
836
|
+
* successive sweeps instead of one.
|
|
837
|
+
*/
|
|
838
|
+
const SWEEP_MAX_PLACES = 60;
|
|
839
|
+
const CHUNK = 20;
|
|
840
|
+
const total = activeIds.length;
|
|
841
|
+
const start = total > SWEEP_MAX_PLACES ? worktreeCursor % total : 0;
|
|
842
|
+
const take = Math.min(total, SWEEP_MAX_PLACES);
|
|
843
|
+
// Rotated slice, so the tail of a long list leads the next sweep rather
|
|
844
|
+
// than never being reached.
|
|
845
|
+
const order = Array.from({ length: take }, (_, i) => activeIds[(start + i) % total]);
|
|
846
|
+
worktreeCursor = total > SWEEP_MAX_PLACES ? (start + take) % total : 0;
|
|
803
847
|
const reports = [];
|
|
804
|
-
for (const id of
|
|
848
|
+
for (const id of order) {
|
|
805
849
|
const r = sessionWorktreeReport(id);
|
|
806
850
|
if (r) reports.push(r);
|
|
807
851
|
}
|
|
808
|
-
|
|
852
|
+
// One POST per chunk. Awaited in sequence rather than fired together:
|
|
853
|
+
// this runs on the machine's own poll beat and a burst of parallel
|
|
854
|
+
// writes to the same rows buys nothing.
|
|
855
|
+
for (let i = 0; i < reports.length; i += CHUNK) {
|
|
856
|
+
await postWorktrees(reports.slice(i, i + CHUNK));
|
|
857
|
+
}
|
|
809
858
|
} finally {
|
|
810
859
|
sweepingWorktrees = false;
|
|
811
860
|
}
|
|
@@ -3734,10 +3783,30 @@ export function createWorkManager({
|
|
|
3734
3783
|
* — which the stale path does on purpose before a merge. A receipt naming
|
|
3735
3784
|
* somebody else's commit is worse than a missing one.
|
|
3736
3785
|
*/
|
|
3737
|
-
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
|
|
3786
|
+
/**
|
|
3787
|
+
* IT CANNOT THROW, and that guard is the whole point of it being here.
|
|
3788
|
+
*
|
|
3789
|
+
* `git()` throws on a non-zero exit, and `baseRef()` is built from the
|
|
3790
|
+
* project's Base branch setting — free text an owner types, never verified
|
|
3791
|
+
* against the remote. Point it at a branch with no tracking ref (`develop`
|
|
3792
|
+
* on a repo whose remote branch is `dev`) and this exits "fatal: ambiguous
|
|
3793
|
+
* argument". The throw escaped `runAgentTurn` AFTER the CLI had already run
|
|
3794
|
+
* the card, so `postAgentTurn` was never reached, the server never settled
|
|
3795
|
+
* the turn, and the next poll handed back the identical turn — the same
|
|
3796
|
+
* card re-run every poll for six hours, on the operator's shared account,
|
|
3797
|
+
* piling commits onto the review branch, silently.
|
|
3798
|
+
*
|
|
3799
|
+
* An unreadable range means we cannot MEASURE the receipts, which is a
|
|
3800
|
+
* smaller failure than not settling: the turn still reports, and ship-time
|
|
3801
|
+
* reconciliation books whatever no card claimed. Missing beats fabricated
|
|
3802
|
+
* and both beat a stall.
|
|
3803
|
+
*/
|
|
3804
|
+
let out;
|
|
3805
|
+
try {
|
|
3806
|
+
out = git(['log', '--format=%H', '--no-merges', `${from}..HEAD`, '--not', baseRef()], wt);
|
|
3807
|
+
} catch {
|
|
3808
|
+
return [];
|
|
3809
|
+
}
|
|
3741
3810
|
return typeof out === 'string'
|
|
3742
3811
|
? out.split('\n').map((x) => x.trim()).filter(Boolean).slice(0, 50)
|
|
3743
3812
|
: [];
|
|
@@ -4060,6 +4129,18 @@ export function createWorkManager({
|
|
|
4060
4129
|
detached: true,
|
|
4061
4130
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
4062
4131
|
});
|
|
4132
|
+
/**
|
|
4133
|
+
* …AND IT IS TRACKED, so a stop or a takeover takes it with them.
|
|
4134
|
+
*
|
|
4135
|
+
* A check is a full test or build run in the agent's worktree, and it
|
|
4136
|
+
* was the one long-lived child the daemon spawned without telling its
|
|
4137
|
+
* own teardown about it. `shutdownWork` signalled the CLI children and
|
|
4138
|
+
* left this one — so restarting the daemon, or a same-repo takeover,
|
|
4139
|
+
* orphaned a running test suite inside a worktree the sweep may then
|
|
4140
|
+
* try to remove. The ten-minute timer would eventually kill it, but by
|
|
4141
|
+
* then it belongs to no daemon and nothing on any surface names it.
|
|
4142
|
+
*/
|
|
4143
|
+
workChildren.set(child, null);
|
|
4063
4144
|
} catch (e) {
|
|
4064
4145
|
// TEXT BEFORE FINISH: `finish` captures `text` by value into the
|
|
4065
4146
|
// resolved object, so assigning afterwards threw the spawn error away
|
|
@@ -4068,9 +4149,32 @@ export function createWorkManager({
|
|
|
4068
4149
|
finish('failed');
|
|
4069
4150
|
return;
|
|
4070
4151
|
}
|
|
4071
|
-
|
|
4152
|
+
/**
|
|
4153
|
+
* The TAIL, not the head: a failing check says why at the end. And
|
|
4154
|
+
* SCRUBBED BEFORE IT IS CUT, which is the order that matters.
|
|
4155
|
+
*
|
|
4156
|
+
* It used to slice first: `text = (text + buf).slice(-CAP)`, with a
|
|
4157
|
+
* single `envScrub` at the very end. `envScrub` replaces EXACT full
|
|
4158
|
+
* values, so any credential straddling either boundary — the rolling
|
|
4159
|
+
* window's, or a chunk's — was already cut in half by the time it was
|
|
4160
|
+
* looked at, matched nothing, and the surviving tail was written to
|
|
4161
|
+
* `agent.checkOutput` and shown to every member of the project. A failing
|
|
4162
|
+
* integration test that dumps its environment is an ordinary way to reach
|
|
4163
|
+
* that, and the partial is enough where the prefix of the key is a
|
|
4164
|
+
* publicly known constant.
|
|
4165
|
+
*
|
|
4166
|
+
* Scrubbing on every chunk fixes both straddles at once: the accumulated
|
|
4167
|
+
* text always holds the previous kept tail plus the whole new chunk, so a
|
|
4168
|
+
* value split across chunks is whole here, and a value near the window
|
|
4169
|
+
* edge is redacted before anything is discarded. Bounded work — the
|
|
4170
|
+
* string is never longer than the cap plus one chunk.
|
|
4171
|
+
*
|
|
4172
|
+
* The one case it cannot cover is a secret LONGER than the cap itself,
|
|
4173
|
+
* which can never sit in the window whole. The final scrub below stays as
|
|
4174
|
+
* the second pass over what actually ships.
|
|
4175
|
+
*/
|
|
4072
4176
|
const keep = (buf) => {
|
|
4073
|
-
text = (text + buf.toString()).slice(-CHECK_OUTPUT_CAP);
|
|
4177
|
+
text = envScrub(text + buf.toString()).slice(-CHECK_OUTPUT_CAP);
|
|
4074
4178
|
};
|
|
4075
4179
|
child.stdout?.on('data', keep);
|
|
4076
4180
|
child.stderr?.on('data', keep);
|
|
@@ -4094,11 +4198,13 @@ export function createWorkManager({
|
|
|
4094
4198
|
}, CHECK_TIMEOUT_MS);
|
|
4095
4199
|
child.on('error', (e) => {
|
|
4096
4200
|
clearTimeout(timer);
|
|
4201
|
+
workChildren.delete(child);
|
|
4097
4202
|
text += String(e?.message || e);
|
|
4098
4203
|
finish('failed');
|
|
4099
4204
|
});
|
|
4100
4205
|
child.on('close', (code) => {
|
|
4101
4206
|
clearTimeout(timer);
|
|
4207
|
+
workChildren.delete(child);
|
|
4102
4208
|
finish(code === 0 ? 'passed' : 'failed');
|
|
4103
4209
|
});
|
|
4104
4210
|
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.77.
|
|
4
|
-
"description": "Run your own coding CLIs as build agents for Flowviant
|
|
3
|
+
"version": "0.77.5",
|
|
4
|
+
"description": "Run your own coding CLIs as build agents for Flowviant — Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"flowviant": "bin/cli.mjs"
|