flowviant 0.52.0 → 0.54.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.
@@ -687,9 +687,18 @@ export const RUNTIMES = {
687
687
  // turn reports failure having touched nothing. Caught only because an
688
688
  // end-to-end test had to add the flag by hand to work.
689
689
  //
690
- // NOT `--sandbox` here, unlike wiki: a build has to `git push` and run
691
- // `gh pr create`, so it needs the network by definition. The containment
692
- // is the worktree, as it is for every runtime.
690
+ // NOT `--sandbox` here, unlike wiki: a build has to `git push`, so it
691
+ // needs the network by definition.
692
+ //
693
+ // AND NOTHING ELSE CONTAINS IT EITHER. This comment used to end "the
694
+ // containment is the worktree, as it is for every runtime", which was
695
+ // false and is the kind of false that stops people looking: `cwd` is a
696
+ // starting directory, not a jail. A build turn runs with the operator's
697
+ // full user permissions — it can write outside the worktree, to their
698
+ // home directory, to their other checkouts. Claude gets
699
+ // --dangerously-skip-permissions, codex gets --sandbox
700
+ // danger-full-access, and agy gets no sandbox flag at all. The only
701
+ // real boundary today is that the person driving the tab is trusted.
693
702
  //
694
703
  // FLOWVIANT_SAFE HAS NO EXPRESSION ON THIS RUNTIME. Claude narrows to an
695
704
  // allowlist and Codex to `workspace-write`; agy's only per-invocation
@@ -1,11 +1,13 @@
1
1
  /**
2
- * Fleet daemon push channel — the best-practice endgame for dispatch latency.
2
+ * Machine daemon push channel — the best-practice endgame for pickup latency.
3
3
  *
4
- * Holds a hibernatable WebSocket open to the server. When a job lands (a wiki
5
- * regen, a dispatch, a merge request, an @mention), the server pushes a
6
- * `{type:'wake'}` frame and the daemon reconciles IMMEDIATELY — its normal
7
- * roster fetch — instead of waiting out the poll. That collapses pickup latency
8
- * from ≤RECONCILE_SECONDS to ~a round trip.
4
+ * Holds a hibernatable WebSocket open to the server. When a job lands a
5
+ * session turn typed into a tab, a diff request, a preview, an env sync, a
6
+ * deploy, a wiki regen — the server pushes a `{type:'wake'}` frame and the
7
+ * daemon reconciles IMMEDIATELY — its normal roster fetch — instead of waiting
8
+ * out the poll. That collapses pickup latency from ≤RECONCILE_SECONDS to ~a
9
+ * round trip, which is what makes a message typed in the browser start on the
10
+ * machine at once.
9
11
  *
10
12
  * The socket carries NO authority: it's a dumb nudge, the roster HTTP fetch is
11
13
  * the source of truth (notify-then-reconcile, à la k8s watch). So a dropped or
@@ -74,9 +76,9 @@ export function connectStream({ onWake, isAlive }) {
74
76
  lastRxAt = Date.now();
75
77
  if (announcedDown) {
76
78
  announcedDown = false;
77
- info(c.dim('push channel reconnected — instant dispatch back on'));
79
+ info(c.dim('push channel reconnected — turns start instantly again'));
78
80
  } else {
79
- note(c.dim('push channel connected — instant dispatch on'));
81
+ note(c.dim('push channel connected — turns start instantly'));
80
82
  }
81
83
  clearPing();
82
84
  pingTimer = setInterval(() => {
@@ -114,10 +114,11 @@ export function handleVersionSignal({ latest, min, autoUpdate, safeToUpdate, tea
114
114
 
115
115
  if (wantInstall && !npx) {
116
116
  if (!safeToUpdate) {
117
- // Outdated but an agent is mid-task — wait for idle. Nag once meanwhile.
117
+ // Outdated but a turn is running — wait until the machine is quiet. Nag
118
+ // once meanwhile.
118
119
  if (naggedFor !== target) {
119
120
  naggedFor = target;
120
- note(`flowviant ${cur} → ${target} available — self-updating once agents go idle.`);
121
+ note(`flowviant ${cur} → ${target} available — self-updating once no turn is running.`);
121
122
  }
122
123
  return false;
123
124
  }
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)) {
@@ -595,9 +799,17 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
595
799
  'Content-Type': 'application/json',
596
800
  },
597
801
  signal: AbortSignal.timeout(30_000),
598
- body: JSON.stringify({ sessionId }),
802
+ // The instance is what CLAIMS the session lease server-side. Two
803
+ // daemons share one fleet credential, so the token cannot say which of
804
+ // us is serving this tab — and the mint is the moment that matters:
805
+ // there is one work-token row per session and minting ROTATES it, so a
806
+ // second mint revokes the first daemon's live secret mid-turn.
807
+ body: JSON.stringify({ sessionId, instance: DAEMON_INSTANCE }),
599
808
  });
600
809
  if (res.status === 404) return { gone: true };
810
+ // 409 — another daemon on this credential holds the session. Not ours to
811
+ // serve and not a retry: stand down and let the holder answer.
812
+ if (res.status === 409) return { heldElsewhere: true };
601
813
  if (!res.ok) return null;
602
814
  const token = (await res.json().catch(() => null))?.data?.token ?? null;
603
815
  if (!token) return null;
@@ -964,8 +1176,25 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
964
1176
  * machines. When the roster omits the field entirely (older server),
965
1177
  * absence of signal is not a close — retire nothing.
966
1178
  */
967
- const retireWorkSessions = (activeIds) => {
1179
+ /** Every session this daemon currently has a worktree for — what renews our
1180
+ * lease on the poll. Read off the directory rather than a map, so it is the
1181
+ * same fact retirement acts on. */
1182
+ const heldSessionIds = () => {
1183
+ const dir = join(baseDir, 'sessions');
1184
+ try {
1185
+ return readdirSync(dir).filter(isSafePathSegment).slice(0, 50);
1186
+ } catch {
1187
+ return [];
1188
+ }
1189
+ };
1190
+
1191
+ const retireWorkSessions = (activeIds, heldElsewhere) => {
968
1192
  if (!Array.isArray(activeIds)) return;
1193
+ // Sessions ANOTHER daemon on this credential is serving. They are absent
1194
+ // from activeWorkSessions for us and present for them, and removing their
1195
+ // worktree would pull the directory out from under a running turn. Absence
1196
+ // means "the tab closed"; this is the one other thing it can mean.
1197
+ const peers = new Set(Array.isArray(heldElsewhere) ? heldElsewhere : []);
969
1198
  const dir = join(baseDir, 'sessions');
970
1199
  if (!existsSync(dir)) return;
971
1200
  let ids;
@@ -978,6 +1207,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
978
1207
  let removed = 0;
979
1208
  for (const id of ids) {
980
1209
  if (live.has(id)) continue;
1210
+ if (peers.has(id)) continue; // another daemon's tab — not ours to retire
981
1211
  if (workChains.has(id) || shipping.has(id)) continue; // still draining here
982
1212
  const wt = join(dir, id);
983
1213
  try {
@@ -1208,6 +1438,15 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
1208
1438
  if (!plainTab) {
1209
1439
  mint = await mintWorkToken(job.sessionId);
1210
1440
  if (!mint) mint = await mintWorkToken(job.sessionId, true); // one transient blip ≠ a dead turn
1441
+ // Another daemon on this credential holds the session. Return
1442
+ // WITHOUT settling: the holder is answering this same turn, and
1443
+ // settling it here — even as a failure — would race the real
1444
+ // answer and could win. Dropping it means the turn stays pending
1445
+ // and the holder's answer lands, which is the whole point.
1446
+ if (mint?.heldElsewhere) {
1447
+ workAnswering.delete(job.id);
1448
+ return;
1449
+ }
1211
1450
  if (mint?.gone) {
1212
1451
  await settleWorkTurn(job.id, {
1213
1452
  ok: false,
@@ -1860,6 +2099,11 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
1860
2099
  processWorkTurns,
1861
2100
  processShipJobs,
1862
2101
  processDiffJobs,
2102
+ heldSessionIds,
2103
+ processPreviewJobs,
2104
+ livePreviewIds,
2105
+ retirePreviews,
2106
+ shutdownPreviews,
1863
2107
  retireWorkSessions,
1864
2108
  reportWorktrees,
1865
2109
  shutdownWork,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.52.0",
3
+ "version": "0.54.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": {