flowviant 0.71.0 → 0.72.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/README.md +1 -1
- package/bin/lib/authproxy.mjs +154 -6
- package/bin/lib/fleet.mjs +18 -0
- package/bin/lib/listeners.mjs +34 -1
- package/bin/lib/preview.mjs +5 -1
- package/bin/lib/work.mjs +123 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -51,7 +51,7 @@ Nothing starts work except you opening a tab and typing in it.
|
|
|
51
51
|
|
|
52
52
|
## Sharing a preview
|
|
53
53
|
|
|
54
|
-
You run your dev server yourself, in the session's own worktree, exactly as you would in any terminal. The daemon NOTICES the listening port; ask for a share in the app and it puts a [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) quick tunnel in front of it (auto-fetched if missing, pinned and checksummed) behind a **mandatory password gate**. Flowviant stores only the tunnel URL; your browser talks to it directly.
|
|
54
|
+
You run your dev server yourself, in the session's own worktree, exactly as you would in any terminal. The daemon NOTICES the listening port; ask for a share in the app and it puts a [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) quick tunnel in front of it (auto-fetched if missing, pinned and checksummed) behind a **mandatory password gate**. Flowviant stores only the tunnel URL; your browser talks to it directly. Since 0.72.0 the gate also lets the Workbench embed the share in a frame: it rewrites the response's frame policy to permit exactly the app's origin, and a framed sign-in gets a partitioned cookie so it works where third-party cookies are blocked.
|
|
55
55
|
|
|
56
56
|
The daemon never executes anything the repository declares. An earlier version read a `.flowviant/preview.json` from the branch and spawned the command it named — that start path was removed in 0.53.0 and is not coming back; see the header of `bin/lib/preview.mjs` for exactly what it did, so nobody rebuilds it.
|
|
57
57
|
|
package/bin/lib/authproxy.mjs
CHANGED
|
@@ -32,10 +32,45 @@
|
|
|
32
32
|
* - NEVER 302 A WEBSOCKET UPGRADE. Browsers do not follow 3xx on an upgrade,
|
|
33
33
|
* they fail the connection — HMR would break in a way that looks like a dead
|
|
34
34
|
* dev server. A cookie-less upgrade stays a 401.
|
|
35
|
-
* -
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
35
|
+
* - THE FRAME IS THE APP'S AND ONLY THE APP'S (0.72.0, and this reverses the
|
|
36
|
+
* old "cannot be embedded, the Workbench must not try" rule deliberately —
|
|
37
|
+
* the Workbench now DOES frame the share, so both halves are re-argued
|
|
38
|
+
* here). Two changes, one per blocker:
|
|
39
|
+
* (a) THE COOKIE. `SameSite=Lax` is kept for a top-level visit — it is the
|
|
40
|
+
* CSRF boundary and works in every browser ever shipped. A FRAMED
|
|
41
|
+
* navigation (`Sec-Fetch-Dest: iframe`) instead gets `SameSite=None;
|
|
42
|
+
* Partitioned` (CHIPS): partitioning keys the cookie to the top-level
|
|
43
|
+
* site that framed it, so evil.com framing this hostname gets its OWN
|
|
44
|
+
* empty jar, never the viewer's grant — which is why None here is not
|
|
45
|
+
* the CSRF hole it would be unpartitioned. Branching on Sec-Fetch-Dest
|
|
46
|
+
* rather than always sending both attributes matters: Safari 18.5–26.1
|
|
47
|
+
* DROPPED any Set-Cookie carrying `Partitioned` outright, so stamping it
|
|
48
|
+
* unconditionally would have broken the working top-level path on those
|
|
49
|
+
* builds. A browser too old to send Sec-Fetch-Dest gets Lax and the
|
|
50
|
+
* frame fails exactly as it always did — Open remains the escape.
|
|
51
|
+
* AND THE PARTITIONING CLAIM HAS A BACKSTOP, because one browser class
|
|
52
|
+
* breaks it: Chromium 76–113 (and same-vintage webviews/forks) sends
|
|
53
|
+
* Sec-Fetch-Dest — so it takes this branch — while ignoring the
|
|
54
|
+
* `Partitioned` attribute it has never heard of, storing a PLAIN
|
|
55
|
+
* unpartitioned SameSite=None cookie: the exact CSRF hole the sentence
|
|
56
|
+
* above says partitioning prevents. So `crossSiteAbuse` refuses a
|
|
57
|
+
* GRANT-authenticated request that Fetch Metadata marks cross-site
|
|
58
|
+
* unless it is a top-level GET/HEAD navigation (the Open link, the
|
|
59
|
+
* frame's own src). Every browser in the vulnerable class sends
|
|
60
|
+
* Sec-Fetch-Site (it shipped alongside Dest), a browser sending
|
|
61
|
+
* neither never got a None cookie in the first place, and the password
|
|
62
|
+
* path is exempt — Basic auth is never attached cross-site by a
|
|
63
|
+
* browser, so curl and Playwright feel nothing.
|
|
64
|
+
* (b) THE FRAME POLICY. In grant mode the forwarded response's frame policy
|
|
65
|
+
* is REWRITTEN to `frame-ancestors 'self' <app origin>` (origin taken
|
|
66
|
+
* from `authorizeUrl`, the one app fact this gate already holds). That
|
|
67
|
+
* permits exactly ONE cross-origin framer — the app the viewer is
|
|
68
|
+
* already authenticated to — instead of switching clickjacking
|
|
69
|
+
* protection off for everyone; enforced `frame-ancestors` outranks
|
|
70
|
+
* `X-Frame-Options` in every current engine, and the stripped XFO is
|
|
71
|
+
* the belt-and-braces for the rest. Password-only mode rewrites
|
|
72
|
+
* NOTHING: there is no app origin to allow and no cookie that could
|
|
73
|
+
* authenticate a frame, so the origin's own policy stands verbatim.
|
|
39
74
|
*
|
|
40
75
|
* Three things this file gets wrong easily, all of them fixed here and all of
|
|
41
76
|
* them worth keeping fixed:
|
|
@@ -155,6 +190,95 @@ export function startAuthProxy({ targetPort, log, onAbuse, grantSecret, shareId,
|
|
|
155
190
|
|
|
156
191
|
const authed = (req) => credential(req) === 'ok';
|
|
157
192
|
|
|
193
|
+
/**
|
|
194
|
+
* The CSRF backstop for the partitioned cookie (header rule 4a): a browser
|
|
195
|
+
* old enough to ignore `Partitioned` while honouring `SameSite=None` will
|
|
196
|
+
* attach the grant to cross-site requests, so a request that FETCH METADATA
|
|
197
|
+
* says is cross-site is refused unless it is a plain navigation GET/HEAD —
|
|
198
|
+
* the two shapes this product legitimately serves cross-site (the Open
|
|
199
|
+
* link, the Workbench frame's own src; a rendered attacker frame is then
|
|
200
|
+
* stopped by the frame-ancestors rewrite). Applies ONLY to grant-cookie
|
|
201
|
+
* auth: a password in an Authorization header is never attached cross-site
|
|
202
|
+
* by a browser, and clients that send no Sec-Fetch headers never held a
|
|
203
|
+
* None cookie, so absence stays permitted.
|
|
204
|
+
*/
|
|
205
|
+
const crossSiteAbuse = (req) => {
|
|
206
|
+
if (String(req.headers['sec-fetch-site'] || '').toLowerCase() !== 'cross-site') return false;
|
|
207
|
+
const mode = String(req.headers['sec-fetch-mode'] || '').toLowerCase();
|
|
208
|
+
// Cross-site fetch/XHR/img/script with the cookie — nothing legitimate
|
|
209
|
+
// looks like this: the framed app's own subresources are same-origin.
|
|
210
|
+
if (mode && mode !== 'navigate') return true;
|
|
211
|
+
// A cross-site POST navigation is the classic auto-submitted CSRF form.
|
|
212
|
+
return !(req.method === 'GET' || req.method === 'HEAD');
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
/** True when THIS request authenticated with the password rather than the
|
|
216
|
+
* cookie — the credential class CSRF cannot ride. */
|
|
217
|
+
const viaPassword = (req) => sameSecret(req.headers['authorization'], expected);
|
|
218
|
+
|
|
219
|
+
/** The one app fact this gate holds: the origin allowed to frame us. Derived
|
|
220
|
+
* from `authorizeUrl` (server-built from PUBLIC_APP_URL) rather than a new
|
|
221
|
+
* wire field, so an older server changes nothing and no floor is needed. */
|
|
222
|
+
let appOrigin = null;
|
|
223
|
+
try {
|
|
224
|
+
if (grants) appOrigin = new URL(authorizeUrl).origin;
|
|
225
|
+
} catch {
|
|
226
|
+
appOrigin = null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Replace the origin's frame policy with ours: `frame-ancestors 'self'
|
|
231
|
+
* <app origin>`. Rules that must survive any edit:
|
|
232
|
+
* - REWRITE the directive inside an existing enforced CSP, never append a
|
|
233
|
+
* second policy beside it — multiple CSP headers intersect, so an origin
|
|
234
|
+
* `frame-ancestors 'none'` would still win over anything we added.
|
|
235
|
+
* - APPEND a policy holding only our directive when the origin stated no
|
|
236
|
+
* frame-ancestors at all — a public tunnel hostname deserves a frame
|
|
237
|
+
* policy even when localhost never needed one.
|
|
238
|
+
* - LEAVE Report-Only alone: browsers ignore frame-ancestors there, and
|
|
239
|
+
* rewriting a report channel would be editing the driver's telemetry.
|
|
240
|
+
* - DELETE X-Frame-Options: an enforced frame-ancestors makes every current
|
|
241
|
+
* engine ignore it anyway; deleting is for the stragglers.
|
|
242
|
+
*/
|
|
243
|
+
const framePolicy = () => `frame-ancestors 'self' ${appOrigin}`;
|
|
244
|
+
const rewriteFramePolicy = (headers) => {
|
|
245
|
+
if (!grants || !appOrigin) return headers;
|
|
246
|
+
const out = { ...headers };
|
|
247
|
+
delete out['x-frame-options'];
|
|
248
|
+
let sawDirective = false;
|
|
249
|
+
// Node joins duplicate response headers with ', ', so one string can hold
|
|
250
|
+
// SEVERAL policies (comma-separated), each holding several directives
|
|
251
|
+
// (semicolon-separated). Split on BOTH levels or replacing a directive
|
|
252
|
+
// eats the tail of a neighbouring policy — CSP source lists never contain
|
|
253
|
+
// a comma, so the outer split is safe.
|
|
254
|
+
const rewriteOne = (v) =>
|
|
255
|
+
String(v)
|
|
256
|
+
.split(',')
|
|
257
|
+
.map((policy) =>
|
|
258
|
+
policy
|
|
259
|
+
.split(';')
|
|
260
|
+
.map((part) => {
|
|
261
|
+
if (/^\s*frame-ancestors(\s|$)/i.test(part)) {
|
|
262
|
+
sawDirective = true;
|
|
263
|
+
return ` ${framePolicy()}`;
|
|
264
|
+
}
|
|
265
|
+
return part;
|
|
266
|
+
})
|
|
267
|
+
.join(';')
|
|
268
|
+
)
|
|
269
|
+
.join(',');
|
|
270
|
+
const csp = out['content-security-policy'];
|
|
271
|
+
if (csp !== undefined) {
|
|
272
|
+
out['content-security-policy'] = Array.isArray(csp) ? csp.map(rewriteOne) : rewriteOne(csp);
|
|
273
|
+
}
|
|
274
|
+
if (!sawDirective) {
|
|
275
|
+
const existing = out['content-security-policy'];
|
|
276
|
+
if (existing === undefined) out['content-security-policy'] = framePolicy();
|
|
277
|
+
else out['content-security-policy'] = Array.isArray(existing) ? [...existing, framePolicy()] : [existing, framePolicy()];
|
|
278
|
+
}
|
|
279
|
+
return out;
|
|
280
|
+
};
|
|
281
|
+
|
|
158
282
|
// The gate credential is OURS and stops here. Everything else is passed
|
|
159
283
|
// through untouched: the origin is the driver's own dev server and rewriting
|
|
160
284
|
// its request would be us editing their app's input.
|
|
@@ -234,12 +358,18 @@ export function startAuthProxy({ targetPort, log, onAbuse, grantSecret, shareId,
|
|
|
234
358
|
if (!r.ok) return challenge(res);
|
|
235
359
|
const to = safeRelative(u.searchParams.get('to'));
|
|
236
360
|
const maxAge = Math.max(0, r.payload.exp - Math.floor(Date.now() / 1000));
|
|
361
|
+
// FRAMED means Partitioned (header rule 4a). Sec-Fetch-Dest survives the
|
|
362
|
+
// whole redirect chain (it describes the navigation, not the hop), so the
|
|
363
|
+
// callback sees `iframe` exactly when the Workbench is the one asking.
|
|
364
|
+
const dest = String(req.headers['sec-fetch-dest'] || '').toLowerCase();
|
|
365
|
+
const framed = dest === 'iframe' || dest === 'frame' || dest === 'embed' || dest === 'object';
|
|
366
|
+
const site = framed ? 'SameSite=None; Partitioned' : 'SameSite=Lax';
|
|
237
367
|
// The immediate 302 to a clean path is MANDATORY, not cosmetic: it takes
|
|
238
368
|
// `?g=` out of the address bar, out of the Referer every subresource would
|
|
239
369
|
// carry, and out of browser history. It cannot take it out of cloudflared's
|
|
240
370
|
// access log — which is why the grant is short-lived and share-bound.
|
|
241
371
|
res.writeHead(302, {
|
|
242
|
-
'Set-Cookie': `${GRANT_COOKIE}=${r.raw}; Path=/; Secure; HttpOnly;
|
|
372
|
+
'Set-Cookie': `${GRANT_COOKIE}=${r.raw}; Path=/; Secure; HttpOnly; ${site}; Max-Age=${maxAge}`,
|
|
243
373
|
Location: to,
|
|
244
374
|
...noStore,
|
|
245
375
|
});
|
|
@@ -262,8 +392,18 @@ export function startAuthProxy({ targetPort, log, onAbuse, grantSecret, shareId,
|
|
|
262
392
|
if (grants && verdict !== 'badpass' && isBrowserNav(req)) return bounce(req, res, verdict);
|
|
263
393
|
return challenge(res);
|
|
264
394
|
}
|
|
395
|
+
// 4. The grant is a cookie, and a cookie can be ridden (header rule 4a's
|
|
396
|
+
// backstop). Never a bounce — the caller HAS a credential; the request
|
|
397
|
+
// SHAPE is what is refused.
|
|
398
|
+
if (grants && crossSiteAbuse(req) && !viaPassword(req)) {
|
|
399
|
+
res.writeHead(403, { 'Content-Type': 'text/plain', ...noStore });
|
|
400
|
+
res.end('cross-site request refused');
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
265
403
|
const proxyReq = request(forwardOpts(req), (proxyRes) => {
|
|
266
|
-
|
|
404
|
+
// The one RESPONSE rewrite this gate performs (header rule 4b): the
|
|
405
|
+
// frame policy. Everything else is the driver's own app talking.
|
|
406
|
+
res.writeHead(proxyRes.statusCode || 502, rewriteFramePolicy(proxyRes.headers));
|
|
267
407
|
proxyRes.pipe(res);
|
|
268
408
|
});
|
|
269
409
|
proxyReq.on('error', () => {
|
|
@@ -296,6 +436,14 @@ export function startAuthProxy({ targetPort, log, onAbuse, grantSecret, shareId,
|
|
|
296
436
|
socket.destroy();
|
|
297
437
|
return;
|
|
298
438
|
}
|
|
439
|
+
// The same cross-site backstop as the request path: a cookie-ridden
|
|
440
|
+
// cross-site handshake (Sec-Fetch-Mode: websocket) is refused; the framed
|
|
441
|
+
// app's own HMR socket is same-origin and never trips it.
|
|
442
|
+
if (grants && crossSiteAbuse(req) && !viaPassword(req)) {
|
|
443
|
+
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
|
444
|
+
socket.destroy();
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
299
447
|
const proxyReq = request(forwardOpts(req));
|
|
300
448
|
proxyReq.on('upgrade', (proxyRes, proxySocket, proxyHead) => {
|
|
301
449
|
const headerLines = Object.entries(proxyRes.headers).map(([k, v]) => `${k}: ${v}`);
|
package/bin/lib/fleet.mjs
CHANGED
|
@@ -831,6 +831,24 @@ export async function runFleetDaemon() {
|
|
|
831
831
|
getBaseRef,
|
|
832
832
|
getMcpUrl: () => mcpUrl,
|
|
833
833
|
getLeaseTtl: () => leaseTtlSeconds,
|
|
834
|
+
/**
|
|
835
|
+
* "THE REPO JUST CHANGED — look again."
|
|
836
|
+
*
|
|
837
|
+
* `maybeReportRepoState` is on its own 60s wall clock and nothing ever
|
|
838
|
+
* reset it, so every Flowviant action that alters the branch/worktree
|
|
839
|
+
* picture — a ship deleting the merged branch, retirement removing a
|
|
840
|
+
* directory and pruning, a tab being cut — left the rail's Repository block
|
|
841
|
+
* listing things that no longer exist for up to a minute. It is the same
|
|
842
|
+
* rule the diffstat just learned: an action that changes what the machine
|
|
843
|
+
* would measure must cause a new measurement.
|
|
844
|
+
*
|
|
845
|
+
* A callback rather than an export because work.mjs is imported BY this
|
|
846
|
+
* file, so it cannot import back. Clearing the timestamp is enough — the
|
|
847
|
+
* next reconcile does the scan, on the beat it already runs.
|
|
848
|
+
*/
|
|
849
|
+
onRepoChanged: () => {
|
|
850
|
+
repoStateScanAt = 0;
|
|
851
|
+
},
|
|
834
852
|
});
|
|
835
853
|
workShutdown = shutdownWork; // teardown can now reach the live session CLIs
|
|
836
854
|
|
package/bin/lib/listeners.mjs
CHANGED
|
@@ -58,6 +58,26 @@ import { platform } from 'node:os';
|
|
|
58
58
|
import { rssBytes } from './processes.mjs';
|
|
59
59
|
import { sep } from 'node:path';
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* INFRASTRUCTURE CHILDREN ARE NOT LISTENERS. cloudflared opens its own local
|
|
63
|
+
* metrics socket, and its cwd is inherited from the daemon — inside the
|
|
64
|
+
* checkout — so a live share put a `cloudflared` row in the repo place's list:
|
|
65
|
+
* Flowviant's own plumbing offered back to the operator as a thing to share or
|
|
66
|
+
* stop. Same rule as the `process.pid` exclusion below: a process that IS
|
|
67
|
+
* Flowviant is not a process Flowviant reports. `preview.mjs` registers each
|
|
68
|
+
* tunnel child here as it spawns it (that import direction already exists;
|
|
69
|
+
* the reverse would be a cycle). In-process is enough: a PEER daemon's tunnel
|
|
70
|
+
* lives in a different checkout, so cwd attribution already excludes it, and
|
|
71
|
+
* a crashed daemon's orphan is reaped at the next startup.
|
|
72
|
+
*/
|
|
73
|
+
const infraPids = new Set();
|
|
74
|
+
export const noteInfraPid = (pid) => {
|
|
75
|
+
if (Number.isInteger(pid) && pid > 0) infraPids.add(pid);
|
|
76
|
+
};
|
|
77
|
+
export const forgetInfraPid = (pid) => {
|
|
78
|
+
infraPids.delete(pid);
|
|
79
|
+
};
|
|
80
|
+
|
|
61
81
|
/** A box with more processes than this is not one we walk per sweep. The scan
|
|
62
82
|
* is one readlink per pid and runs every reconcile; this is the runaway
|
|
63
83
|
* bound, not a capacity statement. */
|
|
@@ -261,6 +281,15 @@ function scanLinux(worktree) {
|
|
|
261
281
|
continue; // not ours, or gone
|
|
262
282
|
}
|
|
263
283
|
if (!inside(cwd)) continue;
|
|
284
|
+
// NEVER THE DAEMON ITSELF. `startAuthProxy` binds a loopback port IN THIS
|
|
285
|
+
// PROCESS to gate a preview, and the daemon's own cwd is inside the
|
|
286
|
+
// checkout — so the gate was attributed to the repo place and surfaced as
|
|
287
|
+
// a listener the operator could click Stop on. Stopping it would take down
|
|
288
|
+
// the door in front of a live share, and it is not a thing anybody
|
|
289
|
+
// started: it is us. Same rule `processes.mjs` keeps by never reporting the
|
|
290
|
+
// group leader, for the same reason — a process that IS Flowviant is not a
|
|
291
|
+
// process Flowviant reports.
|
|
292
|
+
if (Number(pid) === process.pid || infraPids.has(Number(pid))) continue;
|
|
264
293
|
|
|
265
294
|
let fds;
|
|
266
295
|
try {
|
|
@@ -334,8 +363,12 @@ function scanDarwin(worktree) {
|
|
|
334
363
|
} catch {
|
|
335
364
|
/* no memory on this box — rows simply carry no rss */
|
|
336
365
|
}
|
|
366
|
+
const self = String(process.pid);
|
|
337
367
|
for (const line of lsof(['-nP', '-iTCP', '-sTCP:LISTEN', '-F', 'pn']).split('\n')) {
|
|
338
|
-
|
|
368
|
+
// The daemon's own preview gate is not something the driver started — see
|
|
369
|
+
// the linux branch for the whole argument. Nor its cloudflared children.
|
|
370
|
+
if (line.startsWith('p'))
|
|
371
|
+
pid = line.slice(1) === self || infraPids.has(Number(line.slice(1))) ? null : line.slice(1);
|
|
339
372
|
else if (line.startsWith('n') && pid) {
|
|
340
373
|
const m = /:(\d+)$/.exec(line.slice(1));
|
|
341
374
|
if (!m) continue;
|
package/bin/lib/preview.mjs
CHANGED
|
@@ -46,7 +46,7 @@ import {
|
|
|
46
46
|
import { join } from 'node:path';
|
|
47
47
|
import { homedir, platform, arch } from 'node:os';
|
|
48
48
|
import { startAuthProxy } from './authproxy.mjs';
|
|
49
|
-
import { isListening } from './listeners.mjs';
|
|
49
|
+
import { forgetInfraPid, isListening, noteInfraPid } from './listeners.mjs';
|
|
50
50
|
|
|
51
51
|
// ── cloudflared: pinned, verified, or not fetched at all ───────────────────
|
|
52
52
|
|
|
@@ -389,6 +389,7 @@ export async function openTunnel({
|
|
|
389
389
|
}
|
|
390
390
|
}
|
|
391
391
|
forgetPreviewPid(tunnel.pid);
|
|
392
|
+
forgetInfraPid(tunnel.pid);
|
|
392
393
|
}
|
|
393
394
|
};
|
|
394
395
|
|
|
@@ -428,6 +429,9 @@ export async function openTunnel({
|
|
|
428
429
|
// word would let a recycled pid land on an operator's own unrelated
|
|
429
430
|
// cloudflared and group-SIGKILL it.
|
|
430
431
|
recordPreviewPid(tunnel.pid, `--url http://localhost:${gate.port}`);
|
|
432
|
+
// Keep our own plumbing out of the listeners measurement (see listeners.mjs:
|
|
433
|
+
// cloudflared's metrics socket lives in the checkout's cwd).
|
|
434
|
+
noteInfraPid(tunnel.pid);
|
|
431
435
|
|
|
432
436
|
return new Promise((resolve) => {
|
|
433
437
|
let settled = false;
|
package/bin/lib/work.mjs
CHANGED
|
@@ -41,7 +41,7 @@ import {
|
|
|
41
41
|
import { git, gitRaw, splitNul, baseBranchName, isSafePathSegment } from './git.mjs';
|
|
42
42
|
import { listenersIn, measureListeners, listenersSupported } from './listeners.mjs';
|
|
43
43
|
import { measureProcesses, liveGroups, processesSupported } from './processes.mjs';
|
|
44
|
-
import { mutateRegistry, readRegistry } from './procRegistry.mjs';
|
|
44
|
+
import { mutateRegistry, processAlive, readRegistry } from './procRegistry.mjs';
|
|
45
45
|
import { createPlaceLock } from './placeLock.mjs';
|
|
46
46
|
import { sweepMergedBranch } from './shipSweep.mjs';
|
|
47
47
|
import { mergeOutward as shipMergeOutward } from './shipMerge.mjs';
|
|
@@ -107,7 +107,15 @@ function brainFor(job) {
|
|
|
107
107
|
return out;
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
export function createWorkManager({
|
|
110
|
+
export function createWorkManager({
|
|
111
|
+
repoRoot,
|
|
112
|
+
baseDir,
|
|
113
|
+
getBaseRef,
|
|
114
|
+
getMcpUrl,
|
|
115
|
+
getLeaseTtl,
|
|
116
|
+
/** "The repo picture changed — look again." See the caller in fleet.mjs. */
|
|
117
|
+
onRepoChanged = () => {},
|
|
118
|
+
}) {
|
|
111
119
|
/**
|
|
112
120
|
* WHERE SHIP LANDS, read fresh every time rather than captured at startup.
|
|
113
121
|
*
|
|
@@ -319,6 +327,19 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
|
|
|
319
327
|
// The report has landed, so the idempotency path no longer needs the
|
|
320
328
|
// branch to exist. See `sweepMergedSessionBranch`.
|
|
321
329
|
sweepMergedSessionBranch(sessionId);
|
|
330
|
+
// AND THE DIFFSTAT IS NOW WRONG BY DEFINITION. A ship folds base in,
|
|
331
|
+
// merges the tip out and deletes the branch, so a fresh measurement reads
|
|
332
|
+
// `ahead: 0` with an empty diffstat — and without this the rail keeps
|
|
333
|
+
// rendering the ENTIRE pre-ship diff while the transcript two hundred
|
|
334
|
+
// pixels away says "Shipped to main". The ship button re-arms over a
|
|
335
|
+
// branch that is already merged. Same rule the kill path just learned:
|
|
336
|
+
// an action that changes what the machine would measure must cause a new
|
|
337
|
+
// measurement, and the 60s sweep is not that.
|
|
338
|
+
void reportPlaceWorktrees(sessionId).catch(() => {});
|
|
339
|
+
// …and the REPO picture changed too: the session branch is gone and base
|
|
340
|
+
// moved. Without this the Repository block keeps counting a branch the
|
|
341
|
+
// ship just deleted.
|
|
342
|
+
onRepoChanged();
|
|
322
343
|
}
|
|
323
344
|
return r;
|
|
324
345
|
};
|
|
@@ -591,11 +612,43 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
|
|
|
591
612
|
...(title ? { title } : {}),
|
|
592
613
|
};
|
|
593
614
|
};
|
|
594
|
-
/** One session, now
|
|
615
|
+
/** One session, now. */
|
|
595
616
|
const reportSessionWorktree = async (sessionId) => {
|
|
596
617
|
const r = sessionWorktreeReport(sessionId);
|
|
597
618
|
if (r) await postWorktrees([r]);
|
|
598
619
|
};
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* …AND EVERY OTHER TAB STANDING IN THE SAME DIRECTORY.
|
|
623
|
+
*
|
|
624
|
+
* One tab is not one directory any more. Since tabs moved into the driver's
|
|
625
|
+
* own folder, every tab a person owns resolves to the SAME place — so a turn
|
|
626
|
+
* in tab A changed the directory tab B is also describing, and only tab A was
|
|
627
|
+
* re-measured. Tab B went on rendering its pre-turn `+A −D` for up to a
|
|
628
|
+
* minute, which makes the tab strip visibly disagree with itself about one
|
|
629
|
+
* directory. That is the "why are two tabs showing one dev server" confusion
|
|
630
|
+
* the places readout exists to END, arriving through the diffstat instead.
|
|
631
|
+
*
|
|
632
|
+
* It fires on EVERY turn, every ship and every stop, which is what made this
|
|
633
|
+
* the most-hit instance of the rule and the least visible: nothing is wrong
|
|
634
|
+
* on the tab you are looking at.
|
|
635
|
+
*
|
|
636
|
+
* Bounded by the live set the roster last handed us, and the reports go in
|
|
637
|
+
* ONE post — the endpoint is already batched, and a tab per request would
|
|
638
|
+
* turn a five-tab place into five round trips on every settle.
|
|
639
|
+
*/
|
|
640
|
+
const reportPlaceWorktrees = async (sessionId) => {
|
|
641
|
+
const place = placeOf(sessionId);
|
|
642
|
+
const ids = [sessionId];
|
|
643
|
+
// `worktreeSeen` is the roster's own live set, pruned to `activeWorkSessions`
|
|
644
|
+
// on every sweep — so this can never report a tab that has closed, and it
|
|
645
|
+
// needs no second source of truth about which tabs exist.
|
|
646
|
+
for (const id of worktreeSeen) {
|
|
647
|
+
if (id !== sessionId && placeOf(id) === place) ids.push(id);
|
|
648
|
+
}
|
|
649
|
+
const reports = ids.map(sessionWorktreeReport).filter(Boolean);
|
|
650
|
+
if (reports.length) await postWorktrees(reports);
|
|
651
|
+
};
|
|
599
652
|
/** Every live session, throttled — called from the reconcile loop. */
|
|
600
653
|
/**
|
|
601
654
|
* A SESSION NOBODY HAS MEASURED YET JUMPS THE SWEEP (2026-08-26).
|
|
@@ -1070,6 +1123,30 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
|
|
|
1070
1123
|
return false;
|
|
1071
1124
|
};
|
|
1072
1125
|
|
|
1126
|
+
/**
|
|
1127
|
+
* How long to watch for the process to actually go before answering.
|
|
1128
|
+
*
|
|
1129
|
+
* SIGTERM is a REQUEST, not an event: a dev server traps it and tears down
|
|
1130
|
+
* its children, which takes a beat. Answering the instant the signal returns
|
|
1131
|
+
* would report "signalled" over a process that is about to die, and the
|
|
1132
|
+
* surface would then offer Force stop on something already on its way out.
|
|
1133
|
+
*
|
|
1134
|
+
* Four seconds is long enough for the ordinary teardown and short enough that
|
|
1135
|
+
* a person is still looking at the row. Past it the honest answer is that the
|
|
1136
|
+
* signal landed and the thing is still there — which is a real state, and the
|
|
1137
|
+
* one where escalating actually means something.
|
|
1138
|
+
*/
|
|
1139
|
+
const KILL_GRACE_MS = 4000;
|
|
1140
|
+
|
|
1141
|
+
const waitForExit = async (pid) => {
|
|
1142
|
+
const until = Date.now() + KILL_GRACE_MS;
|
|
1143
|
+
while (Date.now() < until) {
|
|
1144
|
+
if (!processAlive(pid)) return true;
|
|
1145
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
1146
|
+
}
|
|
1147
|
+
return !processAlive(pid);
|
|
1148
|
+
};
|
|
1149
|
+
|
|
1073
1150
|
const runKill = async (job) => {
|
|
1074
1151
|
const id = String(job.id);
|
|
1075
1152
|
const sessionId = String(job.sessionId || '');
|
|
@@ -1083,20 +1160,57 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
|
|
|
1083
1160
|
if (!killTargetOk(sessionId, pid)) {
|
|
1084
1161
|
// Not a lie and not a failure: the process is genuinely no longer one of
|
|
1085
1162
|
// this tab's, which is the common case when somebody clicks a row that
|
|
1086
|
-
// has since exited. The asker gets that sentence rather than a spinner
|
|
1163
|
+
// has since exited. The asker gets that sentence rather than a spinner —
|
|
1164
|
+
// and the RE-MEASURE below is what takes the stale row off their screen,
|
|
1165
|
+
// since a row you can click for something already gone is the readout
|
|
1166
|
+
// being behind, not the person being wrong.
|
|
1087
1167
|
await postKill({ id, outcome: 'not_found' });
|
|
1168
|
+
await remeasureAfterKill(sessionId);
|
|
1088
1169
|
return;
|
|
1089
1170
|
}
|
|
1090
1171
|
if (!(await claimKill(id))) return;
|
|
1091
1172
|
try {
|
|
1092
1173
|
process.kill(pid, signal);
|
|
1093
|
-
|
|
1174
|
+
// WHAT HAPPENED, not what we did. "We sent a signal" is a fact about us;
|
|
1175
|
+
// "it stopped" is a fact about the machine, and the machine is standing
|
|
1176
|
+
// right here able to check. Reporting the weaker word would also make the
|
|
1177
|
+
// Force stop offer wrong for the whole window, since escalating only
|
|
1178
|
+
// means something while the process is genuinely still there.
|
|
1179
|
+
const gone = await waitForExit(pid);
|
|
1180
|
+
await postKill({ id, outcome: gone ? 'stopped' : 'signalled', signal });
|
|
1094
1181
|
} catch (e) {
|
|
1095
1182
|
// EPERM is the daemon-on-its-own-user posture doing exactly what it is
|
|
1096
1183
|
// for. Report it as its own word: "we may not" and "it was gone" are
|
|
1097
1184
|
// different sentences and the surface says which.
|
|
1098
1185
|
await postKill({ id, outcome: e?.code === 'ESRCH' ? 'not_found' : 'error', detail: String(e?.code || e) });
|
|
1099
1186
|
}
|
|
1187
|
+
await remeasureAfterKill(sessionId);
|
|
1188
|
+
};
|
|
1189
|
+
|
|
1190
|
+
/**
|
|
1191
|
+
* THE LIST THE PERSON IS LOOKING AT WAS MEASURED BEFORE ANY OF THIS.
|
|
1192
|
+
*
|
|
1193
|
+
* Without this the row survives the thing it describes: the panel renders the
|
|
1194
|
+
* last sweep's `listening`, the sweep is on a SIXTY-SECOND beat, and the
|
|
1195
|
+
* reported outcome sits next to a port row still claiming to be live. The
|
|
1196
|
+
* first person to use it said exactly that — "i clicked stop on the listening
|
|
1197
|
+
* but its still running… then it finally disappears".
|
|
1198
|
+
*
|
|
1199
|
+
* The rule it was missing is one this file already keeps everywhere else: an
|
|
1200
|
+
* action that changes what the machine would measure must cause a new
|
|
1201
|
+
* measurement. A turn settling does it; a kill did not. `reportSessionWorktree`
|
|
1202
|
+
* is the un-throttled per-session path built for precisely this and it was
|
|
1203
|
+
* being called from exactly one place.
|
|
1204
|
+
*
|
|
1205
|
+
* Never awaited by the caller's answer path: the outcome is posted first, so
|
|
1206
|
+
* a slow re-measure can delay the list but never the sentence.
|
|
1207
|
+
*/
|
|
1208
|
+
const remeasureAfterKill = async (sessionId) => {
|
|
1209
|
+
try {
|
|
1210
|
+
await reportPlaceWorktrees(sessionId);
|
|
1211
|
+
} catch {
|
|
1212
|
+
/* the 60s sweep still carries it — this only makes it prompt */
|
|
1213
|
+
}
|
|
1100
1214
|
};
|
|
1101
1215
|
|
|
1102
1216
|
const processKillJobs = (jobs) => {
|
|
@@ -1383,6 +1497,9 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
|
|
|
1383
1497
|
git(['worktree', 'add', '-b', branch, wt, at], repoRoot);
|
|
1384
1498
|
} catch {
|
|
1385
1499
|
git(['worktree', 'prune'], repoRoot);
|
|
1500
|
+
// A directory and a branch just stopped existing. The Repository block
|
|
1501
|
+
// would otherwise keep listing both until its own 60s scan came round.
|
|
1502
|
+
onRepoChanged();
|
|
1386
1503
|
try {
|
|
1387
1504
|
// The branch may already exist (a retired directory's work) — attach.
|
|
1388
1505
|
git(['worktree', 'add', wt, branch], repoRoot);
|
|
@@ -2474,7 +2591,7 @@ export function createWorkManager({ repoRoot, baseDir, getBaseRef, getMcpUrl, ge
|
|
|
2474
2591
|
// written half a file, and the tab should show that honestly). NOT
|
|
2475
2592
|
// awaited: this runs inside the session's chain, and a slow POST
|
|
2476
2593
|
// would delay the next turn of that tab behind a readout.
|
|
2477
|
-
void
|
|
2594
|
+
void reportPlaceWorktrees(job.sessionId).catch(() => {});
|
|
2478
2595
|
}
|
|
2479
2596
|
});
|
|
2480
2597
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.72.0",
|
|
4
4
|
"description": "Run your own coding CLIs as build agents for Flowviant — 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": {
|