flowviant 0.14.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 +3 -0
- package/bin/lib/config.mjs +1 -1
- package/bin/lib/fleet.mjs +6 -0
- package/bin/lib/live.mjs +59 -4
- package/bin/lib/preview.mjs +115 -16
- package/package.json +1 -1
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.');
|
package/bin/lib/config.mjs
CHANGED
|
@@ -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.
|
|
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,18 +700,30 @@ 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,
|
|
663
709
|
kind,
|
|
664
710
|
cmd: entry.cmd,
|
|
665
711
|
port: entry.port,
|
|
712
|
+
env: entry.env, // optional: extra env from .flowviant/preview.json
|
|
713
|
+
hostHeader: entry.hostHeader, // optional: override/disable the Host rewrite
|
|
666
714
|
log: (m) => info(`${label} ${c.dim(m)}`),
|
|
667
715
|
});
|
|
668
716
|
if (preview) {
|
|
669
717
|
onPreview?.(stopPreview); // hand the daemon a stop handle for shutdown
|
|
670
718
|
await registerLiveTarget(intentId, kind, preview.url);
|
|
671
|
-
previewTarget = { intentId, kind }; //
|
|
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?.();
|
|
672
727
|
ok(`${label} ${c.dim('live preview ready — open the node to drive it in your review')}`);
|
|
673
728
|
}
|
|
674
729
|
};
|
package/bin/lib/preview.mjs
CHANGED
|
@@ -11,9 +11,17 @@
|
|
|
11
11
|
* cloudflared / no inferable config → no live preview, and review falls back to
|
|
12
12
|
* the captured evidence the agent attached (never a hard failure).
|
|
13
13
|
*
|
|
14
|
-
* Config shape
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* Config shape (the escape hatch for any setup the defaults don't handle — the
|
|
15
|
+
* repo declares its own recipe, so the daemon never needs per-framework code):
|
|
16
|
+
* { "ui": {
|
|
17
|
+
* "cmd": "<start dev server>", // required
|
|
18
|
+
* "port": 5173, // required
|
|
19
|
+
* "env": { "FOO": "bar" }, // optional — extra env for the dev server
|
|
20
|
+
* "hostHeader": "localhost" // optional — Host sent to the origin;
|
|
21
|
+
* // false disables the rewrite (for apps
|
|
22
|
+
* // that need their real public Host)
|
|
23
|
+
* },
|
|
24
|
+
* "api": { "cmd": "<start api>", "port": 8787 } }
|
|
17
25
|
*/
|
|
18
26
|
|
|
19
27
|
import { spawn, execFileSync } from 'node:child_process';
|
|
@@ -204,6 +212,74 @@ const TUNNEL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
|
|
|
204
212
|
// Where a dev server announces it bound — "Local: http://localhost:3001/".
|
|
205
213
|
const BIND_RE = /https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0):(\d+)/i;
|
|
206
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
|
+
|
|
207
283
|
/**
|
|
208
284
|
* Start the dev server + tunnel for one worktree. Resolves { url, kind, stop }
|
|
209
285
|
* once the tunnel URL is captured, or null if it can't come up. stop() kills
|
|
@@ -214,16 +290,37 @@ const BIND_RE = /https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0):(\d+)/i;
|
|
|
214
290
|
* theirs is taken, and tunneling to the guess then 502s. We fall back to the
|
|
215
291
|
* configured port only if the server never announces one.
|
|
216
292
|
*/
|
|
217
|
-
export async function startPreview({
|
|
293
|
+
export async function startPreview({
|
|
294
|
+
worktree,
|
|
295
|
+
kind,
|
|
296
|
+
cmd,
|
|
297
|
+
port,
|
|
298
|
+
env: extraEnv,
|
|
299
|
+
hostHeader,
|
|
300
|
+
log,
|
|
301
|
+
timeoutMs = 180_000,
|
|
302
|
+
}) {
|
|
218
303
|
const cf = await ensureCloudflared(log);
|
|
219
304
|
if (!cf) return null; // fall back to captured evidence
|
|
305
|
+
// Host the origin sees. Default 'localhost' (what a local browser sends) so
|
|
306
|
+
// dev servers that validate Host — Vite server.allowedHosts, webpack, Next's
|
|
307
|
+
// allowedDevOrigins — accept the tunnel. `hostHeader: false` in preview.json
|
|
308
|
+
// disables the rewrite for apps that route on their real public Host.
|
|
309
|
+
const hostRewrite =
|
|
310
|
+
hostHeader === false ? null : typeof hostHeader === 'string' && hostHeader ? hostHeader : 'localhost';
|
|
220
311
|
return new Promise((resolve) => {
|
|
221
312
|
// We SIGKILL the dev server's whole group on teardown, which skips a tool's
|
|
222
313
|
// graceful cleanup — some dev servers (e.g. vinext) leave a singleton
|
|
223
314
|
// dev-lock behind and then REFUSE to start next time. Disable known locks so
|
|
224
315
|
// a reused/uncleaned worktree still previews. Harmless to tools that ignore
|
|
225
|
-
// these vars; BROWSER=none stops any auto-open.
|
|
226
|
-
|
|
316
|
+
// these vars; BROWSER=none stops any auto-open. A repo's preview.json `env`
|
|
317
|
+
// is layered last, so it can override any of these.
|
|
318
|
+
const env = {
|
|
319
|
+
...process.env,
|
|
320
|
+
VINEXT_NO_DEV_LOCK: '1',
|
|
321
|
+
BROWSER: 'none',
|
|
322
|
+
...(extraEnv && typeof extraEnv === 'object' ? extraEnv : {}),
|
|
323
|
+
};
|
|
227
324
|
// detached so each gets its own process group — `bun run dev` via a shell
|
|
228
325
|
// spawns a grandchild dev server that would otherwise SURVIVE a kill of the
|
|
229
326
|
// shell. We kill the whole group instead. stdout/stderr piped so we can read
|
|
@@ -235,6 +332,9 @@ export async function startPreview({ worktree, kind, cmd, port, log, timeoutMs =
|
|
|
235
332
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
236
333
|
env,
|
|
237
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);
|
|
238
338
|
|
|
239
339
|
let settled = false;
|
|
240
340
|
let tunnel = null;
|
|
@@ -254,6 +354,8 @@ export async function startPreview({ worktree, kind, cmd, port, log, timeoutMs =
|
|
|
254
354
|
}
|
|
255
355
|
};
|
|
256
356
|
const stop = () => {
|
|
357
|
+
forgetPreviewPid(server.pid);
|
|
358
|
+
forgetPreviewPid(tunnel?.pid);
|
|
257
359
|
killGroup(server);
|
|
258
360
|
killGroup(tunnel);
|
|
259
361
|
};
|
|
@@ -274,16 +376,13 @@ export async function startPreview({ worktree, kind, cmd, port, log, timeoutMs =
|
|
|
274
376
|
tunnelStarted = true;
|
|
275
377
|
clearTimeout(bindTimer);
|
|
276
378
|
log?.(`preview: dev server on :${p} — opening the tunnel…`);
|
|
277
|
-
// --http-host-header
|
|
278
|
-
//
|
|
279
|
-
//
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
tunnel = spawn(
|
|
283
|
-
|
|
284
|
-
['tunnel', '--url', `http://localhost:${p}`, '--http-host-header', 'localhost'],
|
|
285
|
-
{ detached: true, stdio: ['ignore', 'pipe', 'pipe'] },
|
|
286
|
-
);
|
|
379
|
+
// --http-host-header: send the origin the Host it expects (default
|
|
380
|
+
// localhost — see hostRewrite above). Passes Vite/webpack/Next host checks
|
|
381
|
+
// with zero repo config; skipped when preview.json sets hostHeader:false.
|
|
382
|
+
const args = ['tunnel', '--url', `http://localhost:${p}`];
|
|
383
|
+
if (hostRewrite) args.push('--http-host-header', hostRewrite);
|
|
384
|
+
tunnel = spawn(cf, args, { detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
385
|
+
recordPreviewPid(tunnel.pid, 'cloudflared'); // signature for orphan reaping
|
|
287
386
|
const onTunnel = (d) => {
|
|
288
387
|
const m = TUNNEL_RE.exec(d.toString());
|
|
289
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.
|
|
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": {
|