flowviant 0.54.1 → 0.54.2
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/deploy.mjs +10 -2
- package/bin/lib/fleet.mjs +35 -4
- package/bin/lib/instance.mjs +104 -21
- package/bin/lib/login.mjs +5 -1
- package/bin/lib/preview.mjs +99 -10
- package/bin/lib/work.mjs +37 -1
- package/package.json +2 -2
package/bin/lib/deploy.mjs
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { readFileSync, existsSync } from 'node:fs';
|
|
14
14
|
import { spawn } from 'node:child_process';
|
|
15
15
|
import { join } from 'node:path';
|
|
16
|
-
import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
|
|
16
|
+
import { FLEET_URL, FLEET_TOKEN, USER_AGENT, DAEMON_INSTANCE } from './config.mjs';
|
|
17
17
|
import { c, note, ok, warn } from './ui.mjs';
|
|
18
18
|
import { deployCreds, appSecretsFor, scrub, myPubB64 } from './env.mjs';
|
|
19
19
|
|
|
@@ -167,7 +167,15 @@ export function processDeployJobs(jobs, ctx) {
|
|
|
167
167
|
void (async () => {
|
|
168
168
|
let beat = null;
|
|
169
169
|
try {
|
|
170
|
-
|
|
170
|
+
// `instance` names THIS PROCESS. The pubkey cannot: it is the env
|
|
171
|
+
// keypair read from one file per home directory, so two daemons on one
|
|
172
|
+
// box share it and a pubkey-only read-back let both "win" the claim
|
|
173
|
+
// and run the same deploy twice concurrently.
|
|
174
|
+
const claimed = await post('deploy-claim', {
|
|
175
|
+
jobId: job.id,
|
|
176
|
+
pubkey: ctx.myPubB64(),
|
|
177
|
+
instance: DAEMON_INSTANCE,
|
|
178
|
+
}).catch(() => null);
|
|
171
179
|
if (!claimed?.claimed) return; // another daemon won the claim
|
|
172
180
|
// Keep the claim fresh while we run — a long deploy must never be
|
|
173
181
|
// re-queued out from under us (that would double-deploy). The async
|
package/bin/lib/fleet.mjs
CHANGED
|
@@ -782,7 +782,7 @@ export async function runFleetDaemon() {
|
|
|
782
782
|
// Direct enqueue = immediacy; the server's durable regroundJobs list
|
|
783
783
|
// (created by merge-done above, cleared by our reground-done report)
|
|
784
784
|
// is the restart-safe backstop — dedup'd here by groundedIntents.
|
|
785
|
-
enqueueReground(job.id, job.prUrl, job.title, job.dirtiesPages);
|
|
785
|
+
enqueueReground(job.id, job.prUrl, job.title, job.dirtiesPages, job.shas);
|
|
786
786
|
} else if (failedReason) {
|
|
787
787
|
// Report into the thread (server narrates + re-arms the merge
|
|
788
788
|
// button + notifies) — the job disappears from the roster.
|
|
@@ -926,7 +926,7 @@ export async function runFleetDaemon() {
|
|
|
926
926
|
wikiQueue.push({ type: 'sweep' });
|
|
927
927
|
void drainWiki();
|
|
928
928
|
};
|
|
929
|
-
const enqueueReground = (intentId, prUrl, title, dirtiesPages) => {
|
|
929
|
+
const enqueueReground = (intentId, prUrl, title, dirtiesPages, shas) => {
|
|
930
930
|
if (!intentId || groundedIntents.has(intentId)) return;
|
|
931
931
|
groundedIntents.add(intentId);
|
|
932
932
|
wikiQueue.push({
|
|
@@ -939,6 +939,13 @@ export async function runFleetDaemon() {
|
|
|
939
939
|
// frontmatter file list has drifted, or that document a concept rather
|
|
940
940
|
// than a directory.
|
|
941
941
|
dirtiesPages: Array.isArray(dirtiesPages) ? dirtiesPages : [],
|
|
942
|
+
// THE COMMITS THAT SHIPPED — what changedFilesForShas resolves against.
|
|
943
|
+
// Dropping this here was the whole 0.54.0/0.54.1 defect: the server sent
|
|
944
|
+
// shas on every reground job, this function never stored them, and the
|
|
945
|
+
// drain's `task.shas` was undefined on every job — so the re-ground
|
|
946
|
+
// "revived" on 2026-08-22 retried three times against nothing and gave
|
|
947
|
+
// up, on a console nobody reads, on every single ship.
|
|
948
|
+
shas: Array.isArray(shas) ? shas : [],
|
|
942
949
|
});
|
|
943
950
|
void drainWiki();
|
|
944
951
|
};
|
|
@@ -1185,6 +1192,13 @@ export async function runFleetDaemon() {
|
|
|
1185
1192
|
// crash BEFORE this line leaves the job listed for a retry.
|
|
1186
1193
|
regroundAttempts.delete(task.intentId);
|
|
1187
1194
|
await reportMergeOutcome(REGROUND_DONE_URL, { taskId: task.intentId });
|
|
1195
|
+
// The dedup was DAEMON-LIFETIME, which wedged a reopened card: its
|
|
1196
|
+
// second ship writes a fresh durable job, this Set still holds the
|
|
1197
|
+
// taskId, enqueueReground refuses it on every poll forever, and
|
|
1198
|
+
// the never-consumed job churns the wiki-writer lease until a
|
|
1199
|
+
// restart. The job is consumed now, so the guard has done its work;
|
|
1200
|
+
// a FUTURE ship of the same card is new work, not a duplicate.
|
|
1201
|
+
groundedIntents.delete(task.intentId);
|
|
1188
1202
|
}
|
|
1189
1203
|
} catch (e) {
|
|
1190
1204
|
warn(`wiki ${task.type} failed: ${e.message}`);
|
|
@@ -1269,7 +1283,13 @@ export async function runFleetDaemon() {
|
|
|
1269
1283
|
if (e.auth) {
|
|
1270
1284
|
fail(`${e.message} — credential revoked or invalid. Shutting down.`);
|
|
1271
1285
|
teardown();
|
|
1272
|
-
|
|
1286
|
+
// EXIT 0, for the same reason the commanded-stop path does: a revoked
|
|
1287
|
+
// credential is a terminal, asked-for-by-someone state, and a relaunch
|
|
1288
|
+
// can never fix it. Under `Restart=on-failure` a nonzero code has
|
|
1289
|
+
// systemd relaunch the daemon immediately — a restart loop hammering
|
|
1290
|
+
// dead-credential polls, fighting the Disconnect that revoked it, and
|
|
1291
|
+
// ending in a unit that reads as a crash rather than a kill.
|
|
1292
|
+
process.exit(0);
|
|
1273
1293
|
}
|
|
1274
1294
|
warn(`roster poll failed: ${e.message} — retrying in ${RECONCILE_SECONDS}s`);
|
|
1275
1295
|
await sleep(RECONCILE_SECONDS);
|
|
@@ -1304,6 +1324,17 @@ export async function runFleetDaemon() {
|
|
|
1304
1324
|
: 'stopped by Flowviant — no reason given.'
|
|
1305
1325
|
);
|
|
1306
1326
|
note('shutting down — stopping workers. Worktrees are kept: in-flight work resumes next run.');
|
|
1327
|
+
// FLUSH the settle queue first, bounded: a queued-but-undelivered report
|
|
1328
|
+
// is a COMPLETED turn whose side effects already happened, and dropping
|
|
1329
|
+
// it re-runs the whole turn on the next start — quota spent twice and
|
|
1330
|
+
// every card write doubled. This path is async (unlike the signal
|
|
1331
|
+
// handlers, which cannot await), so the stop can afford five seconds of
|
|
1332
|
+
// delivery before it obeys.
|
|
1333
|
+
try {
|
|
1334
|
+
await Promise.race([flushWorkReports(), sleep(5)]);
|
|
1335
|
+
} catch {
|
|
1336
|
+
/* undelivered reports re-run; delivering them was best-effort */
|
|
1337
|
+
}
|
|
1307
1338
|
// teardown() is NOT optional on this path. Detached preview tunnels
|
|
1308
1339
|
// survive this process BY DESIGN, so exiting without it strands a public
|
|
1309
1340
|
// hostname pointed into a worktree until somebody reboots the box — which
|
|
@@ -1421,7 +1452,7 @@ export async function runFleetDaemon() {
|
|
|
1421
1452
|
for (const j of roster.regroundJobs ?? []) {
|
|
1422
1453
|
const rid = j && (j.taskId ?? j.intentId); // new name first, old as fallback
|
|
1423
1454
|
if (!j || typeof rid !== 'string') continue; // a null element would throw + wedge the loop
|
|
1424
|
-
enqueueReground(rid, j.prUrl, j.title, j.dirtiesPages);
|
|
1455
|
+
enqueueReground(rid, j.prUrl, j.title, j.dirtiesPages, j.shas);
|
|
1425
1456
|
}
|
|
1426
1457
|
void drainWiki();
|
|
1427
1458
|
|
package/bin/lib/instance.mjs
CHANGED
|
@@ -413,15 +413,16 @@ function stillTheHolder(holder) {
|
|
|
413
413
|
// `process.argv[1] || ''` — and matching on '' would match every process
|
|
414
414
|
// alive, so it takes the same road as a missing one.
|
|
415
415
|
if (!want) return startedAroundLockWrite(holder);
|
|
416
|
+
let cmdline;
|
|
416
417
|
try {
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
418
|
+
cmdline =
|
|
419
|
+
platform() === 'linux'
|
|
420
|
+
? readFileSync(`/proc/${holder.pid}/cmdline`, 'utf8').replace(/\0/g, ' ')
|
|
421
|
+
: execFileSync('ps', ['-o', 'command=', '-p', String(holder.pid)], {
|
|
422
|
+
encoding: 'utf8',
|
|
423
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
424
|
+
timeout: 3000,
|
|
425
|
+
});
|
|
425
426
|
} catch {
|
|
426
427
|
// NOT `false`. takeOverFrom already returned early if the pid were gone, so
|
|
427
428
|
// reaching here means the process is alive and we could not READ it —
|
|
@@ -429,6 +430,45 @@ function stillTheHolder(holder) {
|
|
|
429
430
|
// here is what made the refusal claim the pid belonged to somebody else.
|
|
430
431
|
return null;
|
|
431
432
|
}
|
|
433
|
+
if (!cmdline.includes(want)) return false;
|
|
434
|
+
// AN ENTRY MATCH ALONE IS NOT IDENTITY. Every daemon on the box shares one
|
|
435
|
+
// entry path under a global install, so "cmdline contains this cli.mjs"
|
|
436
|
+
// proves "is SOME flowviant daemon", not "is the daemon that wrote THIS
|
|
437
|
+
// lock" — and a crashed daemon's pid recycled to a SIBLING project's live
|
|
438
|
+
// daemon passed it, which let a same-repo takeover SIGTERM a different
|
|
439
|
+
// project's machine. Every 0.54.0+ lock also carries `startedAt`, the
|
|
440
|
+
// process's own witness to when it began, so when it is present the start
|
|
441
|
+
// time must agree too. `null` (could not measure — hidepid, no ps, a lock
|
|
442
|
+
// with no startedAt) falls back to the entry match alone, exactly the
|
|
443
|
+
// pre-check behaviour: refusing on ignorance here would re-brick takeover
|
|
444
|
+
// on the hosts that hide /proc.
|
|
445
|
+
const around = startedAroundLockWrite(holder);
|
|
446
|
+
return around === false ? false : true;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Unlink a lock file ONLY while it still names the pid the caller decided
|
|
451
|
+
* about (or nothing readable). Every rmSync of a lock outside the ppid-adopt
|
|
452
|
+
* path goes through this: between "I proved pid N is dead/stale" and the
|
|
453
|
+
* unlink, a concurrently starting daemon can clear the file itself and
|
|
454
|
+
* wx-create its own — and an unconditional rm then deletes a LIVE daemon's
|
|
455
|
+
* lock, leaving it running unguarded, which is the one condition this module
|
|
456
|
+
* exists to prevent. The read-then-rm gap that remains is microseconds against
|
|
457
|
+
* the seconds-wide window it closes.
|
|
458
|
+
*
|
|
459
|
+
* Returns false when the file now names a DIFFERENT pid — a handover the
|
|
460
|
+
* caller must treat as "not mine to clear" — true otherwise (removed, already
|
|
461
|
+
* gone, or best-effort failed into acquire's next pass).
|
|
462
|
+
*/
|
|
463
|
+
function rmLockIfStill(path, pid) {
|
|
464
|
+
const cur = readHolder(path);
|
|
465
|
+
if (cur && cur.pid !== pid) return false;
|
|
466
|
+
try {
|
|
467
|
+
rmSync(path, { force: true });
|
|
468
|
+
} catch {
|
|
469
|
+
/* best-effort; a stale file is cleared by the next acquire */
|
|
470
|
+
}
|
|
471
|
+
return true;
|
|
432
472
|
}
|
|
433
473
|
|
|
434
474
|
/** Blocking, because this runs before there is an event loop worth yielding to
|
|
@@ -530,11 +570,11 @@ function standDown(holder, path, log) {
|
|
|
530
570
|
sleep(400);
|
|
531
571
|
}
|
|
532
572
|
|
|
533
|
-
// A SIGKILLed daemon never ran its release(), so clear what it left
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
return { failed: '
|
|
573
|
+
// A SIGKILLed daemon never ran its release(), so clear what it left — but
|
|
574
|
+
// only if the file still names the pid we stood down: in the gap since the
|
|
575
|
+
// last read a fresh daemon may have cleared it and taken the lock itself.
|
|
576
|
+
if (!rmLockIfStill(path, holder.pid)) {
|
|
577
|
+
return { failed: 'another daemon took the lock while it was being cleared — try again in a moment' };
|
|
538
578
|
}
|
|
539
579
|
return null;
|
|
540
580
|
}
|
|
@@ -564,7 +604,13 @@ function takeOverFrom(holder, path, log, { allowDowngrade = false } = {}) {
|
|
|
564
604
|
};
|
|
565
605
|
}
|
|
566
606
|
if (identified === false) {
|
|
567
|
-
|
|
607
|
+
// MEASURED: the lock's writer is gone and the pid now belongs to something
|
|
608
|
+
// else. That is a STALE LOCK, not an unremovable holder — refusing here
|
|
609
|
+
// used to brick every start after an OOM-kill or reboot recycled the pid
|
|
610
|
+
// to any live process, until a human deleted ~/.flowviant/daemon-*.lock by
|
|
611
|
+
// hand. Nothing is signalled (the process is a stranger); the caller
|
|
612
|
+
// clears the corpse the same way it clears a dead pid's.
|
|
613
|
+
return { stale: true };
|
|
568
614
|
}
|
|
569
615
|
if (!allowDowngrade && holder.version && cmpVersion(VERSION, holder.version) < 0) {
|
|
570
616
|
return {
|
|
@@ -608,9 +654,19 @@ export function acquireInstanceLock(fleetToken, repoRoot, opts = {}) {
|
|
|
608
654
|
// quietly.
|
|
609
655
|
if (noTakeover) return { ok: false, holder: neighbour, sameRepo: true };
|
|
610
656
|
log?.(`another project's daemon is serving this repo (pid ${neighbour.pid}).`);
|
|
611
|
-
|
|
612
|
-
|
|
657
|
+
// The OPTIONS ride along — this call used to drop them, so a deliberate
|
|
658
|
+
// `flowviant --takeover-downgrade` against a newer neighbour printed
|
|
659
|
+
// "--takeover-downgrade if you mean it" at somebody who had already
|
|
660
|
+
// typed it.
|
|
661
|
+
const bad = takeOverFrom(neighbour, neighbourLockPath(neighbour, path), log, { allowDowngrade });
|
|
662
|
+
if (bad?.stale) {
|
|
663
|
+
// The neighbour's lock is a corpse wearing a recycled pid — clear it (if
|
|
664
|
+
// it still names that pid) and carry on to our own lock.
|
|
665
|
+
log?.(`pid ${neighbour.pid} is no longer a daemon — clearing its stale lock.`);
|
|
666
|
+
rmLockIfStill(neighbourLockPath(neighbour, path), neighbour.pid);
|
|
667
|
+
} else if (bad) {
|
|
613
668
|
return { ok: false, holder: neighbour, sameRepo: true, takeoverFailed: bad.failed, unidentified: bad.unidentified };
|
|
669
|
+
}
|
|
614
670
|
}
|
|
615
671
|
|
|
616
672
|
// Two passes at most: one to clear a stale holder, one to take the lock. A
|
|
@@ -623,11 +679,20 @@ export function acquireInstanceLock(fleetToken, repoRoot, opts = {}) {
|
|
|
623
679
|
if (e.code !== 'EEXIST') return { ok: true, release: () => {}, unguarded: true };
|
|
624
680
|
const holder = readHolder(path);
|
|
625
681
|
if (!holder || !alive(holder.pid)) {
|
|
626
|
-
// A crashed daemon's leftover. Clear it and take it on the next pass
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
682
|
+
// A crashed daemon's leftover. Clear it and take it on the next pass —
|
|
683
|
+
// ownership-verified, because a concurrent start may have cleared and
|
|
684
|
+
// re-created it in the gap since our read.
|
|
685
|
+
if (holder) rmLockIfStill(path, holder.pid);
|
|
686
|
+
else {
|
|
687
|
+
// Unreadable content: re-read before clearing, so a half-written
|
|
688
|
+
// record a peer is writing RIGHT NOW is not deleted mid-write.
|
|
689
|
+
const again = readHolder(path);
|
|
690
|
+
if (again && alive(again.pid)) continue; // it finished writing — a real holder now
|
|
691
|
+
try {
|
|
692
|
+
rmSync(path, { force: true });
|
|
693
|
+
} catch {
|
|
694
|
+
return { ok: true, release: () => {}, unguarded: true };
|
|
695
|
+
}
|
|
631
696
|
}
|
|
632
697
|
continue;
|
|
633
698
|
}
|
|
@@ -653,10 +718,28 @@ export function acquireInstanceLock(fleetToken, repoRoot, opts = {}) {
|
|
|
653
718
|
const wanted = force || (here && !noTakeover);
|
|
654
719
|
if (wanted) {
|
|
655
720
|
const bad = takeOverFrom(holder, path, log, { allowDowngrade });
|
|
721
|
+
if (bad?.stale) {
|
|
722
|
+
// Measured: the lock's writer is gone and its pid was recycled to a
|
|
723
|
+
// stranger. A corpse is cleared, never "refused" — refusing bricked
|
|
724
|
+
// every start after a reboot handed the pid to any live process.
|
|
725
|
+
log?.(`pid ${holder.pid} is no longer a daemon — clearing its stale lock.`);
|
|
726
|
+
rmLockIfStill(path, holder.pid);
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
656
729
|
if (bad)
|
|
657
730
|
return { ok: false, holder, takeoverFailed: bad.failed, sameRepo: here, unidentified: bad.unidentified };
|
|
658
731
|
continue; // the file is gone — the next pass takes it
|
|
659
732
|
}
|
|
733
|
+
// Before refusing on a different-repo holder, make sure it IS one: a
|
|
734
|
+
// stale lock whose pid was recycled to any live process would otherwise
|
|
735
|
+
// refuse this credential's start forever, naming a "daemon" that is a
|
|
736
|
+
// stranger. Only the MEASURED verdict clears; null (could not look)
|
|
737
|
+
// still refuses, because ignorance must not delete a lock.
|
|
738
|
+
if (stillTheHolder(holder) === false) {
|
|
739
|
+
log?.(`pid ${holder.pid} is no longer a daemon — clearing its stale lock.`);
|
|
740
|
+
rmLockIfStill(path, holder.pid);
|
|
741
|
+
continue;
|
|
742
|
+
}
|
|
660
743
|
return { ok: false, holder, sameRepo: here };
|
|
661
744
|
}
|
|
662
745
|
try {
|
package/bin/lib/login.mjs
CHANGED
|
@@ -74,7 +74,11 @@ export async function runLogin({ thenStart = false } = {}) {
|
|
|
74
74
|
continue; // transient — keep polling
|
|
75
75
|
}
|
|
76
76
|
if (poll.status === 'approved') {
|
|
77
|
-
|
|
77
|
+
// `machineToken` is the wire's new name; `fleetToken` is the one every
|
|
78
|
+
// published daemon reads. The server dual-sends until DAEMON_MIN clears
|
|
79
|
+
// THIS release (0.54.2) — reading both here is what makes retiring the
|
|
80
|
+
// old key possible at all.
|
|
81
|
+
store({ fleetToken: poll.machineToken ?? poll.fleetToken, projectId: poll.projectId, mcpUrl: poll.mcpUrl });
|
|
78
82
|
ok('connected — credential saved to ~/.flowviant/credentials.json');
|
|
79
83
|
// The daemon starts right here unless the caller opted out; telling
|
|
80
84
|
// someone to run a second command was the step that got missed, since by
|
package/bin/lib/preview.mjs
CHANGED
|
@@ -228,9 +228,25 @@ function mutateRegistry(fn) {
|
|
|
228
228
|
}
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
+
/** Signal-0 liveness (EPERM = alive and not ours), for the OWNER check below. */
|
|
232
|
+
function processAlive(pid) {
|
|
233
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
234
|
+
try {
|
|
235
|
+
process.kill(pid, 0);
|
|
236
|
+
return true;
|
|
237
|
+
} catch (e) {
|
|
238
|
+
return e.code === 'EPERM';
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
231
242
|
function recordPreviewPid(pid, sig) {
|
|
232
243
|
if (!pid) return;
|
|
233
|
-
|
|
244
|
+
// `owner` is the DAEMON that spawned it. The registry is shared by design —
|
|
245
|
+
// two daemons serving two projects both write here — so without an owner a
|
|
246
|
+
// starting daemon reaped its PEER's live tunnels: killed them, wiped their
|
|
247
|
+
// entries, and the peer kept heartbeating a URL that 530s (its probe watches
|
|
248
|
+
// the origin port, which was still alive).
|
|
249
|
+
mutateRegistry((list) => [...list, { pid, sig, owner: process.pid }]);
|
|
234
250
|
}
|
|
235
251
|
|
|
236
252
|
function forgetPreviewPid(pid) {
|
|
@@ -253,12 +269,21 @@ function stillOurs(pid, sig) {
|
|
|
253
269
|
}
|
|
254
270
|
|
|
255
271
|
/** Reap tunnel process groups left behind by a previously-crashed daemon.
|
|
256
|
-
* Call once at daemon startup, before any work begins.
|
|
272
|
+
* Call once at daemon startup, before any work begins.
|
|
273
|
+
*
|
|
274
|
+
* ORPHANS ONLY: an entry whose owning daemon is STILL ALIVE belongs to a
|
|
275
|
+
* peer serving another project (or to the process we are replacing, whose
|
|
276
|
+
* own teardown handles it) — killing those and wiping their entries was a
|
|
277
|
+
* peer daemon's startup silently breaking every live share on the box. Only
|
|
278
|
+
* the entries this pass handled are removed; a peer's records survive. */
|
|
257
279
|
export function reapOrphanPreviews(log) {
|
|
258
280
|
const list = readRegistry();
|
|
259
281
|
if (list.length === 0) return;
|
|
260
282
|
let killed = 0;
|
|
261
|
-
|
|
283
|
+
const handled = new Set();
|
|
284
|
+
for (const { pid, sig, owner } of list) {
|
|
285
|
+
if (Number.isInteger(owner) && owner !== process.pid && processAlive(owner)) continue;
|
|
286
|
+
handled.add(pid);
|
|
262
287
|
if (!stillOurs(pid, sig)) continue;
|
|
263
288
|
try {
|
|
264
289
|
process.kill(-pid, 'SIGKILL'); // whole group
|
|
@@ -272,7 +297,7 @@ export function reapOrphanPreviews(log) {
|
|
|
272
297
|
}
|
|
273
298
|
}
|
|
274
299
|
}
|
|
275
|
-
mutateRegistry(() =>
|
|
300
|
+
if (handled.size) mutateRegistry((cur) => cur.filter((e) => !handled.has(e.pid)));
|
|
276
301
|
if (killed) log?.(`reaped ${killed} orphaned preview tunnel${killed === 1 ? '' : 's'} from a previous run.`);
|
|
277
302
|
}
|
|
278
303
|
|
|
@@ -295,8 +320,35 @@ const TAIL_BYTES = 2000;
|
|
|
295
320
|
* cloudflared happily outlives a dead dev server and the gate answers a dead
|
|
296
321
|
* origin with 502, so without this the product would report "live" over a 502 —
|
|
297
322
|
* Flowviant asserting a state it never measured.
|
|
323
|
+
*
|
|
324
|
+
* `stillServing` (optional, async → boolean) is the ATTRIBUTION re-check the
|
|
325
|
+
* probe runs instead of a bare TCP connect. Ports are global to a box and a
|
|
326
|
+
* worktree is not: when the driver's dev server dies and anything else — a
|
|
327
|
+
* teammate's worktree, a database — binds the same number, a bare
|
|
328
|
+
* `isListening` keeps the probe green and the existing URL+password serve the
|
|
329
|
+
* NEW process, outside every consent gate. The caller passes the same
|
|
330
|
+
* `listenersIn(worktree)` check the open path uses, so "the origin is alive"
|
|
331
|
+
* keeps meaning "THIS session's origin".
|
|
332
|
+
*
|
|
333
|
+
* `onAbuse` fires when the gate closes itself after repeated failed password
|
|
334
|
+
* attempts — AFTER the share is torn down locally — so the caller can report
|
|
335
|
+
* the incident. Without it the abuse close was invisible: the row kept
|
|
336
|
+
* reading "live" until staleness, and endedReason 'abuse' was unreachable.
|
|
337
|
+
*
|
|
338
|
+
* `onTunnelGone` fires when cloudflared exits AFTER the URL was published
|
|
339
|
+
* (quick tunnels are best-effort and do get dropped). The probe cannot see
|
|
340
|
+
* this — it watches the origin — and a daemon that keeps heartbeating a dead
|
|
341
|
+
* hostname confirms "live" over a 530 for up to 8 hours.
|
|
298
342
|
*/
|
|
299
|
-
export async function openTunnel({
|
|
343
|
+
export async function openTunnel({
|
|
344
|
+
port,
|
|
345
|
+
log,
|
|
346
|
+
onDead,
|
|
347
|
+
onAbuse,
|
|
348
|
+
onTunnelGone,
|
|
349
|
+
stillServing,
|
|
350
|
+
probeMs = 20_000,
|
|
351
|
+
}) {
|
|
300
352
|
// Re-validate at the machine. The server checked this port against the last
|
|
301
353
|
// report; reports are up to a minute old and a dev server is a process a
|
|
302
354
|
// human can stop at any moment.
|
|
@@ -337,7 +389,18 @@ export async function openTunnel({ port, log, onDead, probeMs = 20_000 }) {
|
|
|
337
389
|
|
|
338
390
|
// The gate comes up FIRST and the tunnel points at it, never at the origin —
|
|
339
391
|
// so there is no window in which the public hostname is un-gated.
|
|
340
|
-
gate = await startAuthProxy({
|
|
392
|
+
gate = await startAuthProxy({
|
|
393
|
+
targetPort: port,
|
|
394
|
+
log,
|
|
395
|
+
onAbuse: () => {
|
|
396
|
+
stop();
|
|
397
|
+
try {
|
|
398
|
+
onAbuse?.();
|
|
399
|
+
} catch {
|
|
400
|
+
/* the caller's report is best-effort */
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
});
|
|
341
404
|
if (!gate) {
|
|
342
405
|
return { error: 'could not start the password gate for this preview, so nothing was published.' };
|
|
343
406
|
}
|
|
@@ -348,7 +411,11 @@ export async function openTunnel({ port, log, onDead, probeMs = 20_000 }) {
|
|
|
348
411
|
args.push('--http-host-header', 'localhost');
|
|
349
412
|
|
|
350
413
|
tunnel = spawn(cf.bin, args, { detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
351
|
-
|
|
414
|
+
// The signature names THIS tunnel's gate port, not the bare word
|
|
415
|
+
// 'cloudflared': the reap matches cmdline.includes(sig), and the generic
|
|
416
|
+
// word would let a recycled pid land on an operator's own unrelated
|
|
417
|
+
// cloudflared and group-SIGKILL it.
|
|
418
|
+
recordPreviewPid(tunnel.pid, `--url http://localhost:${gate.port}`);
|
|
352
419
|
|
|
353
420
|
return new Promise((resolve) => {
|
|
354
421
|
let settled = false;
|
|
@@ -378,11 +445,19 @@ export async function openTunnel({ port, log, onDead, probeMs = 20_000 }) {
|
|
|
378
445
|
const m = TUNNEL_RE.exec(s);
|
|
379
446
|
if (!m) return;
|
|
380
447
|
|
|
381
|
-
// Watch the ORIGIN
|
|
382
|
-
//
|
|
448
|
+
// Watch the ORIGIN — with the caller's ATTRIBUTION check when it gave
|
|
449
|
+
// one, never a bare port probe: a freed port rebound by another
|
|
450
|
+
// worktree answers a TCP connect exactly like the origin did, and the
|
|
451
|
+
// share would keep serving a process nobody consented to publish.
|
|
383
452
|
probe = setInterval(async () => {
|
|
384
453
|
if (stopped) return;
|
|
385
|
-
|
|
454
|
+
let serving;
|
|
455
|
+
try {
|
|
456
|
+
serving = stillServing ? await stillServing() : await isListening(port);
|
|
457
|
+
} catch {
|
|
458
|
+
serving = false; // an attribution check that errors is not a "yes"
|
|
459
|
+
}
|
|
460
|
+
if (!serving) {
|
|
386
461
|
const dead = onDead;
|
|
387
462
|
stop();
|
|
388
463
|
try {
|
|
@@ -394,6 +469,20 @@ export async function openTunnel({ port, log, onDead, probeMs = 20_000 }) {
|
|
|
394
469
|
}, probeMs);
|
|
395
470
|
if (probe.unref) probe.unref();
|
|
396
471
|
|
|
472
|
+
// The TUNNEL dying after publish (quick tunnels get dropped) is the one
|
|
473
|
+
// exit the probe cannot see. `stopped` guards our own kill: stop() sets
|
|
474
|
+
// it before signalling, so this only fires for a death nobody asked for.
|
|
475
|
+
tunnel.once('close', () => {
|
|
476
|
+
if (stopped) return;
|
|
477
|
+
const gone = onTunnelGone;
|
|
478
|
+
stop();
|
|
479
|
+
try {
|
|
480
|
+
gone?.();
|
|
481
|
+
} catch {
|
|
482
|
+
/* the caller's report is best-effort */
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
|
|
397
486
|
finish({ url: m[0], user: gate.user, password: gate.password, stop });
|
|
398
487
|
};
|
|
399
488
|
|
package/bin/lib/work.mjs
CHANGED
|
@@ -492,7 +492,15 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
492
492
|
/* best-effort */
|
|
493
493
|
}
|
|
494
494
|
}
|
|
495
|
-
|
|
495
|
+
// Confirm only a teardown we actually PERFORMED. The stop job is a
|
|
496
|
+
// broadcast — every daemon on the credential gets it — and the one holding
|
|
497
|
+
// nothing used to answer instantly, flipping the row to 'ended' so the
|
|
498
|
+
// real holder was never told to stop and its tunnel outlived every
|
|
499
|
+
// surface. (The server drops mismatched confirms too; this is the copy on
|
|
500
|
+
// the component that can be published ahead of a deploy.) A stop for a
|
|
501
|
+
// tunnel whose daemon crashed resolves server-side: an unanswered 'ending'
|
|
502
|
+
// row reads as over once it goes stale.
|
|
503
|
+
if (live) await postPreview({ sessionId, ended: true, endedReason: reason });
|
|
496
504
|
};
|
|
497
505
|
|
|
498
506
|
const processPreviewJobs = (jobs) => {
|
|
@@ -552,6 +560,27 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
552
560
|
livePreviews.delete(sessionId);
|
|
553
561
|
void postPreview({ sessionId, ended: true, endedReason: 'origin_gone' });
|
|
554
562
|
},
|
|
563
|
+
// ATTRIBUTION rides the probe, not just the open: a freed default
|
|
564
|
+
// port (5173…) rebound by any other process on the box would keep
|
|
565
|
+
// a bare TCP probe green, and the share's URL+password would serve
|
|
566
|
+
// a worktree nobody consented to publish.
|
|
567
|
+
stillServing: async () => listenersIn(wt).some((l) => l.port === port),
|
|
568
|
+
// The gate closed itself after repeated failed passwords. Stored,
|
|
569
|
+
// so the incident is visible — and the entry is dropped so the
|
|
570
|
+
// owner can re-share the port without restarting the daemon.
|
|
571
|
+
onAbuse: () => {
|
|
572
|
+
livePreviews.delete(sessionId);
|
|
573
|
+
void postPreview({ sessionId, ended: true, endedReason: 'abuse' });
|
|
574
|
+
},
|
|
575
|
+
// cloudflared died AFTER publishing (quick tunnels get dropped).
|
|
576
|
+
// Without this the daemon kept heartbeating a hostname that 530s.
|
|
577
|
+
onTunnelGone: () => {
|
|
578
|
+
livePreviews.delete(sessionId);
|
|
579
|
+
void postPreview({
|
|
580
|
+
sessionId,
|
|
581
|
+
error: 'the tunnel dropped — share it again to reopen.',
|
|
582
|
+
});
|
|
583
|
+
},
|
|
555
584
|
});
|
|
556
585
|
if (t.error) {
|
|
557
586
|
await postPreview({ sessionId, error: t.error });
|
|
@@ -1195,6 +1224,13 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
1195
1224
|
// worktree would pull the directory out from under a running turn. Absence
|
|
1196
1225
|
// means "the tab closed"; this is the one other thing it can mean.
|
|
1197
1226
|
const peers = new Set(Array.isArray(heldElsewhere) ? heldElsewhere : []);
|
|
1227
|
+
// A peer-held session's CACHED work token is a claim-bypass: the mint is
|
|
1228
|
+
// the one place the session lease 409s a non-holder, and a token younger
|
|
1229
|
+
// than ~23h skips the mint entirely — so a daemon that lost a lease would
|
|
1230
|
+
// run the next turn anyway, editing the worktree while every MCP call
|
|
1231
|
+
// 401s (the peer's mint rotated the secret). Dropping the cache forces
|
|
1232
|
+
// the next turn through the mint, where the 409 stands it down.
|
|
1233
|
+
for (const id of peers) workTokens.delete(id);
|
|
1198
1234
|
const dir = join(baseDir, 'sessions');
|
|
1199
1235
|
if (!existsSync(dir)) return;
|
|
1200
1236
|
let ids;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.54.
|
|
4
|
-
"description": "Run your own coding CLIs as build agents for Flowviant
|
|
3
|
+
"version": "0.54.2",
|
|
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"
|