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.
- package/README.md +25 -31
- package/bin/cli.mjs +28 -22
- package/bin/lib/authproxy.mjs +131 -32
- package/bin/lib/config.mjs +15 -20
- package/bin/lib/fleet.mjs +141 -63
- package/bin/lib/git.mjs +1 -1
- package/bin/lib/instance.mjs +296 -17
- package/bin/lib/listeners.mjs +269 -0
- package/bin/lib/login.mjs +5 -1
- package/bin/lib/mcp-cli.mjs +12 -11
- package/bin/lib/preflight.mjs +8 -5
- package/bin/lib/preview.mjs +294 -390
- package/bin/lib/runtimes.mjs +12 -3
- package/bin/lib/stream.mjs +10 -8
- package/bin/lib/update.mjs +3 -2
- package/bin/lib/work.mjs +249 -5
- package/package.json +1 -1
package/bin/lib/instance.mjs
CHANGED
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ONE DAEMON PER
|
|
2
|
+
* ONE DAEMON PER REPO (and per credential), ARBITRATED at startup — a second
|
|
3
|
+
* run in the same repo takes the first one's place rather than being turned
|
|
4
|
+
* away. See "WHAT A SECOND RUN DOES" below for the whole rule.
|
|
3
5
|
*
|
|
4
|
-
* WHY THIS EXISTS. Nothing stopped two daemons before, and the server
|
|
5
|
-
* work out by READING, never claiming: `listWorkTurnJobs`
|
|
6
|
-
* turn for the
|
|
7
|
-
* credential
|
|
8
|
-
* connected daemon socket at once, so they
|
|
6
|
+
* WHY THIS EXISTS. Nothing stopped two daemons before, and the server USED to
|
|
7
|
+
* hand work out by READING, never claiming: `listWorkTurnJobs` selected every
|
|
8
|
+
* pending turn for the machine credential, `listShipJobs` read a flag. So two
|
|
9
|
+
* daemons on one credential were offered the SAME turn — and the ProjectRoom
|
|
10
|
+
* nudges every connected daemon socket at once, so they did not even drift out
|
|
11
|
+
* of phase.
|
|
12
|
+
*
|
|
13
|
+
* That half is fixed on the server now: since 0.53.0 each SESSION is leased to
|
|
14
|
+
* one daemon INSTANCE nonce — `di`, regenerated every start (config.mjs) and
|
|
15
|
+
* sent on every poll beside `ws`, the list of sessions this daemon holds a
|
|
16
|
+
* worktree for — so a turn is handed to the instance holding that session and
|
|
17
|
+
* to no one else. It does NOT retire this lock. The lease fails OPEN when no
|
|
18
|
+
* instance is reported (an older daemon cannot name itself), and it arbitrates
|
|
19
|
+
* only what rides a session: the wiki sweep, env materialization, previews,
|
|
20
|
+
* deploys and every worktree operation the server never sees are still first
|
|
21
|
+
* come, first served.
|
|
9
22
|
*
|
|
10
23
|
* The per-worktree `flowviant-turn.lock` cannot save it. That lock is written
|
|
11
24
|
* AFTER the work token is minted and the attachments are fetched — a window
|
|
@@ -14,27 +27,70 @@
|
|
|
14
27
|
* (its own comment says so, work.mjs), where the holder is already live when
|
|
15
28
|
* the successor looks; it was never a concurrency primitive.
|
|
16
29
|
*
|
|
17
|
-
* What
|
|
18
|
-
* editing one worktree, two cards from one `file_card` (no
|
|
19
|
-
* the session write budget spent twice, quota spent twice —
|
|
20
|
-
* ONE answer
|
|
21
|
-
*
|
|
30
|
+
* What a duplicate run cost before the session lease, all of it invisible in
|
|
31
|
+
* the tab: two Claudes editing one worktree, two cards from one `file_card` (no
|
|
32
|
+
* idempotency key), the session write budget spent twice, quota spent twice —
|
|
33
|
+
* and then exactly ONE answer surviving, because `settleWorkTurn` is atomic.
|
|
34
|
+
* The side effects landed twice and the transcript showed one turn. Two daemons
|
|
35
|
+
* in one checkout still cost the un-leased half of that: two `git fetch`, two
|
|
36
|
+
* worktree sweeps, and the collisions listed under ONE DAEMON PER REPO below.
|
|
22
37
|
*
|
|
23
|
-
* KEYED ON THE CREDENTIAL,
|
|
38
|
+
* KEYED ON THE CREDENTIAL — and, as the next paragraph adds, on the REPO as
|
|
39
|
+
* well; both checks run, and either one is enough. The credential is stored once, at
|
|
24
40
|
* ~/.flowviant/credentials.json, so `flowviant` in two DIFFERENT checkouts is
|
|
25
41
|
* still one project served twice — and that case is strictly worse, because the
|
|
26
42
|
* two daemons have different worktree roots and the turn lock cannot even see
|
|
27
43
|
* across them. Keying on the token catches both, and still lets a second
|
|
28
44
|
* credential run a second project on the same machine.
|
|
29
45
|
*
|
|
46
|
+
* ...AND ONE DAEMON PER REPO, which is NOT the same statement. The lock above
|
|
47
|
+
* is keyed on the credential, and the two coincide only while one credential
|
|
48
|
+
* serves one project — which is the product's law but not a thing this file can
|
|
49
|
+
* assume. Two DIFFERENT credentials pointing at one checkout both acquired
|
|
50
|
+
* happily (measured), giving two daemons in one working tree: two `git fetch`,
|
|
51
|
+
* two worktree sweeps, `retireWorkSessions` in one removing directories the
|
|
52
|
+
* other is serving, and a ship in one racing a rebase in the other. No server
|
|
53
|
+
* lease can arbitrate any of that, because the server never sees a directory.
|
|
54
|
+
* So the repo is checked too, across every credential's lock.
|
|
55
|
+
*
|
|
56
|
+
* WHAT A SECOND RUN DOES, and this is the whole rule:
|
|
57
|
+
*
|
|
58
|
+
* SAME REPO -> the new run WINS. The holder is asked to stand down and
|
|
59
|
+
* this daemon takes its place. Re-running `flowviant` in a
|
|
60
|
+
* directory you are working in means "serve this repo", and
|
|
61
|
+
* the process already serving it is by definition the one
|
|
62
|
+
* you are replacing. That is a restart, and a restart
|
|
63
|
+
* should not require you to go and find a pid.
|
|
64
|
+
*
|
|
65
|
+
* DIFFERENT REPO -> REFUSED, and nothing is signalled. That daemon is serving
|
|
66
|
+
* other work; killing it because you happened to run this
|
|
67
|
+
* command elsewhere is not a restart, it is collateral.
|
|
68
|
+
* `--takeover` overrides, deliberately explicitly.
|
|
69
|
+
*
|
|
70
|
+
* One rule, and it is the invariant stated as behaviour: one daemon per repo.
|
|
71
|
+
* `--no-takeover` (or FLOWVIANT_NO_TAKEOVER=1) makes even the same-repo case
|
|
72
|
+
* refuse, for anyone who wants the old ceremony.
|
|
73
|
+
*
|
|
30
74
|
* IT FAILS OPEN. A home directory we cannot write to is not a reason to refuse
|
|
31
75
|
* to start; it is a reason to say so and carry on unguarded.
|
|
32
76
|
*/
|
|
33
77
|
|
|
34
|
-
import {
|
|
35
|
-
import {
|
|
78
|
+
import { execFileSync } from 'node:child_process';
|
|
79
|
+
import {
|
|
80
|
+
closeSync,
|
|
81
|
+
mkdirSync,
|
|
82
|
+
openSync,
|
|
83
|
+
readdirSync,
|
|
84
|
+
readFileSync,
|
|
85
|
+
realpathSync,
|
|
86
|
+
rmSync,
|
|
87
|
+
writeFileSync,
|
|
88
|
+
writeSync,
|
|
89
|
+
} from 'node:fs';
|
|
90
|
+
import { homedir, platform } from 'node:os';
|
|
36
91
|
import { join } from 'node:path';
|
|
37
92
|
import { createHash } from 'node:crypto';
|
|
93
|
+
import { VERSION } from './config.mjs';
|
|
38
94
|
|
|
39
95
|
/** Deliberately a HASH: a credential must never become a filename. */
|
|
40
96
|
export function instanceLockPath(fleetToken) {
|
|
@@ -42,6 +98,20 @@ export function instanceLockPath(fleetToken) {
|
|
|
42
98
|
return join(homedir(), '.flowviant', `daemon-${key}.lock`);
|
|
43
99
|
}
|
|
44
100
|
|
|
101
|
+
/** Numeric dotted compare, -1/0/1. Unparsable compares EQUAL, so a version we
|
|
102
|
+
* cannot read never silently authorises a downgrade. */
|
|
103
|
+
function cmpVersion(a, b) {
|
|
104
|
+
const x = String(a).split('.').map((n) => Number.parseInt(n, 10));
|
|
105
|
+
const y = String(b).split('.').map((n) => Number.parseInt(n, 10));
|
|
106
|
+
for (let i = 0; i < Math.max(x.length, y.length); i++) {
|
|
107
|
+
const p = x[i] ?? 0;
|
|
108
|
+
const q = y[i] ?? 0;
|
|
109
|
+
if (Number.isNaN(p) || Number.isNaN(q)) return 0;
|
|
110
|
+
if (p !== q) return p > q ? 1 : -1;
|
|
111
|
+
}
|
|
112
|
+
return 0;
|
|
113
|
+
}
|
|
114
|
+
|
|
45
115
|
/** Signal 0 — a liveness probe, not a kill. EPERM means alive and not ours. */
|
|
46
116
|
function alive(pid) {
|
|
47
117
|
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
@@ -63,7 +133,192 @@ function readHolder(path) {
|
|
|
63
133
|
}
|
|
64
134
|
|
|
65
135
|
const record = (repoRoot) =>
|
|
66
|
-
JSON.stringify({
|
|
136
|
+
JSON.stringify({
|
|
137
|
+
pid: process.pid,
|
|
138
|
+
repoRoot,
|
|
139
|
+
startedAt: new Date().toISOString(),
|
|
140
|
+
// The script we were started from, and what we are. A takeover matches the
|
|
141
|
+
// live command line against `entry` before signalling anything — a lock
|
|
142
|
+
// records a PID, and a crashed daemon's PID can be reused by anything.
|
|
143
|
+
entry: process.argv[1] || '',
|
|
144
|
+
version: VERSION,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
/** Same directory, whatever it is spelled as — symlinks and trailing slashes
|
|
148
|
+
* included. A repo compared by string would let `/repo` and `/repo/` past. */
|
|
149
|
+
function samePath(a, b) {
|
|
150
|
+
if (!a || !b) return false;
|
|
151
|
+
const norm = (v) => {
|
|
152
|
+
try {
|
|
153
|
+
return realpathSync(v);
|
|
154
|
+
} catch {
|
|
155
|
+
return String(v).replace(/\/+$/, '');
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
return norm(a) === norm(b);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* A LIVE daemon in this same checkout, under a DIFFERENT credential.
|
|
163
|
+
*
|
|
164
|
+
* The lock above cannot see one: it is keyed on the credential, so a second
|
|
165
|
+
* token in the same directory opens its own file and takes it. Every other
|
|
166
|
+
* lock file on this machine is ours to read, so read them.
|
|
167
|
+
*
|
|
168
|
+
* Returns the holder, or null. A stale file never blocks — it is cleared by
|
|
169
|
+
* whichever acquire owns it, and blocking on a corpse would be worse than the
|
|
170
|
+
* thing this prevents.
|
|
171
|
+
*/
|
|
172
|
+
/** Which lock file a neighbour holder was read from — takeOverFrom waits on it. */
|
|
173
|
+
const NEIGHBOUR_PATHS = new WeakMap();
|
|
174
|
+
function neighbourLockPath(holder, fallback) {
|
|
175
|
+
return NEIGHBOUR_PATHS.get(holder) ?? fallback;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function daemonInSameRepo(repoRoot, ownPath) {
|
|
179
|
+
const dir = join(homedir(), '.flowviant');
|
|
180
|
+
let files;
|
|
181
|
+
try {
|
|
182
|
+
files = readdirSync(dir).filter((f) => /^daemon-[0-9a-f]{12}\.lock$/.test(f));
|
|
183
|
+
} catch {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
for (const f of files) {
|
|
187
|
+
const path = join(dir, f);
|
|
188
|
+
if (path === ownPath) continue; // our own credential — the lock above owns that question
|
|
189
|
+
const holder = readHolder(path);
|
|
190
|
+
if (!holder || !alive(holder.pid)) continue;
|
|
191
|
+
if (holder.pid === process.ppid) continue; // ourselves mid self-update re-exec
|
|
192
|
+
if (samePath(holder.repoRoot, repoRoot)) {
|
|
193
|
+
NEIGHBOUR_PATHS.set(holder, path);
|
|
194
|
+
return holder;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* IS THIS PID STILL THE DAEMON THAT TOOK THE LOCK?
|
|
202
|
+
*
|
|
203
|
+
* `process.kill(pid, 0)` says "a process exists", which is not the same claim,
|
|
204
|
+
* and the difference matters the moment we are about to signal it. Matched on
|
|
205
|
+
* the holder's own recorded ENTRYPOINT, never on the word "flowviant": a
|
|
206
|
+
* command line merely CONTAINING it matches a shell, an editor, or a test
|
|
207
|
+
* runner living under a `…-flowviant/` directory. That last one is not
|
|
208
|
+
* hypothetical — a looser version of this check SIGTERMed one.
|
|
209
|
+
*
|
|
210
|
+
* A lock with no `entry` predates this and is never signalled.
|
|
211
|
+
*/
|
|
212
|
+
function stillTheHolder(holder) {
|
|
213
|
+
const want = typeof holder?.entry === 'string' ? holder.entry : null;
|
|
214
|
+
if (!want) return false;
|
|
215
|
+
try {
|
|
216
|
+
if (platform() === 'linux') {
|
|
217
|
+
return readFileSync(`/proc/${holder.pid}/cmdline`, 'utf8').replace(/\0/g, ' ').includes(want);
|
|
218
|
+
}
|
|
219
|
+
return execFileSync('ps', ['-o', 'command=', '-p', String(holder.pid)], {
|
|
220
|
+
encoding: 'utf8',
|
|
221
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
222
|
+
timeout: 3000,
|
|
223
|
+
}).includes(want);
|
|
224
|
+
} catch {
|
|
225
|
+
return false; // gone, or unreadable — not something we signal
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Blocking, because this runs before there is an event loop worth yielding to
|
|
230
|
+
* and the caller cannot proceed until it knows whether the holder is gone. */
|
|
231
|
+
const sleep = (ms) => {
|
|
232
|
+
try {
|
|
233
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
234
|
+
} catch {
|
|
235
|
+
/* no SharedArrayBuffer — check again immediately */
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
/** How long the outgoing daemon gets to stand down cleanly. Its SIGTERM handler
|
|
240
|
+
* kills the CLI children it spawned and stops its preview tunnels; both are
|
|
241
|
+
* why we ask before we insist. */
|
|
242
|
+
const TAKEOVER_GRACE_MS = 20_000;
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Ask the holder to stand down, then take its place.
|
|
246
|
+
*
|
|
247
|
+
* SIGTERM FIRST, and not out of politeness: the daemon's handler runs its
|
|
248
|
+
* teardown — it kills the CLI children it spawned and stops its preview
|
|
249
|
+
* tunnels, which are DETACHED and would otherwise keep a public hostname
|
|
250
|
+
* serving a worktree until the box reboots.
|
|
251
|
+
*
|
|
252
|
+
* WAIT ON THE LOCK FILE, not the pid. A departing daemon's release() removes it
|
|
253
|
+
* on exit, so the file changing IS the handover. `kill(pid, 0)` cannot see it:
|
|
254
|
+
* a process that exited but has not been reaped is a ZOMBIE and answers signal
|
|
255
|
+
* 0 exactly like a living one — measured, a peer that exited cleanly still read
|
|
256
|
+
* as alive for the full grace window.
|
|
257
|
+
*
|
|
258
|
+
* And "gone" is NOT "the file stopped naming our pid". It can stop naming it
|
|
259
|
+
* because the holder SELF-UPDATED: update.mjs re-execs and the successor adopts
|
|
260
|
+
* this same lock through the ppid branch. Treating that as free steals a live
|
|
261
|
+
* daemon's lock and leaves it running unguarded — measured doing exactly that.
|
|
262
|
+
*/
|
|
263
|
+
function takeOverFrom(holder, path, log, { allowDowngrade = false } = {}) {
|
|
264
|
+
if (!holder?.pid || !alive(holder.pid)) return null; // already gone
|
|
265
|
+
if (!stillTheHolder(holder)) {
|
|
266
|
+
return { failed: `pid ${holder.pid} is no longer the daemon that took this lock — refusing to signal it` };
|
|
267
|
+
}
|
|
268
|
+
if (!allowDowngrade && holder.version && cmpVersion(VERSION, holder.version) < 0) {
|
|
269
|
+
return {
|
|
270
|
+
failed: `the running daemon is ${holder.version} and this one is ${VERSION} — refusing to replace a newer daemon with an older one (--takeover-downgrade if you mean it)`,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
log?.(`asking daemon pid ${holder.pid} to stand down…`);
|
|
275
|
+
try {
|
|
276
|
+
process.kill(holder.pid, 'SIGTERM');
|
|
277
|
+
} catch {
|
|
278
|
+
return { failed: `could not signal pid ${holder.pid}` };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const standing = () => {
|
|
282
|
+
const now = readHolder(path);
|
|
283
|
+
if (!now || !alive(now.pid)) return null;
|
|
284
|
+
return now;
|
|
285
|
+
};
|
|
286
|
+
const deadline = Date.now() + TAKEOVER_GRACE_MS;
|
|
287
|
+
for (;;) {
|
|
288
|
+
const now = standing();
|
|
289
|
+
if (!now) break;
|
|
290
|
+
if (now.pid !== holder.pid) {
|
|
291
|
+
return {
|
|
292
|
+
failed: `the daemon handed over to pid ${now.pid}${now.version ? ` (${now.version})` : ''} while we waited — it is mid-update, so try again in a moment`,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
if (Date.now() >= deadline) {
|
|
296
|
+
log?.(`pid ${holder.pid} did not stand down within ${TAKEOVER_GRACE_MS / 1000}s — forcing it.`);
|
|
297
|
+
try {
|
|
298
|
+
process.kill(holder.pid, 'SIGKILL');
|
|
299
|
+
} catch {
|
|
300
|
+
/* exited in the gap */
|
|
301
|
+
}
|
|
302
|
+
sleep(600);
|
|
303
|
+
const after = standing();
|
|
304
|
+
if (after && after.pid !== holder.pid) {
|
|
305
|
+
return { failed: `the daemon handed over to pid ${after.pid} — try again in a moment` };
|
|
306
|
+
}
|
|
307
|
+
if (after) return { failed: `pid ${holder.pid} would not stop` };
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
sleep(400);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// A SIGKILLed daemon never ran its release(), so clear what it left.
|
|
314
|
+
try {
|
|
315
|
+
rmSync(path, { force: true });
|
|
316
|
+
} catch {
|
|
317
|
+
return { failed: 'could not clear the lock file' };
|
|
318
|
+
}
|
|
319
|
+
log?.(`daemon pid ${holder.pid} stopped — taking over.`);
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
67
322
|
|
|
68
323
|
/**
|
|
69
324
|
* Take the lock, or report who holds it.
|
|
@@ -75,7 +330,8 @@ const record = (repoRoot) =>
|
|
|
75
330
|
* `wx` is the whole guarantee: create-exclusive is one atomic syscall, which is
|
|
76
331
|
* the property the turn lock's check-then-write does not have.
|
|
77
332
|
*/
|
|
78
|
-
export function acquireInstanceLock(fleetToken, repoRoot) {
|
|
333
|
+
export function acquireInstanceLock(fleetToken, repoRoot, opts = {}) {
|
|
334
|
+
const { takeover: force = false, noTakeover = false, allowDowngrade = false, log } = opts;
|
|
79
335
|
if (process.env.FLOWVIANT_ALLOW_MULTI === '1') return { ok: true, release: () => {} };
|
|
80
336
|
const path = instanceLockPath(fleetToken);
|
|
81
337
|
try {
|
|
@@ -84,6 +340,20 @@ export function acquireInstanceLock(fleetToken, repoRoot) {
|
|
|
84
340
|
return { ok: true, release: () => {}, unguarded: true };
|
|
85
341
|
}
|
|
86
342
|
|
|
343
|
+
// ONE DAEMON PER REPO, checked across every credential — see the header. This
|
|
344
|
+
// runs BEFORE we take our own lock, so a refusal leaves nothing behind.
|
|
345
|
+
const neighbour = daemonInSameRepo(repoRoot, path);
|
|
346
|
+
if (neighbour) {
|
|
347
|
+
// Same working tree, another credential. Under "one daemon per repo" the
|
|
348
|
+
// new run wins here too — but it is signalling a process that belongs to a
|
|
349
|
+
// DIFFERENT project, so it is worth saying out loud rather than doing
|
|
350
|
+
// quietly.
|
|
351
|
+
if (noTakeover) return { ok: false, holder: neighbour, sameRepo: true };
|
|
352
|
+
log?.(`another project's daemon is serving this repo (pid ${neighbour.pid}).`);
|
|
353
|
+
const bad = takeOverFrom(neighbour, neighbourLockPath(neighbour, path), log);
|
|
354
|
+
if (bad) return { ok: false, holder: neighbour, sameRepo: true, takeoverFailed: bad.failed };
|
|
355
|
+
}
|
|
356
|
+
|
|
87
357
|
// Two passes at most: one to clear a stale holder, one to take the lock. A
|
|
88
358
|
// loop here would spin against a peer that keeps re-taking it.
|
|
89
359
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
@@ -118,7 +388,16 @@ export function acquireInstanceLock(fleetToken, repoRoot) {
|
|
|
118
388
|
}
|
|
119
389
|
return { ok: true, release: makeRelease(path) };
|
|
120
390
|
}
|
|
121
|
-
|
|
391
|
+
// THE RULE. Same repo -> this run replaces it; different repo -> refuse
|
|
392
|
+
// and signal nothing, unless --takeover says otherwise. See the header.
|
|
393
|
+
const here = samePath(holder.repoRoot, repoRoot);
|
|
394
|
+
const wanted = force || (here && !noTakeover);
|
|
395
|
+
if (wanted) {
|
|
396
|
+
const bad = takeOverFrom(holder, path, log, { allowDowngrade });
|
|
397
|
+
if (bad) return { ok: false, holder, takeoverFailed: bad.failed, sameRepo: here };
|
|
398
|
+
continue; // the file is gone — the next pass takes it
|
|
399
|
+
}
|
|
400
|
+
return { ok: false, holder, sameRepo: here };
|
|
122
401
|
}
|
|
123
402
|
try {
|
|
124
403
|
writeSync(fd, record(repoRoot));
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What is LISTENING inside a session's worktree.
|
|
3
|
+
*
|
|
4
|
+
* The Workbench preview never starts an app. The driver runs their own dev
|
|
5
|
+
* server in their own tab, exactly as they would in a terminal, and this file
|
|
6
|
+
* is how the machine NOTICES — a browser has no `ss -ltnp` to run, so the
|
|
7
|
+
* daemon runs it. That direction is the whole design: a control that can only
|
|
8
|
+
* exist once the machine has measured the thing it acts on cannot invent a
|
|
9
|
+
* state, cannot guess a port, and cannot time out waiting for a cold start.
|
|
10
|
+
*
|
|
11
|
+
* A listener is attributed to a session by the CWD OF THE PROCESS HOLDING THE
|
|
12
|
+
* SOCKET, never by the port number. Ports are global to the box; a worktree is
|
|
13
|
+
* not. Without that attribution `share_preview(5432)` tunnels Postgres and
|
|
14
|
+
* `share_preview(<a teammate's port>)` publishes somebody else's worktree — so
|
|
15
|
+
* this measurement is a security control, not a convenience, and it is why the
|
|
16
|
+
* MCP tool must not ship before it.
|
|
17
|
+
*
|
|
18
|
+
* Deliberately NOT a probe: nothing here connects to the port, sends bytes, or
|
|
19
|
+
* asks what is on the other end. It reads the kernel's own socket table. A
|
|
20
|
+
* daemon that spoke to whatever the driver happened to be running would be a
|
|
21
|
+
* second actor in their session.
|
|
22
|
+
*
|
|
23
|
+
* Linux (including WSL2) reads /proc. macOS shells out to lsof twice. Windows
|
|
24
|
+
* reports NOTHING and says so through the empty array — the same answer
|
|
25
|
+
* `stillOurs` gives, and the same rule the rest of the product keeps: an
|
|
26
|
+
* unmeasured thing renders nothing rather than rendering "none".
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { execFileSync } from 'node:child_process';
|
|
30
|
+
import { createConnection } from 'node:net';
|
|
31
|
+
import { readFileSync, readdirSync, readlinkSync, realpathSync } from 'node:fs';
|
|
32
|
+
import { platform } from 'node:os';
|
|
33
|
+
import { sep } from 'node:path';
|
|
34
|
+
|
|
35
|
+
/** A box with more processes than this is not one we walk per sweep. The scan
|
|
36
|
+
* is one readlink per pid and runs every reconcile; this is the runaway
|
|
37
|
+
* bound, not a capacity statement. */
|
|
38
|
+
const MAX_PIDS = 4000;
|
|
39
|
+
/** Rows reported per session. A dev server, its HMR socket and an API is three;
|
|
40
|
+
* twenty is somebody's docker-compose and the extra rows say nothing. */
|
|
41
|
+
const MAX_ROWS = 8;
|
|
42
|
+
/** Longest process label we relay. */
|
|
43
|
+
const MAX_LABEL = 24;
|
|
44
|
+
|
|
45
|
+
// ── /proc/net/tcp parsing (linux) ──────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
// local_address is "<hex addr>:<hex port>". The address is little-endian per
|
|
48
|
+
// 4-byte word; the PORT is big-endian. Only the port and the coarse bind scope
|
|
49
|
+
// are worth relaying — a browser cannot reach a loopback bind through a tunnel
|
|
50
|
+
// any differently than an any-bind, but the operator can read the difference.
|
|
51
|
+
function parseLocal(hex) {
|
|
52
|
+
const [addr, port] = String(hex).split(':');
|
|
53
|
+
if (!addr || !port) return null;
|
|
54
|
+
const p = parseInt(port, 16);
|
|
55
|
+
if (!Number.isInteger(p) || p <= 0 || p > 65535) return null;
|
|
56
|
+
const zeros = /^0+$/.test(addr);
|
|
57
|
+
const v4Loopback = addr.toUpperCase() === '0100007F';
|
|
58
|
+
// ::1 in /proc/net/tcp6 is 24 zeros then 01000000 (little-endian per word).
|
|
59
|
+
const v6Loopback = addr.toUpperCase() === '00000000000000000000000001000000';
|
|
60
|
+
return { port: p, bind: zeros ? 'any' : v4Loopback || v6Loopback ? 'loopback' : 'other' };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** inode -> { port, bind } for every socket in LISTEN state. */
|
|
64
|
+
function listeningByInode() {
|
|
65
|
+
const out = new Map();
|
|
66
|
+
for (const f of ['/proc/net/tcp', '/proc/net/tcp6']) {
|
|
67
|
+
let text;
|
|
68
|
+
try {
|
|
69
|
+
text = readFileSync(f, 'utf8');
|
|
70
|
+
} catch {
|
|
71
|
+
continue; // no ipv6 stack, or not linux
|
|
72
|
+
}
|
|
73
|
+
for (const line of text.split('\n').slice(1)) {
|
|
74
|
+
const c = line.trim().split(/\s+/);
|
|
75
|
+
if (c.length < 10) continue;
|
|
76
|
+
if (c[3] !== '0A') continue; // TCP_LISTEN
|
|
77
|
+
const local = parseLocal(c[1]);
|
|
78
|
+
if (!local) continue;
|
|
79
|
+
const inode = c[9];
|
|
80
|
+
if (inode && inode !== '0') out.set(inode, local);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function labelFor(pid) {
|
|
87
|
+
try {
|
|
88
|
+
const raw = readFileSync(`/proc/${pid}/cmdline`, 'utf8');
|
|
89
|
+
const first = raw.split('\0').filter(Boolean)[0] || '';
|
|
90
|
+
// The basename only. A full argv is the driver's command line, which can
|
|
91
|
+
// carry a token in an inline env assignment — and the whole argv is never
|
|
92
|
+
// what a reader needs to recognise their own dev server.
|
|
93
|
+
const base = first.split('/').pop() || first;
|
|
94
|
+
return base.slice(0, MAX_LABEL) || null;
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function scanLinux(worktree) {
|
|
101
|
+
const inodes = listeningByInode();
|
|
102
|
+
if (inodes.size === 0) return [];
|
|
103
|
+
|
|
104
|
+
let root;
|
|
105
|
+
try {
|
|
106
|
+
root = realpathSync(worktree);
|
|
107
|
+
} catch {
|
|
108
|
+
return []; // the directory is gone — retired under us
|
|
109
|
+
}
|
|
110
|
+
const prefix = root.endsWith(sep) ? root : root + sep;
|
|
111
|
+
const inside = (p) => p === root || p.startsWith(prefix);
|
|
112
|
+
|
|
113
|
+
let pids;
|
|
114
|
+
try {
|
|
115
|
+
pids = readdirSync('/proc').filter((d) => /^\d+$/.test(d));
|
|
116
|
+
} catch {
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
if (pids.length > MAX_PIDS) pids = pids.slice(0, MAX_PIDS);
|
|
120
|
+
|
|
121
|
+
const found = new Map(); // port -> row
|
|
122
|
+
for (const pid of pids) {
|
|
123
|
+
// Cheap filter FIRST: one readlink rejects almost every process on the box,
|
|
124
|
+
// and only survivors pay for a readdir of their fd table.
|
|
125
|
+
let cwd;
|
|
126
|
+
try {
|
|
127
|
+
cwd = readlinkSync(`/proc/${pid}/cwd`);
|
|
128
|
+
} catch {
|
|
129
|
+
continue; // not ours, or gone
|
|
130
|
+
}
|
|
131
|
+
if (!inside(cwd)) continue;
|
|
132
|
+
|
|
133
|
+
let fds;
|
|
134
|
+
try {
|
|
135
|
+
fds = readdirSync(`/proc/${pid}/fd`);
|
|
136
|
+
} catch {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
for (const fd of fds) {
|
|
140
|
+
let link;
|
|
141
|
+
try {
|
|
142
|
+
link = readlinkSync(`/proc/${pid}/fd/${fd}`);
|
|
143
|
+
} catch {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const m = /^socket:\[(\d+)\]$/.exec(link);
|
|
147
|
+
if (!m) continue;
|
|
148
|
+
const hit = inodes.get(m[1]);
|
|
149
|
+
if (!hit) continue;
|
|
150
|
+
if (found.has(hit.port)) continue;
|
|
151
|
+
found.set(hit.port, { port: hit.port, bind: hit.bind, label: labelFor(pid) });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return [...found.values()];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── macOS ──────────────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
function lsof(args) {
|
|
160
|
+
try {
|
|
161
|
+
return execFileSync('lsof', args, {
|
|
162
|
+
encoding: 'utf8',
|
|
163
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
164
|
+
timeout: 5000,
|
|
165
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
166
|
+
});
|
|
167
|
+
} catch {
|
|
168
|
+
return ''; // lsof absent or nothing matched — both are "no measurement"
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function scanDarwin(worktree) {
|
|
173
|
+
// Pass 1: every listening socket, as pid → ports.
|
|
174
|
+
const byPid = new Map();
|
|
175
|
+
let pid = null;
|
|
176
|
+
for (const line of lsof(['-nP', '-iTCP', '-sTCP:LISTEN', '-F', 'pn']).split('\n')) {
|
|
177
|
+
if (line.startsWith('p')) pid = line.slice(1);
|
|
178
|
+
else if (line.startsWith('n') && pid) {
|
|
179
|
+
const m = /:(\d+)$/.exec(line.slice(1));
|
|
180
|
+
if (!m) continue;
|
|
181
|
+
const p = Number(m[1]);
|
|
182
|
+
const bind = /^n\*:/.test(line) ? 'any' : /^n(127\.0\.0\.1|\[::1\])/.test(line) ? 'loopback' : 'other';
|
|
183
|
+
if (!byPid.has(pid)) byPid.set(pid, []);
|
|
184
|
+
byPid.get(pid).push({ port: p, bind });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (byPid.size === 0) return [];
|
|
188
|
+
|
|
189
|
+
let root;
|
|
190
|
+
try {
|
|
191
|
+
root = realpathSync(worktree);
|
|
192
|
+
} catch {
|
|
193
|
+
return [];
|
|
194
|
+
}
|
|
195
|
+
const prefix = root.endsWith(sep) ? root : root + sep;
|
|
196
|
+
|
|
197
|
+
// Pass 2: the cwd of exactly those pids, in ONE batched call.
|
|
198
|
+
const out = new Map();
|
|
199
|
+
let cur = null;
|
|
200
|
+
for (const line of lsof(['-a', '-d', 'cwd', '-F', 'pn', '-p', [...byPid.keys()].join(',')]).split('\n')) {
|
|
201
|
+
if (line.startsWith('p')) cur = line.slice(1);
|
|
202
|
+
else if (line.startsWith('n') && cur) {
|
|
203
|
+
const cwd = line.slice(1);
|
|
204
|
+
if (cwd !== root && !cwd.startsWith(prefix)) continue;
|
|
205
|
+
for (const row of byPid.get(cur) || []) {
|
|
206
|
+
if (!out.has(row.port)) out.set(row.port, { ...row, label: null });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return [...out.values()];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── public ─────────────────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Every TCP port in LISTEN held by a process whose cwd is inside `worktree`.
|
|
217
|
+
* Smallest port first, capped. An empty array on an unsupported platform means
|
|
218
|
+
* "we did not look" — callers must not turn it into "nothing is running".
|
|
219
|
+
*/
|
|
220
|
+
export function listenersIn(worktree) {
|
|
221
|
+
if (!worktree) return [];
|
|
222
|
+
let rows;
|
|
223
|
+
try {
|
|
224
|
+
rows = platform() === 'linux' ? scanLinux(worktree) : platform() === 'darwin' ? scanDarwin(worktree) : [];
|
|
225
|
+
} catch {
|
|
226
|
+
return [];
|
|
227
|
+
}
|
|
228
|
+
return rows.sort((a, b) => a.port - b.port).slice(0, MAX_ROWS);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Does this platform measure listeners at all? The web must render no preview
|
|
232
|
+
* affordance where the answer is no, rather than an empty one. */
|
|
233
|
+
export function listenersSupported() {
|
|
234
|
+
return platform() === 'linux' || platform() === 'darwin';
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Is something accepting connections on this loopback port RIGHT NOW?
|
|
239
|
+
*
|
|
240
|
+
* Used at two moments, both of them re-validation rather than discovery: the
|
|
241
|
+
* daemon re-checks a port the server told it to share, and a live share checks
|
|
242
|
+
* that its origin has not died under the tunnel. cloudflared happily outlives a
|
|
243
|
+
* dead dev server and the gate answers a dead origin with 502, so without this
|
|
244
|
+
* the product would print "live" over a 502 — which is Flowviant asserting a
|
|
245
|
+
* state it never measured.
|
|
246
|
+
*
|
|
247
|
+
* A TCP connect and an immediate close: no bytes sent, nothing read.
|
|
248
|
+
*/
|
|
249
|
+
export function isListening(port, timeoutMs = 1500) {
|
|
250
|
+
return new Promise((resolve) => {
|
|
251
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) return resolve(false);
|
|
252
|
+
let done = false;
|
|
253
|
+
const finish = (v) => {
|
|
254
|
+
if (done) return;
|
|
255
|
+
done = true;
|
|
256
|
+
try {
|
|
257
|
+
sock.destroy();
|
|
258
|
+
} catch {
|
|
259
|
+
/* already gone */
|
|
260
|
+
}
|
|
261
|
+
resolve(v);
|
|
262
|
+
};
|
|
263
|
+
const sock = createConnection({ port, host: '127.0.0.1' });
|
|
264
|
+
sock.setTimeout(timeoutMs);
|
|
265
|
+
sock.once('connect', () => finish(true));
|
|
266
|
+
sock.once('timeout', () => finish(false));
|
|
267
|
+
sock.once('error', () => finish(false));
|
|
268
|
+
});
|
|
269
|
+
}
|
package/bin/lib/login.mjs
CHANGED
|
@@ -56,7 +56,11 @@ export async function runLogin({ thenStart = false } = {}) {
|
|
|
56
56
|
}
|
|
57
57
|
const { deviceCode, userCode, intervalSeconds = 5, expiresInSeconds = 600 } = start;
|
|
58
58
|
const pretty = `${userCode.slice(0, 4)}-${userCode.slice(4)}`;
|
|
59
|
-
|
|
59
|
+
// Where the control ACTUALLY is. It was "the Agents panel", a settings
|
|
60
|
+
// section deleted 2026-08-17; connecting a machine is offered on the surface
|
|
61
|
+
// you are on when it matters, and for a new operator that is the Workbench —
|
|
62
|
+
// the project's empty state says so before it can show you any sessions.
|
|
63
|
+
console.log(` 1. Open ${c.cyan(APP_URL)} → your project → the ${c.bold('Workbench')} → ${c.bold('Connect a machine')}.`);
|
|
60
64
|
console.log(` 2. Enter this code: ${c.bold(c.green(pretty))}\n`);
|
|
61
65
|
info('waiting for you to approve…');
|
|
62
66
|
|