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/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 { FLEET_URL, FLEET_TOKEN, USER_AGENT, REFRESH_BEFORE_SECONDS } from './config.mjs';
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 d = worktreeDiff(join(baseDir, 'sessions', sessionId), baseRef);
331
- return d ? { sessionId, ...d } : null;
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.52.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": {