flowviant 0.52.0 → 0.53.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/bin/lib/authproxy.mjs +131 -32
- package/bin/lib/config.mjs +16 -0
- package/bin/lib/fleet.mjs +36 -15
- package/bin/lib/listeners.mjs +269 -0
- package/bin/lib/preview.mjs +294 -390
- package/bin/lib/work.mjs +211 -3
- package/package.json +1 -1
package/bin/lib/work.mjs
CHANGED
|
@@ -30,8 +30,16 @@ import {
|
|
|
30
30
|
} from 'node:fs';
|
|
31
31
|
import { execFileSync } from 'node:child_process';
|
|
32
32
|
import { join, dirname } from 'node:path';
|
|
33
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
FLEET_URL,
|
|
35
|
+
FLEET_TOKEN,
|
|
36
|
+
USER_AGENT,
|
|
37
|
+
REFRESH_BEFORE_SECONDS,
|
|
38
|
+
DAEMON_INSTANCE,
|
|
39
|
+
} from './config.mjs';
|
|
34
40
|
import { git, gitRaw, splitNul, baseBranchName, isSafePathSegment } from './git.mjs';
|
|
41
|
+
import { listenersIn } from './listeners.mjs';
|
|
42
|
+
import { openTunnel } from './preview.mjs';
|
|
35
43
|
import { c, note, ok, warn } from './ui.mjs';
|
|
36
44
|
import { mcpFor, runTurn } from './claude.mjs';
|
|
37
45
|
import {
|
|
@@ -92,6 +100,8 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
92
100
|
const ACTIVITY_URL = FLEET_URL.replace(/\/agents\/?$/, '/session-activity');
|
|
93
101
|
const WORKTREES_URL = FLEET_URL.replace(/\/agents\/?$/, '/session-worktrees');
|
|
94
102
|
const DIFF_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/diff-done');
|
|
103
|
+
const PREVIEW_CLAIM_URL = FLEET_URL.replace(/\/agents\/?$/, '/preview-claim');
|
|
104
|
+
const PREVIEW_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/preview-done');
|
|
95
105
|
const ATTACHMENT_URL = FLEET_URL.replace(/\/agents\/?$/, '/attachment');
|
|
96
106
|
const workAnswering = new Set(); // turn ids currently queued/running here
|
|
97
107
|
const workAttempts = new Map(); // turn id -> completed runTurn attempts
|
|
@@ -327,8 +337,19 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
327
337
|
};
|
|
328
338
|
const sessionWorktreeReport = (sessionId) => {
|
|
329
339
|
if (!isSafePathSegment(sessionId)) return null;
|
|
330
|
-
const
|
|
331
|
-
|
|
340
|
+
const wt = join(baseDir, 'sessions', sessionId);
|
|
341
|
+
const d = worktreeDiff(wt, baseRef);
|
|
342
|
+
if (!d) return null;
|
|
343
|
+
// WHAT IS LISTENING in this worktree, attributed by the CWD of the process
|
|
344
|
+
// holding the socket. It rides the sweep the daemon already makes rather
|
|
345
|
+
// than taking a beat of its own, exactly as the commit trailers do — and
|
|
346
|
+
// like them it needs no version floor, because it is a daemon→server report
|
|
347
|
+
// on an endpoint that already exists. An older server ignores the key.
|
|
348
|
+
//
|
|
349
|
+
// The browser NEVER names a directory and never names a port this did not
|
|
350
|
+
// report: ports are global to a box and a worktree is not, so this
|
|
351
|
+
// measurement is the security boundary for the whole preview feature.
|
|
352
|
+
return { sessionId, ...d, listening: listenersIn(wt) };
|
|
332
353
|
};
|
|
333
354
|
/** One session, now — called after its turn settles. */
|
|
334
355
|
const reportSessionWorktree = async (sessionId) => {
|
|
@@ -403,6 +424,189 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
403
424
|
/* the row stays pending and expires; the next click re-requests */
|
|
404
425
|
}
|
|
405
426
|
};
|
|
427
|
+
// ── SESSION PREVIEWS ──────────────────────────────────────────────────────
|
|
428
|
+
//
|
|
429
|
+
// Share the dev server the DRIVER is already running in their tab, behind a
|
|
430
|
+
// generated password, on a quick tunnel. This daemon never starts an app: the
|
|
431
|
+
// deleted live-preview feature ran a repo-declared command through a shell,
|
|
432
|
+
// and that is the reason it is deleted. Here the human runs their own server,
|
|
433
|
+
// `listenersIn` notices it, and this only ever wraps a port that measurement
|
|
434
|
+
// already named for that session.
|
|
435
|
+
//
|
|
436
|
+
// CLAIM BEFORE ACTING. Two daemons legitimately share one fleet credential —
|
|
437
|
+
// the case `machineDaemonsDisagree` exists because it happens, and the 0.51.2
|
|
438
|
+
// instance lock is blind to an OLDER peer — so both are handed the same job
|
|
439
|
+
// array. Both opening a tunnel leaves a public hostname alive that nobody
|
|
440
|
+
// owns and nobody can tear down, because only the lease holder can settle the
|
|
441
|
+
// row. `processDiffJobs` gets away without this because running `git show`
|
|
442
|
+
// twice costs nothing.
|
|
443
|
+
const livePreviews = new Map(); // sessionId -> { port, url, stop }
|
|
444
|
+
const previewClaiming = new Set(); // sessionIds mid-claim on this tick
|
|
445
|
+
|
|
446
|
+
const postPreview = async (body) => {
|
|
447
|
+
try {
|
|
448
|
+
await fetch(PREVIEW_DONE_URL, {
|
|
449
|
+
method: 'POST',
|
|
450
|
+
headers: {
|
|
451
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
452
|
+
'User-Agent': USER_AGENT,
|
|
453
|
+
'Content-Type': 'application/json',
|
|
454
|
+
},
|
|
455
|
+
signal: AbortSignal.timeout(30_000),
|
|
456
|
+
body: JSON.stringify({ ...body, instance: DAEMON_INSTANCE }),
|
|
457
|
+
});
|
|
458
|
+
} catch {
|
|
459
|
+
/* the row stops being confirmed and reads as ended — which is true */
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
const claimPreview = async (sessionId) => {
|
|
464
|
+
try {
|
|
465
|
+
const res = await fetch(PREVIEW_CLAIM_URL, {
|
|
466
|
+
method: 'POST',
|
|
467
|
+
headers: {
|
|
468
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
469
|
+
'User-Agent': USER_AGENT,
|
|
470
|
+
'Content-Type': 'application/json',
|
|
471
|
+
},
|
|
472
|
+
signal: AbortSignal.timeout(15_000),
|
|
473
|
+
body: JSON.stringify({ sessionId, instance: DAEMON_INSTANCE }),
|
|
474
|
+
});
|
|
475
|
+
const j = await res.json().catch(() => null);
|
|
476
|
+
return j?.data?.claimed === true;
|
|
477
|
+
} catch {
|
|
478
|
+
return false; // could not claim → do nothing at all. The other daemon may have.
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
/** Tear one down here, and say so. `reason` is why, stored server-side rather
|
|
483
|
+
* than inferred: "the origin stopped listening" and "the owner pressed Stop"
|
|
484
|
+
* are different sentences to a teammate holding a phone. */
|
|
485
|
+
const stopPreview = async (sessionId, reason) => {
|
|
486
|
+
const live = livePreviews.get(sessionId);
|
|
487
|
+
livePreviews.delete(sessionId);
|
|
488
|
+
if (live) {
|
|
489
|
+
try {
|
|
490
|
+
live.stop();
|
|
491
|
+
} catch {
|
|
492
|
+
/* best-effort */
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
await postPreview({ sessionId, ended: true, endedReason: reason });
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
const processPreviewJobs = (jobs) => {
|
|
499
|
+
if (!Array.isArray(jobs) || jobs.length === 0) return;
|
|
500
|
+
for (const job of jobs.slice(0, 5)) {
|
|
501
|
+
const sessionId = String(job?.sessionId || '');
|
|
502
|
+
const port = Number(job?.port);
|
|
503
|
+
if (!isSafePathSegment(sessionId)) continue;
|
|
504
|
+
|
|
505
|
+
if (job?.action === 'stop') {
|
|
506
|
+
if (previewClaiming.has(sessionId)) continue;
|
|
507
|
+
previewClaiming.add(sessionId);
|
|
508
|
+
void stopPreview(sessionId, 'stopped').finally(() => previewClaiming.delete(sessionId));
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) continue;
|
|
513
|
+
// Already serving exactly this. Re-opening would replace a working URL
|
|
514
|
+
// somebody may be looking at right now.
|
|
515
|
+
if (livePreviews.get(sessionId)?.port === port) continue;
|
|
516
|
+
if (previewClaiming.has(sessionId)) continue;
|
|
517
|
+
previewClaiming.add(sessionId);
|
|
518
|
+
|
|
519
|
+
void (async () => {
|
|
520
|
+
try {
|
|
521
|
+
if (!(await claimPreview(sessionId))) return; // somebody else has it
|
|
522
|
+
const wt = join(baseDir, 'sessions', sessionId);
|
|
523
|
+
// RE-VALIDATE the attribution here, not just the liveness. The server
|
|
524
|
+
// checked this port against a report up to a minute old; more
|
|
525
|
+
// importantly, checking `listenersIn` again is what keeps the answer
|
|
526
|
+
// to "whose port is this" on the machine that can actually see it.
|
|
527
|
+
const measured = listenersIn(wt).some((l) => l.port === port);
|
|
528
|
+
if (!measured) {
|
|
529
|
+
await postPreview({
|
|
530
|
+
sessionId,
|
|
531
|
+
error: `nothing is listening on port ${port} in this worktree.`,
|
|
532
|
+
});
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
// Replace anything this session already had — one tab, one door.
|
|
536
|
+
const prev = livePreviews.get(sessionId);
|
|
537
|
+
if (prev) {
|
|
538
|
+
try {
|
|
539
|
+
prev.stop();
|
|
540
|
+
} catch {
|
|
541
|
+
/* best-effort */
|
|
542
|
+
}
|
|
543
|
+
livePreviews.delete(sessionId);
|
|
544
|
+
}
|
|
545
|
+
const t = await openTunnel({
|
|
546
|
+
port,
|
|
547
|
+
log: (m) => note(`preview ${sessionId.slice(0, 8)}: ${m}`),
|
|
548
|
+
// The origin died under a live tunnel. cloudflared happily outlives
|
|
549
|
+
// a dead dev server and the gate answers a dead origin with 502, so
|
|
550
|
+
// without this the app would print "live" over a 502.
|
|
551
|
+
onDead: () => {
|
|
552
|
+
livePreviews.delete(sessionId);
|
|
553
|
+
void postPreview({ sessionId, ended: true, endedReason: 'origin_gone' });
|
|
554
|
+
},
|
|
555
|
+
});
|
|
556
|
+
if (t.error) {
|
|
557
|
+
await postPreview({ sessionId, error: t.error });
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
livePreviews.set(sessionId, { port, url: t.url, stop: t.stop });
|
|
561
|
+
await postPreview({ sessionId, url: t.url, user: t.user, password: t.password });
|
|
562
|
+
} finally {
|
|
563
|
+
previewClaiming.delete(sessionId);
|
|
564
|
+
}
|
|
565
|
+
})();
|
|
566
|
+
}
|
|
567
|
+
};
|
|
568
|
+
|
|
569
|
+
/** The sessionIds this machine is still serving — sent on the poll so the
|
|
570
|
+
* server can tell a live share from one whose machine went away. Silence
|
|
571
|
+
* must never read as "live". */
|
|
572
|
+
const livePreviewIds = () => [...livePreviews.keys()];
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* The tab closed (or the server stopped listing it). Ordered BEFORE
|
|
576
|
+
* `retireWorkSessions`, and that ordering is load-bearing: `git worktree
|
|
577
|
+
* remove` under a running dev server reintroduces the stale-server bug — on
|
|
578
|
+
* Linux the process keeps serving bytes from open file handles in a directory
|
|
579
|
+
* that no longer exists, which shows a human the wrong thing without erroring
|
|
580
|
+
* anywhere.
|
|
581
|
+
*/
|
|
582
|
+
const retirePreviews = (activeIds) => {
|
|
583
|
+
// Same guard `retireWorkSessions` keeps: a roster response missing the
|
|
584
|
+
// field is an older server, not a close, and must not tear down every live
|
|
585
|
+
// share at once.
|
|
586
|
+
if (!Array.isArray(activeIds)) return;
|
|
587
|
+
const live = new Set(activeIds);
|
|
588
|
+
for (const sessionId of [...livePreviews.keys()]) {
|
|
589
|
+
if (live.has(sessionId)) continue;
|
|
590
|
+
if (previewClaiming.has(sessionId)) continue;
|
|
591
|
+
previewClaiming.add(sessionId);
|
|
592
|
+
void stopPreview(sessionId, 'tab_closed').finally(() => previewClaiming.delete(sessionId));
|
|
593
|
+
}
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
/** Daemon shutdown. Detached tunnels survive our exit by design, so leaving
|
|
597
|
+
* them would strand a public hostname until the box rebooted — the exact
|
|
598
|
+
* case `reapOrphanPreviews` exists to clean up after an UNgraceful death. */
|
|
599
|
+
const shutdownPreviews = () => {
|
|
600
|
+
for (const [, live] of livePreviews) {
|
|
601
|
+
try {
|
|
602
|
+
live.stop();
|
|
603
|
+
} catch {
|
|
604
|
+
/* best-effort */
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
livePreviews.clear();
|
|
608
|
+
};
|
|
609
|
+
|
|
406
610
|
const processDiffJobs = (jobs) => {
|
|
407
611
|
if (!Array.isArray(jobs) || jobs.length === 0) return;
|
|
408
612
|
for (const job of jobs.slice(0, 5)) {
|
|
@@ -1860,6 +2064,10 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
1860
2064
|
processWorkTurns,
|
|
1861
2065
|
processShipJobs,
|
|
1862
2066
|
processDiffJobs,
|
|
2067
|
+
processPreviewJobs,
|
|
2068
|
+
livePreviewIds,
|
|
2069
|
+
retirePreviews,
|
|
2070
|
+
shutdownPreviews,
|
|
1863
2071
|
retireWorkSessions,
|
|
1864
2072
|
reportWorktrees,
|
|
1865
2073
|
shutdownWork,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.53.0",
|
|
4
4
|
"description": "Run your own coding CLIs as build agents for Flowviant \u2014 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": {
|