flowviant 0.15.0 → 0.17.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/cli.mjs CHANGED
@@ -80,6 +80,9 @@ if (process.argv[2] === 'clean') {
80
80
  const { join } = await import('node:path');
81
81
  const { homedir } = await import('node:os');
82
82
  const { execFileSync } = await import('node:child_process');
83
+ // Also reap any preview dev-server/tunnel groups a crashed daemon left running.
84
+ const { reapOrphanPreviews } = await import('./lib/preview.mjs');
85
+ reapOrphanPreviews((m) => console.log(m));
83
86
  const dir = join(homedir(), '.flowviant', 'worktrees');
84
87
  if (!existsSync(dir)) {
85
88
  console.log('nothing to clean — no worktrees at ~/.flowviant/worktrees.');
@@ -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.15.0';
7
+ export const VERSION = '0.17.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/fleet.mjs CHANGED
@@ -47,6 +47,7 @@ import {
47
47
  SINGLE_RESUME,
48
48
  } from './claude.mjs';
49
49
  import { runLiveWorker } from './live.mjs';
50
+ import { reapOrphanPreviews } from './preview.mjs';
50
51
  import { preflight } from './preflight.mjs';
51
52
 
52
53
  async function fetchRoster(haveIds) {
@@ -176,6 +177,9 @@ export async function runFleetDaemon() {
176
177
  info(`server · ${FLEET_URL}`);
177
178
  console.log('');
178
179
  await preflight({ needGit: true });
180
+ // Kill any preview dev-server/tunnel groups a previously-crashed daemon left
181
+ // running (detached children survive an ungraceful exit) before we start fresh.
182
+ reapOrphanPreviews((m) => info(m));
179
183
 
180
184
  // Persistent worktree home (0.9.0) — survives daemon restarts AND reboots,
181
185
  // so Ctrl+C mid-task never loses local work. Keyed per repo path; each
@@ -481,6 +485,8 @@ export async function runFleetDaemon() {
481
485
  label,
482
486
  cwd: wt,
483
487
  baseRef,
488
+ repoRoot, // for copying the repo's local env into the preview worktree
489
+
484
490
  getToken: (id) => tokenByAgent.get(id),
485
491
  getHasWork: (id) => hasWorkByAgent.get(id) ?? false,
486
492
  getMcpUrl: () => mcpUrl,
package/bin/lib/live.mjs CHANGED
@@ -16,7 +16,7 @@
16
16
  * live fleet + repo to shake out. Old (poll/sentinel) mode is untouched.
17
17
  */
18
18
 
19
- import { readFileSync, writeFileSync, rmSync } from 'node:fs';
19
+ import { readFileSync, writeFileSync, rmSync, existsSync, copyFileSync } from 'node:fs';
20
20
  import { join } from 'node:path';
21
21
  import { query } from '@anthropic-ai/claude-agent-sdk';
22
22
  import {
@@ -37,6 +37,13 @@ import { loadPreviewConfig, startPreview } from './preview.mjs';
37
37
  // Register a branch preview's tunnel URL with Flowviant (fleet-authed). The
38
38
  // reviewer then drives it via "Open live preview" in the node.
39
39
  const LIVE_TARGET_URL = FLEET_URL.replace(/\/agents\/?$/, '/live-target');
40
+ // Short TTL + a heartbeat that re-asserts while the tunnel is alive. So a live
41
+ // preview stays linked indefinitely (survives long reviews), but one whose
42
+ // daemon DIED ungracefully (no more heartbeats) drops off the card within the
43
+ // TTL instead of showing a dead URL for 2 hours. TTL comfortably covers a few
44
+ // missed heartbeats.
45
+ const PREVIEW_TTL_MINUTES = 6;
46
+ const PREVIEW_HEARTBEAT_MS = 90_000;
40
47
  async function registerLiveTarget(intentId, kind, url) {
41
48
  try {
42
49
  await fetch(LIVE_TARGET_URL, {
@@ -47,7 +54,7 @@ async function registerLiveTarget(intentId, kind, url) {
47
54
  'Content-Type': 'application/json',
48
55
  },
49
56
  signal: AbortSignal.timeout(30_000),
50
- body: JSON.stringify({ intentId, kind, url }),
57
+ body: JSON.stringify({ intentId, kind, url, ttlMinutes: PREVIEW_TTL_MINUTES }),
51
58
  });
52
59
  } catch {
53
60
  /* best-effort — the tunnel still works; it just isn't linked in the app */
@@ -586,11 +593,39 @@ export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive, resume
586
593
 
587
594
  // Per-agent loop — same signature/scaffolding as runFleetWorker, but each task
588
595
  // is a persistent SDK session instead of a one-shot claude turn.
596
+ // A preview runs in the agent's WORKTREE — a fresh checkout that lacks the repo's
597
+ // gitignored env files (.env.local etc.), so the app's DB/auth secrets are absent
598
+ // and anything that hits them (sign-in!) 500s. Copy the files the checkout is
599
+ // missing from the real repo into the worktree so the preview runs like local
600
+ // dev. We only copy files ABSENT from the worktree — i.e. the gitignored ones —
601
+ // so nothing tracked is overwritten and (being gitignored) nothing gets committed.
602
+ const PREVIEW_ENV_FILES = ['.env', '.env.local', '.env.development', '.env.development.local'];
603
+ function copyLocalEnvFiles(repoRoot, worktree, log) {
604
+ if (!repoRoot || repoRoot === worktree) return;
605
+ let copied = 0;
606
+ for (const f of PREVIEW_ENV_FILES) {
607
+ const src = join(repoRoot, f);
608
+ const dst = join(worktree, f);
609
+ if (existsSync(src) && !existsSync(dst)) {
610
+ try {
611
+ copyFileSync(src, dst);
612
+ copied++;
613
+ } catch {
614
+ /* best-effort */
615
+ }
616
+ }
617
+ }
618
+ if (copied) {
619
+ log?.(`preview: brought ${copied} local env file(s) into the worktree so the app has its secrets.`);
620
+ }
621
+ }
622
+
589
623
  export async function runLiveWorker({
590
624
  agentId,
591
625
  label,
592
626
  cwd,
593
627
  baseRef,
628
+ repoRoot,
594
629
  getToken,
595
630
  getHasWork,
596
631
  getMcpUrl,
@@ -615,8 +650,16 @@ export async function runLiveWorker({
615
650
  // kept up while it's in review (a gated agent parks, so it lives until review
616
651
  // resolves). Replaced when the next task finishes; torn down on shutdown.
617
652
  let preview = null;
618
- let previewTarget = null; // { intentId, kind } of the currently-registered link
653
+ let previewTarget = null; // { intentId, kind, url } of the currently-registered link
654
+ let previewHeartbeat = null;
655
+ const stopHeartbeat = () => {
656
+ if (previewHeartbeat) {
657
+ clearInterval(previewHeartbeat);
658
+ previewHeartbeat = null;
659
+ }
660
+ };
619
661
  const stopPreview = () => {
662
+ stopHeartbeat();
620
663
  if (preview) {
621
664
  try {
622
665
  preview.stop();
@@ -657,6 +700,9 @@ export async function runLiveWorker({
657
700
  if (cfg.dir && cfg.dir !== '.') {
658
701
  info(`${label} ${c.dim(`live preview: detected a frontend at ${cfg.dir}/ (port ${entry.port})`)}`);
659
702
  }
703
+ // Give the dev server the repo's local env (gitignored secrets the fresh
704
+ // worktree is missing) so DB/auth-backed paths like sign-in don't 500.
705
+ copyLocalEnvFiles(repoRoot, cwd, (m) => info(`${label} ${c.dim(m)}`));
660
706
  info(`${label} ${c.dim('starting a live preview of the branch for review…')}`);
661
707
  preview = await startPreview({
662
708
  worktree: cwd,
@@ -670,7 +716,14 @@ export async function runLiveWorker({
670
716
  if (preview) {
671
717
  onPreview?.(stopPreview); // hand the daemon a stop handle for shutdown
672
718
  await registerLiveTarget(intentId, kind, preview.url);
673
- previewTarget = { intentId, kind }; // so teardown can drop the link
719
+ previewTarget = { intentId, kind, url: preview.url }; // teardown drops it; heartbeat re-asserts it
720
+ // Re-assert the link while the tunnel is alive so it survives long reviews
721
+ // (and a dead daemon stops re-asserting → the record expires by itself).
722
+ stopHeartbeat();
723
+ previewHeartbeat = setInterval(() => {
724
+ if (previewTarget) void registerLiveTarget(previewTarget.intentId, previewTarget.kind, previewTarget.url);
725
+ }, PREVIEW_HEARTBEAT_MS);
726
+ previewHeartbeat.unref?.();
674
727
  ok(`${label} ${c.dim('live preview ready — open the node to drive it in your review')}`);
675
728
  }
676
729
  };
@@ -212,6 +212,74 @@ const TUNNEL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
212
212
  // Where a dev server announces it bound — "Local: http://localhost:3001/".
213
213
  const BIND_RE = /https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0):(\d+)/i;
214
214
 
215
+ // ── Orphan reaping ─────────────────────────────────────────────────────────
216
+ // Preview children (dev server + tunnel) are detached so we can kill the whole
217
+ // group — but that also means they SURVIVE an ungraceful daemon death (SIGKILL,
218
+ // crash, box sleep), leaking ports/memory. We record each spawned group's pid +
219
+ // a signature; on the next daemon start we reap any that are still ours.
220
+ const PREVIEW_REGISTRY = join(homedir(), '.flowviant', 'previews.json');
221
+ function readRegistry() {
222
+ try {
223
+ const v = JSON.parse(readFileSync(PREVIEW_REGISTRY, 'utf8'));
224
+ return Array.isArray(v) ? v : [];
225
+ } catch {
226
+ return [];
227
+ }
228
+ }
229
+ function writeRegistry(list) {
230
+ try {
231
+ mkdirSync(join(homedir(), '.flowviant'), { recursive: true });
232
+ writeFileSync(PREVIEW_REGISTRY, JSON.stringify(list));
233
+ } catch {
234
+ /* best-effort */
235
+ }
236
+ }
237
+ function recordPreviewPid(pid, sig) {
238
+ if (!pid) return;
239
+ writeRegistry([...readRegistry(), { pid, sig }]);
240
+ }
241
+ function forgetPreviewPid(pid) {
242
+ if (!pid) return;
243
+ writeRegistry(readRegistry().filter((e) => e.pid !== pid));
244
+ }
245
+ // Only kill a pid we can VERIFY is still one of ours — its /proc cmdline must
246
+ // still contain the signature we stored. A reused pid (belonging to something
247
+ // unrelated) won't match, so we never kill a stranger. Linux-only (that's where
248
+ // /proc + process groups work); elsewhere we just clear the registry.
249
+ function stillOurs(pid, sig) {
250
+ if (platform() !== 'linux') return false;
251
+ try {
252
+ const cmd = readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ');
253
+ return typeof sig === 'string' && sig.length > 0 && cmd.includes(sig);
254
+ } catch {
255
+ return false; // process gone / unreadable
256
+ }
257
+ }
258
+
259
+ /** Reap preview process groups left behind by a previously-crashed daemon.
260
+ * Call once at daemon startup, before spawning workers. */
261
+ export function reapOrphanPreviews(log) {
262
+ const list = readRegistry();
263
+ if (list.length === 0) return;
264
+ let killed = 0;
265
+ for (const { pid, sig } of list) {
266
+ if (!stillOurs(pid, sig)) continue;
267
+ try {
268
+ process.kill(-pid, 'SIGKILL'); // whole group
269
+ killed++;
270
+ } catch {
271
+ try {
272
+ process.kill(pid, 'SIGKILL');
273
+ killed++;
274
+ } catch {
275
+ /* already gone */
276
+ }
277
+ }
278
+ }
279
+ writeRegistry([]);
280
+ if (killed) log?.(`reaped ${killed} orphaned preview process${killed === 1 ? '' : 'es'} from a previous run.`);
281
+ }
282
+
215
283
  /**
216
284
  * Start the dev server + tunnel for one worktree. Resolves { url, kind, stop }
217
285
  * once the tunnel URL is captured, or null if it can't come up. stop() kills
@@ -264,6 +332,9 @@ export async function startPreview({
264
332
  stdio: ['ignore', 'pipe', 'pipe'],
265
333
  env,
266
334
  });
335
+ // Track for orphan reaping: the shell's cmdline stays `sh -c <cmd>`, so `cmd`
336
+ // is a safe signature to re-verify against later.
337
+ recordPreviewPid(server.pid, cmd);
267
338
 
268
339
  let settled = false;
269
340
  let tunnel = null;
@@ -283,6 +354,8 @@ export async function startPreview({
283
354
  }
284
355
  };
285
356
  const stop = () => {
357
+ forgetPreviewPid(server.pid);
358
+ forgetPreviewPid(tunnel?.pid);
286
359
  killGroup(server);
287
360
  killGroup(tunnel);
288
361
  };
@@ -309,6 +382,7 @@ export async function startPreview({
309
382
  const args = ['tunnel', '--url', `http://localhost:${p}`];
310
383
  if (hostRewrite) args.push('--http-host-header', hostRewrite);
311
384
  tunnel = spawn(cf, args, { detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
385
+ recordPreviewPid(tunnel.pid, 'cloudflared'); // signature for orphan reaping
312
386
  const onTunnel = (d) => {
313
387
  const m = TUNNEL_RE.exec(d.toString());
314
388
  if (m) finish({ url: m[0], kind, stop });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.15.0",
3
+ "version": "0.17.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": {