flowviant 0.12.0 → 0.13.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.
@@ -4,7 +4,7 @@ import { readFileSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { homedir } from 'node:os';
6
6
 
7
- export const VERSION = '0.12.0';
7
+ export const VERSION = '0.13.0';
8
8
 
9
9
  // Credential stored by `flowviant login` (device auth) — the no-token,
10
10
  // no-env-var path. An explicit --fleet flag or FLOWVIANT_FLEET env still wins.
package/bin/lib/live.mjs CHANGED
@@ -54,6 +54,23 @@ async function registerLiveTarget(intentId, kind, url) {
54
54
  }
55
55
  }
56
56
 
57
+ // The preview's tunnel is going down (replaced by another task's, or the daemon
58
+ // is stopping/restarting) — tell Flowviant to drop the link so it doesn't keep
59
+ // offering a dead URL that 530s. Best-effort + short timeout so teardown is snappy.
60
+ const LIVE_TARGET_CLEAR_URL = FLEET_URL.replace(/\/agents\/?$/, '/live-target-clear');
61
+ function clearLiveTarget(intentId, kind) {
62
+ return fetch(LIVE_TARGET_CLEAR_URL, {
63
+ method: 'POST',
64
+ headers: {
65
+ Authorization: `Bearer ${FLEET_TOKEN}`,
66
+ 'User-Agent': USER_AGENT,
67
+ 'Content-Type': 'application/json',
68
+ },
69
+ signal: AbortSignal.timeout(5_000),
70
+ body: JSON.stringify({ intentId, kind }),
71
+ }).catch(() => {});
72
+ }
73
+
57
74
  // Safe mode's curated toolset. Bash is scoped to the specific CLIs the agent
58
75
  // needs (git/gh/npm/bun) — NOT bare `Bash`, which would auto-approve arbitrary
59
76
  // shell (rm -rf, curl|sh, reading ~/.ssh) and defeat the point of safe mode.
@@ -598,6 +615,7 @@ export async function runLiveWorker({
598
615
  // kept up while it's in review (a gated agent parks, so it lives until review
599
616
  // resolves). Replaced when the next task finishes; torn down on shutdown.
600
617
  let preview = null;
618
+ let previewTarget = null; // { intentId, kind } of the currently-registered link
601
619
  const stopPreview = () => {
602
620
  if (preview) {
603
621
  try {
@@ -607,6 +625,11 @@ export async function runLiveWorker({
607
625
  }
608
626
  preview = null;
609
627
  }
628
+ // Drop the app-side link so it stops offering a now-dead tunnel (530).
629
+ if (previewTarget) {
630
+ void clearLiveTarget(previewTarget.intentId, previewTarget.kind);
631
+ previewTarget = null;
632
+ }
610
633
  // Detached preview children (dev server + tunnel) survive process exit, so
611
634
  // the daemon's SIGINT teardown needs a handle to stop them — clear it here
612
635
  // once they're down.
@@ -645,6 +668,7 @@ export async function runLiveWorker({
645
668
  if (preview) {
646
669
  onPreview?.(stopPreview); // hand the daemon a stop handle for shutdown
647
670
  await registerLiveTarget(intentId, kind, preview.url);
671
+ previewTarget = { intentId, kind }; // so teardown can drop the link
648
672
  ok(`${label} ${c.dim('live preview ready — open the node to drive it in your review')}`);
649
673
  }
650
674
  };
@@ -201,11 +201,18 @@ async function ensureCloudflared(log) {
201
201
  }
202
202
 
203
203
  const TUNNEL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
204
+ // Where a dev server announces it bound — "Local: http://localhost:3001/".
205
+ const BIND_RE = /https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0):(\d+)/i;
204
206
 
205
207
  /**
206
208
  * Start the dev server + tunnel for one worktree. Resolves { url, kind, stop }
207
209
  * once the tunnel URL is captured, or null if it can't come up. stop() kills
208
210
  * both the server and the tunnel.
211
+ *
212
+ * The tunnel target is the port the server ACTUALLY bound (read from its
213
+ * output), not the guessed one — vite/vinext/next hop to the next free port when
214
+ * theirs is taken, and tunneling to the guess then 502s. We fall back to the
215
+ * configured port only if the server never announces one.
209
216
  */
210
217
  export async function startPreview({ worktree, kind, cmd, port, log, timeoutMs = 180_000 }) {
211
218
  const cf = await ensureCloudflared(log);
@@ -219,11 +226,8 @@ export async function startPreview({ worktree, kind, cmd, port, log, timeoutMs =
219
226
  const env = { ...process.env, VINEXT_NO_DEV_LOCK: '1', BROWSER: 'none' };
220
227
  // detached so each gets its own process group — `bun run dev` via a shell
221
228
  // spawns a grandchild dev server that would otherwise SURVIVE a kill of the
222
- // shell, keep port bound, and get silently re-fronted by the NEXT task's
223
- // tunnel (reviewer sees the wrong branch). We kill the whole group instead.
224
- // stdout/stderr are piped (not ignored) so we can show WHY a preview didn't
225
- // come up — a swallowed "another dev server is already running" was a real
226
- // dead-end.
229
+ // shell. We kill the whole group instead. stdout/stderr piped so we can read
230
+ // the bound port and surface failures.
227
231
  const server = spawn(cmd, {
228
232
  cwd: worktree,
229
233
  shell: true,
@@ -231,21 +235,14 @@ export async function startPreview({ worktree, kind, cmd, port, log, timeoutMs =
231
235
  stdio: ['ignore', 'pipe', 'pipe'],
232
236
  env,
233
237
  });
234
- // Ring buffer of the dev server's recent output, surfaced on failure.
238
+
239
+ let settled = false;
240
+ let tunnel = null;
241
+ let tunnelStarted = false;
235
242
  let out = '';
236
- const capture = (d) => {
237
- out = (out + d.toString()).slice(-4000);
238
- };
239
- server.stdout.on('data', capture);
240
- server.stderr.on('data', capture);
241
243
  const tail = () => out.trim().split('\n').slice(-15).join('\n');
242
- const tunnel = spawn(cf, ['tunnel', '--url', `http://localhost:${port}`], {
243
- detached: true,
244
- stdio: ['ignore', 'pipe', 'pipe'],
245
- });
246
- let settled = false;
247
244
  const killGroup = (child) => {
248
- if (!child.pid) return;
245
+ if (!child?.pid) return;
249
246
  try {
250
247
  process.kill(-child.pid, 'SIGKILL'); // negative pid = the whole group
251
248
  } catch {
@@ -260,24 +257,49 @@ export async function startPreview({ worktree, kind, cmd, port, log, timeoutMs =
260
257
  killGroup(server);
261
258
  killGroup(tunnel);
262
259
  };
260
+ let bindTimer;
261
+ let timer;
263
262
  const finish = (val) => {
264
263
  if (settled) return;
265
264
  settled = true;
266
265
  clearTimeout(timer);
266
+ clearTimeout(bindTimer);
267
267
  if (!val) stop();
268
268
  resolve(val);
269
269
  };
270
- const onData = (d) => {
271
- const m = TUNNEL_RE.exec(d.toString());
272
- if (m) finish({ url: m[0], kind, stop });
270
+
271
+ // Open the tunnel once we know the real port (detected or fallback).
272
+ const openTunnel = (p) => {
273
+ if (tunnelStarted || settled) return;
274
+ tunnelStarted = true;
275
+ clearTimeout(bindTimer);
276
+ log?.(`preview: dev server on :${p} — opening the tunnel…`);
277
+ tunnel = spawn(cf, ['tunnel', '--url', `http://localhost:${p}`], {
278
+ detached: true,
279
+ stdio: ['ignore', 'pipe', 'pipe'],
280
+ });
281
+ const onTunnel = (d) => {
282
+ const m = TUNNEL_RE.exec(d.toString());
283
+ if (m) finish({ url: m[0], kind, stop });
284
+ };
285
+ tunnel.stdout.on('data', onTunnel);
286
+ tunnel.stderr.on('data', onTunnel);
287
+ tunnel.on('error', () => finish(null));
288
+ tunnel.on('close', () => finish(null));
289
+ };
290
+
291
+ const onServer = (d) => {
292
+ const s = d.toString();
293
+ out = (out + s).slice(-4000);
294
+ if (!tunnelStarted) {
295
+ const m = BIND_RE.exec(s);
296
+ if (m) openTunnel(Number(m[1]));
297
+ }
273
298
  };
274
- tunnel.stdout.on('data', onData);
275
- tunnel.stderr.on('data', onData);
276
- tunnel.on('error', () => finish(null));
277
- tunnel.on('close', () => finish(null));
278
- // The dev server exiting BEFORE the tunnel came up is the loud failure mode
279
- // (crash on boot, or a singleton lock refusing to start) — surface its
280
- // output instead of a silent timeout.
299
+ server.stdout.on('data', onServer);
300
+ server.stderr.on('data', onServer);
301
+ // A dev server that exits before it's reachable (crash on boot, a singleton
302
+ // lock refusing to start) is the loud failure mode — surface its output.
281
303
  server.on('exit', (code) => {
282
304
  if (settled) return;
283
305
  log?.(
@@ -287,7 +309,11 @@ export async function startPreview({ worktree, kind, cmd, port, log, timeoutMs =
287
309
  );
288
310
  finish(null);
289
311
  });
290
- const timer = setTimeout(() => {
312
+
313
+ // If the server never prints a URL we recognize (quiet server), tunnel to the
314
+ // configured port as a last resort.
315
+ bindTimer = setTimeout(() => openTunnel(port), 30_000);
316
+ timer = setTimeout(() => {
291
317
  log?.(
292
318
  `preview tunnel did not come up in ${Math.round(timeoutMs / 1000)}s — skipping.${
293
319
  tail() ? `\n last dev-server output:\n${tail()}` : ''
@@ -110,8 +110,28 @@ export function handleVersionSignal({ latest, min, autoUpdate, safeToUpdate, tea
110
110
  }
111
111
  return false;
112
112
  }
113
+ // Loop guard: the server can announce a version before it's published. npm is
114
+ // the source of truth — only install if npm ACTUALLY has something newer than
115
+ // us, else `npm i -g @latest` reinstalls our own version and we'd re-exec
116
+ // forever.
117
+ let published = null;
113
118
  try {
114
- note(`flowviant ${cur} → ${target}: self-updating…`);
119
+ published = execFileSync('npm', ['view', 'flowviant', 'version'], {
120
+ encoding: 'utf8',
121
+ stdio: ['ignore', 'pipe', 'ignore'],
122
+ }).trim();
123
+ } catch {
124
+ /* offline / npm hiccup — treat as "can't confirm", skip this poll */
125
+ }
126
+ if (!published || cmpVersion(published, cur) <= 0) {
127
+ if (naggedFor !== target) {
128
+ naggedFor = target;
129
+ note(`update ${target} announced but npm still serves ${published ?? '?'} — waiting for the publish.`);
130
+ }
131
+ return false;
132
+ }
133
+ try {
134
+ note(`flowviant ${cur} → ${published}: self-updating…`);
115
135
  installLatest();
116
136
  ok('updated — restarting into the new version.');
117
137
  reexec(teardown);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Run your own Claude Code as headless build agents for Flowviant — on your own credentials. Claims dispatched work, opens PRs, captures review evidence, and routes questions back to you.",
5
5
  "type": "module",
6
6
  "bin": {