flowviant 0.102.2 → 0.104.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 +87 -474
- package/bin/lib/admission.mjs +19 -3
- package/bin/lib/agentPlan.mjs +2 -2
- package/bin/lib/agentTaskKinds.mjs +93 -0
- package/bin/lib/agySessions.mjs +158 -0
- package/bin/lib/artifactPolicy.mjs +44 -0
- package/bin/lib/artifactPreview.mjs +52 -0
- package/bin/lib/artifactReporter.mjs +163 -0
- package/bin/lib/artifacts.mjs +47 -227
- package/bin/lib/authReject.mjs +16 -4
- package/bin/lib/authproxy.mjs +47 -304
- package/bin/lib/boxIdentity.mjs +245 -0
- package/bin/lib/childEnv.mjs +11 -9
- package/bin/lib/claude.mjs +14 -851
- package/bin/lib/claudeAuth.mjs +3 -3
- package/bin/lib/claudePosture.mjs +367 -0
- package/bin/lib/claudeSessions.mjs +411 -0
- package/bin/lib/claudeStream.mjs +207 -0
- package/bin/lib/cliLimit.mjs +34 -0
- package/bin/lib/commandSpecs.mjs +173 -0
- package/bin/lib/config.mjs +11 -42
- package/bin/lib/credentialDate.mjs +22 -0
- package/bin/lib/credentials.mjs +21 -26
- package/bin/lib/daemonLogging.mjs +55 -0
- package/bin/lib/daemonState.mjs +64 -0
- package/bin/lib/deploy.mjs +148 -647
- package/bin/lib/deployCheckout.mjs +265 -0
- package/bin/lib/deployConfig.mjs +142 -0
- package/bin/lib/deployRunner.mjs +311 -0
- package/bin/lib/deployWire.mjs +41 -0
- package/bin/lib/desktopContract.mjs +56 -105
- package/bin/lib/env.mjs +49 -484
- package/bin/lib/fleet.mjs +184 -2396
- package/bin/lib/fleetJobs.mjs +120 -0
- package/bin/lib/fleetPost.mjs +40 -0
- package/bin/lib/fleetReports.mjs +392 -0
- package/bin/lib/fleetRoster.mjs +470 -0
- package/bin/lib/fleetStartup.mjs +196 -0
- package/bin/lib/fleetWake.mjs +45 -0
- package/bin/lib/fleetWire.mjs +46 -0
- package/bin/lib/git.mjs +34 -3
- package/bin/lib/gitMergeIdentity.mjs +51 -0
- package/bin/lib/grant.mjs +5 -3
- package/bin/lib/help.mjs +3 -125
- package/bin/lib/holder.mjs +482 -0
- package/bin/lib/instance.mjs +124 -46
- package/bin/lib/knowledge.mjs +16 -799
- package/bin/lib/knowledgeFetch.mjs +38 -0
- package/bin/lib/knowledgeFileSync.mjs +112 -0
- package/bin/lib/knowledgeFiles.mjs +487 -0
- package/bin/lib/knowledgeLibrary.mjs +249 -0
- package/bin/lib/landed.mjs +2 -1
- package/bin/lib/listeners.mjs +99 -141
- package/bin/lib/localSessions.mjs +30 -555
- package/bin/lib/login.mjs +3 -2
- package/bin/lib/loginPath.mjs +3 -5
- package/bin/lib/machineDisconnect.mjs +203 -0
- package/bin/lib/machineEnv.mjs +22 -0
- package/bin/lib/machineLimits.mjs +61 -0
- package/bin/lib/machineListing.mjs +338 -0
- package/bin/lib/machines.mjs +78 -455
- package/bin/lib/machinesCommand.mjs +214 -0
- package/bin/lib/mcp-cli.mjs +2 -1
- package/bin/lib/patchRevert.mjs +60 -0
- package/bin/lib/prWorkflow.mjs +157 -0
- package/bin/lib/preflight.mjs +2 -1
- package/bin/lib/pressurePolicy.mjs +104 -0
- package/bin/lib/preview.mjs +2 -272
- package/bin/lib/previewFramePolicy.mjs +112 -0
- package/bin/lib/previewGatePolicy.mjs +236 -0
- package/bin/lib/previewRegistry.mjs +235 -0
- package/bin/lib/printable.mjs +2 -2
- package/bin/lib/procRegistry.mjs +28 -18
- package/bin/lib/procScan.mjs +146 -0
- package/bin/lib/processes.mjs +29 -37
- package/bin/lib/projectToolRuntimes.mjs +138 -0
- package/bin/lib/projectTools.mjs +13 -88
- package/bin/lib/prompts.mjs +28 -42
- package/bin/lib/repoPath.mjs +26 -0
- package/bin/lib/resources.mjs +7 -69
- package/bin/lib/resumeLost.mjs +107 -0
- package/bin/lib/runTurn.mjs +324 -0
- package/bin/lib/runtimeAntigravity.mjs +230 -0
- package/bin/lib/runtimeCapabilities.mjs +334 -0
- package/bin/lib/runtimeClaude.mjs +126 -0
- package/bin/lib/runtimeCodex.mjs +217 -0
- package/bin/lib/runtimeDetection.mjs +121 -0
- package/bin/lib/runtimeEvents.mjs +482 -0
- package/bin/lib/runtimeOfferings.mjs +4 -1
- package/bin/lib/runtimes.mjs +42 -1402
- package/bin/lib/safeFileName.mjs +74 -0
- package/bin/lib/shipMerge.mjs +33 -2
- package/bin/lib/standDownExit.mjs +181 -0
- package/bin/lib/standDownGate.mjs +48 -0
- package/bin/lib/startProject.mjs +279 -0
- package/bin/lib/trace.mjs +3 -3
- package/bin/lib/turnLock.mjs +68 -0
- package/bin/lib/turnProfile.mjs +77 -0
- package/bin/lib/uninstall.mjs +55 -45
- package/bin/lib/uninstallCopies.mjs +112 -0
- package/bin/lib/update.mjs +30 -17
- package/bin/lib/uplinkScrub.mjs +265 -0
- package/bin/lib/vault.mjs +22 -151
- package/bin/lib/vaultDiff.mjs +183 -0
- package/bin/lib/versionOrder.mjs +42 -0
- package/bin/lib/views.mjs +35 -12
- package/bin/lib/wikiRunner.mjs +630 -0
- package/bin/lib/work.mjs +188 -3037
- package/bin/lib/workAdoptCarry.mjs +94 -0
- package/bin/lib/workAgentCheck.mjs +278 -0
- package/bin/lib/workAgentMerges.mjs +54 -142
- package/bin/lib/workAgentPlans.mjs +14 -12
- package/bin/lib/workAgentPrecheck.mjs +393 -0
- package/bin/lib/workAgentPublish.mjs +177 -0
- package/bin/lib/workAgentReview.mjs +8 -666
- package/bin/lib/workAgentTurnBegun.mjs +109 -0
- package/bin/lib/workAgentTurnExecution.mjs +604 -0
- package/bin/lib/workAgentTurnOutcome.mjs +207 -0
- package/bin/lib/workAgentTurnReports.mjs +189 -0
- package/bin/lib/workAgentTurns.mjs +92 -897
- package/bin/lib/workAttachments.mjs +93 -0
- package/bin/lib/workBrain.mjs +53 -0
- package/bin/lib/workChildren.mjs +110 -0
- package/bin/lib/workCommandAudit.mjs +62 -0
- package/bin/lib/workDiffs.mjs +2 -1
- package/bin/lib/workNarration.mjs +152 -0
- package/bin/lib/workPlaceReports.mjs +426 -0
- package/bin/lib/workPlaces.mjs +228 -0
- package/bin/lib/workPreviews.mjs +31 -9
- package/bin/lib/workProcesses.mjs +4 -5
- package/bin/lib/workPullRequests.mjs +52 -126
- package/bin/lib/workReportQueue.mjs +254 -0
- package/bin/lib/workRetire.mjs +185 -0
- package/bin/lib/workSessionGroups.mjs +166 -0
- package/bin/lib/workSessionRuntime.mjs +164 -0
- package/bin/lib/workSessionTokens.mjs +68 -0
- package/bin/lib/workSessionTurns.mjs +892 -0
- package/bin/lib/workShip.mjs +9 -29
- package/bin/lib/workToolLog.mjs +76 -0
- package/package.json +3 -2
- package/bin/lib/patch.mjs +0 -363
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE PREVIEW TUNNEL REGISTRY — which cloudflared groups this box started, so
|
|
3
|
+
* a daemon that died ungracefully has its public tunnels reaped by the next.
|
|
4
|
+
*
|
|
5
|
+
* cloudflared is detached so we can kill its whole group — which also means it
|
|
6
|
+
* SURVIVES an ungraceful daemon death (SIGKILL, crash, box sleep), leaving a
|
|
7
|
+
* public hostname pointed at a worktree with nobody minding it. We record each
|
|
8
|
+
* group's pid + a signature and reap ours at the next start.
|
|
9
|
+
*
|
|
10
|
+
* The registry is a read-modify-write over one file in a directory that TWO
|
|
11
|
+
* daemons can legitimately share (the 0.51.2 instance lock is keyed on a
|
|
12
|
+
* CREDENTIAL, so two daemons serving two different projects are fine and both
|
|
13
|
+
* write here). It was unlocked, written back when previews were serial. A lost
|
|
14
|
+
* entry is precisely the case reaping exists for.
|
|
15
|
+
*
|
|
16
|
+
* ── ONE REGISTRY IMPLEMENTATION, NOT TWO (SOLID audit 2026-09-26, F006) ──
|
|
17
|
+
*
|
|
18
|
+
* `procRegistry.mjs` was lifted out of `preview.mjs` for the dev-run system and
|
|
19
|
+
* then the session process groups, and preview kept its ORIGINAL copy of the
|
|
20
|
+
* lock, the atomic read/write and the process start-time reader — which had
|
|
21
|
+
* drifted: no bound on the file at all, and a start time with no boot mark.
|
|
22
|
+
* This file is preview's POLICY over procRegistry's PRIMITIVES:
|
|
23
|
+
*
|
|
24
|
+
* - THE LOCK AND THE ATOMIC WRITE are procRegistry's, same paths and same
|
|
25
|
+
* stale-lock recovery as before.
|
|
26
|
+
* - THE PRUNE IS PREVIEW'S OWN and it NEVER DISCARDS AN UNREAPED TUNNEL.
|
|
27
|
+
* procRegistry's default prune caps the file at 32 rows and ages out dead
|
|
28
|
+
* ones after a week; a cap would drop a live public tunnel's only record
|
|
29
|
+
* the day a box had 33 of them. So a row survives exactly while its tunnel
|
|
30
|
+
* pid is alive (signal 0, EPERM counting as alive) — a dead tunnel has
|
|
31
|
+
* nothing left to reap, and the reap pass itself drops those anyway. The
|
|
32
|
+
* file is bounded by the tunnels that exist.
|
|
33
|
+
* - ONE PROCESS IDENTITY: procRegistry's `processStartTime`, which carries the
|
|
34
|
+
* boot mark, in a NEW field, `ownerMark`, which this code prefers.
|
|
35
|
+
*
|
|
36
|
+
* ── THE FILE IS SHARED ACROSS DAEMON VERSIONS, SO THE OLD FIELD KEEPS ITS
|
|
37
|
+
* OLD SPELLING ──
|
|
38
|
+
*
|
|
39
|
+
* The daemon is the one component a deploy cannot upgrade, and previews.json
|
|
40
|
+
* is shared by every daemon on the box: two projects, the tray's bundled WSL
|
|
41
|
+
* daemon beside a curl install, a self-update window. A published daemon
|
|
42
|
+
* compares `ownerStart` EXACTLY against its own reading (`l:<ticks>` on Linux,
|
|
43
|
+
* `d:<lstart>` on macOS) and SIGKILLs the tunnel when they differ. The first
|
|
44
|
+
* cut of this move wrote the boot-marked reading INTO `ownerStart`, and an
|
|
45
|
+
* older daemon starting beside a live new one read every new row as a dead
|
|
46
|
+
* owner's and killed the peer's public tunnels (review 2026-09-26, reproduced
|
|
47
|
+
* against 0.103.0's reap). So a row carries BOTH: `ownerStart` in the legacy
|
|
48
|
+
* spelling, derived from the one reader, for published readers; `ownerMark`,
|
|
49
|
+
* the boot-marked reading, for this one. A row with no `ownerMark` (written by
|
|
50
|
+
* an older daemon) is judged on its legacy `ownerStart`, field for field, so a
|
|
51
|
+
* peer that recorded its tunnels before this change is not reaped either.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
import { readFileSync } from 'node:fs';
|
|
55
|
+
import { execFileSync } from 'node:child_process';
|
|
56
|
+
import { join } from 'node:path';
|
|
57
|
+
import { homedir, platform } from 'node:os';
|
|
58
|
+
import { mutateRegistry, processAlive, processStartTime, readRegistry } from './procRegistry.mjs';
|
|
59
|
+
|
|
60
|
+
const FLOWVIANT_DIR = join(homedir(), '.flowviant');
|
|
61
|
+
const PREVIEW_REGISTRY = join(FLOWVIANT_DIR, 'previews.json');
|
|
62
|
+
const REGISTRY_LOCK = join(FLOWVIANT_DIR, 'previews.lock');
|
|
63
|
+
|
|
64
|
+
/** A row lives while its tunnel does, uncapped — see the header. */
|
|
65
|
+
export const keepLiveTunnels = (list) => list.filter((e) => processAlive(e?.pid));
|
|
66
|
+
|
|
67
|
+
function mutatePreviews(fn) {
|
|
68
|
+
mutateRegistry(FLOWVIANT_DIR, PREVIEW_REGISTRY, REGISTRY_LOCK, fn, { prune: keepLiveTunnels });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The legacy spelling of a `processStartTime` reading, for `ownerStart` — the
|
|
73
|
+
* field published daemons compare exactly (see the header). Linux readings are
|
|
74
|
+
* `<boot mark>:<ticks>` and published readers wrote `l:<ticks>`; macOS readings
|
|
75
|
+
* are the raw `lstart` and published readers wrote `d:<lstart>`. Null where a
|
|
76
|
+
* published reader could not read a start time either.
|
|
77
|
+
*/
|
|
78
|
+
export function legacyOwnerStart(reading, os = platform()) {
|
|
79
|
+
if (typeof reading !== 'string' || !reading) return null;
|
|
80
|
+
if (os === 'linux') return `l:${reading.slice(reading.lastIndexOf(':') + 1)}`;
|
|
81
|
+
if (os === 'darwin') return `d:${reading}`;
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Does `recorded` (an entry's `ownerMark`, else its `ownerStart`) name the
|
|
87
|
+
* process that holds `pid` NOW? `true`/`false`, or `null` when this box cannot
|
|
88
|
+
* read a start time — the caller's "cannot tell", never a mismatch.
|
|
89
|
+
*
|
|
90
|
+
* `ownerMark` is procRegistry's reading verbatim. A legacy `ownerStart` (the
|
|
91
|
+
* only mark on a row an older daemon wrote) is compared field for field:
|
|
92
|
+
* `l:<ticks>` against the ticks after the boot mark, `d:<lstart>` against the
|
|
93
|
+
* macOS reading, which never had a prefix in procRegistry.
|
|
94
|
+
*/
|
|
95
|
+
export function sameOwnerStart(recorded, pid) {
|
|
96
|
+
const now = processStartTime(pid);
|
|
97
|
+
if (now == null) return null;
|
|
98
|
+
if (recorded.startsWith('l:')) return now.slice(now.lastIndexOf(':') + 1) === recorded.slice(2);
|
|
99
|
+
if (recorded.startsWith('d:')) return now === recorded.slice(2);
|
|
100
|
+
return now === recorded;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function recordPreviewPid(pid, sig) {
|
|
104
|
+
if (!pid) return;
|
|
105
|
+
// `owner` is the DAEMON that spawned it. The registry is shared by design —
|
|
106
|
+
// two daemons serving two projects both write here — so without an owner a
|
|
107
|
+
// starting daemon reaped its PEER's live tunnels: killed them, wiped their
|
|
108
|
+
// entries, and the peer kept heartbeating a URL that 530s (its probe watches
|
|
109
|
+
// the origin port, which was still alive).
|
|
110
|
+
//
|
|
111
|
+
// `ownerStart` pins the owner to ONE process (audit 2026-09-24). A daemon that
|
|
112
|
+
// is SIGKILLed with a share open leaves cloudflared up; if its pid is reused
|
|
113
|
+
// by the next start — any process, another user's included, since EPERM
|
|
114
|
+
// counts as alive — a pid-only check read the entry as a live peer's and
|
|
115
|
+
// skipped it forever, leaving a public hostname aimed at a dead gate's
|
|
116
|
+
// ephemeral port for anything that binds it next.
|
|
117
|
+
//
|
|
118
|
+
// `ownerMark` is that pin in the one identity reader's spelling; `ownerStart`
|
|
119
|
+
// keeps the spelling published daemons compare — see the header.
|
|
120
|
+
const ownerMark = processStartTime(process.pid);
|
|
121
|
+
const ownerStart = legacyOwnerStart(ownerMark);
|
|
122
|
+
mutatePreviews((list) => [
|
|
123
|
+
...list,
|
|
124
|
+
{ pid, sig, owner: process.pid, ...(ownerStart ? { ownerStart } : {}), ...(ownerMark ? { ownerMark } : {}) },
|
|
125
|
+
]);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function forgetPreviewPid(pid) {
|
|
129
|
+
if (!pid) return;
|
|
130
|
+
mutatePreviews((list) => list.filter((e) => e.pid !== pid));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Only kill a pid we can VERIFY is still one of ours — its command line must
|
|
135
|
+
* still contain the signature we stored. A reused pid belonging to something
|
|
136
|
+
* unrelated won't match, so we never kill a stranger.
|
|
137
|
+
*
|
|
138
|
+
* MACOS READS IT THROUGH `ps`, because "Linux-only, elsewhere just clear the
|
|
139
|
+
* registry" was the worst of both. A macOS daemon killed ungracefully leaves
|
|
140
|
+
* cloudflared running — it is spawned detached — so the public hostname keeps
|
|
141
|
+
* resolving to this machine while the gate dies with the daemon. The reaper
|
|
142
|
+
* then verified nothing, killed nothing, and DELETED the entry, so no later run
|
|
143
|
+
* could ever find that process. The tunnel served 502 until anything else on
|
|
144
|
+
* the box bound the gate's old port, which `startAuthProxy` obtained with
|
|
145
|
+
* `listen(0)` and is therefore squarely inside the kernel's ephemeral reuse
|
|
146
|
+
* pool — at which point a live public hostname forwarded straight to an
|
|
147
|
+
* unrelated local service with no gate, no password and no grant check. Exactly
|
|
148
|
+
* what this file's header says the reap exists to prevent.
|
|
149
|
+
*
|
|
150
|
+
* Three states, not two: `true` (ours), `false` (verified NOT ours, or gone),
|
|
151
|
+
* and `null` (we could not look — the caller keeps the record rather than
|
|
152
|
+
* dropping it).
|
|
153
|
+
*/
|
|
154
|
+
function stillOurs(pid, sig) {
|
|
155
|
+
if (typeof sig !== 'string' || sig.length === 0) return false;
|
|
156
|
+
if (platform() === 'linux') {
|
|
157
|
+
try {
|
|
158
|
+
const cmd = readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ');
|
|
159
|
+
return cmd.includes(sig);
|
|
160
|
+
} catch {
|
|
161
|
+
return false; // process gone / unreadable
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (platform() === 'darwin') {
|
|
165
|
+
try {
|
|
166
|
+
const cmd = execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
|
|
167
|
+
encoding: 'utf8',
|
|
168
|
+
timeout: 5_000,
|
|
169
|
+
});
|
|
170
|
+
return cmd.includes(sig);
|
|
171
|
+
} catch {
|
|
172
|
+
// `ps` exits non-zero when the pid is gone — which is a real answer.
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// Windows: no way to check from here. UNKNOWN, never "not ours" — see the
|
|
177
|
+
// caller, which keeps the record so a later run on a platform that can look
|
|
178
|
+
// is still able to.
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Reap tunnel process groups left behind by a previously-crashed daemon.
|
|
183
|
+
* Call once at daemon startup, before any work begins.
|
|
184
|
+
*
|
|
185
|
+
* ORPHANS ONLY: an entry whose owning daemon is STILL ALIVE belongs to a
|
|
186
|
+
* peer serving another project (or to the process we are replacing, whose
|
|
187
|
+
* own teardown handles it) — killing those and wiping their entries was a
|
|
188
|
+
* peer daemon's startup silently breaking every live share on the box. Only
|
|
189
|
+
* the entries this pass handled are removed; a peer's records survive. */
|
|
190
|
+
/** Is the daemon that recorded an entry still the process at that pid? With
|
|
191
|
+
* a recorded start time, only when the pid's start time still matches (a
|
|
192
|
+
* recycled pid is an orphaned entry); where this box cannot report a start
|
|
193
|
+
* time now, or the entry predates the field, signal 0 is all there is. */
|
|
194
|
+
function ownerStillRunning(owner, ownerStart, ownerMark) {
|
|
195
|
+
if (!processAlive(owner)) return false;
|
|
196
|
+
const recorded = typeof ownerMark === 'string' ? ownerMark : ownerStart;
|
|
197
|
+
if (typeof recorded !== 'string') return true;
|
|
198
|
+
return sameOwnerStart(recorded, owner) !== false;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function reapOrphanPreviews(log) {
|
|
202
|
+
const list = readRegistry(PREVIEW_REGISTRY);
|
|
203
|
+
if (list.length === 0) return;
|
|
204
|
+
let killed = 0;
|
|
205
|
+
const handled = new Set();
|
|
206
|
+
for (const { pid, sig, owner, ownerStart, ownerMark } of list) {
|
|
207
|
+
if (Number.isInteger(owner) && owner !== process.pid && ownerStillRunning(owner, ownerStart, ownerMark)) continue;
|
|
208
|
+
const ours = stillOurs(pid, sig);
|
|
209
|
+
/**
|
|
210
|
+
* THE RECORD OUTLIVES A REAP THAT COULD NOT LOOK. `handled.add` ran BEFORE
|
|
211
|
+
* this check, so an entry was dropped whether or not anything was killed —
|
|
212
|
+
* and on any platform `stillOurs` could not read, that deleted the only
|
|
213
|
+
* trace of a tunnel still serving the public internet. Kept on `null`
|
|
214
|
+
* (unknown); removed on `true` (we killed it) and on `false` (the process
|
|
215
|
+
* is gone, or the pid now belongs to somebody else and the entry is stale
|
|
216
|
+
* either way).
|
|
217
|
+
*/
|
|
218
|
+
if (ours === null) continue;
|
|
219
|
+
handled.add(pid);
|
|
220
|
+
if (!ours) continue;
|
|
221
|
+
try {
|
|
222
|
+
process.kill(-pid, 'SIGKILL'); // whole group
|
|
223
|
+
killed++;
|
|
224
|
+
} catch {
|
|
225
|
+
try {
|
|
226
|
+
process.kill(pid, 'SIGKILL');
|
|
227
|
+
killed++;
|
|
228
|
+
} catch {
|
|
229
|
+
/* already gone */
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (handled.size) mutatePreviews((cur) => cur.filter((e) => !handled.has(e.pid)));
|
|
234
|
+
if (killed) log?.(`reaped ${killed} orphaned preview tunnel${killed === 1 ? '' : 's'} from a previous run.`);
|
|
235
|
+
}
|
package/bin/lib/printable.mjs
CHANGED
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
* `eviltxt.crt` reads backwards on a bidi-aware terminal, so
|
|
12
12
|
* "evil" can visually swap places with an extension that was never there.
|
|
13
13
|
*
|
|
14
|
-
* THIS IS A LEAF MODULE ON PURPOSE. `
|
|
15
|
-
* it, `
|
|
14
|
+
* THIS IS A LEAF MODULE ON PURPOSE. `machineListing.mjs` and the daemon's
|
|
15
|
+
* holder/roster modules (`holder.mjs`, `fleetRoster.mjs`) all need it, `machineListing.mjs` deliberately imports nothing from `fleet.mjs`'s world
|
|
16
16
|
* (its own header explains why: it runs before the auth gate, like `stop` and
|
|
17
17
|
* `projects`, and must not pull in the daemon's whole CLI/worktree
|
|
18
18
|
* machinery), and `credentials.mjs` is where `safeName` already lived. A leaf
|
package/bin/lib/procRegistry.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* A DISK REGISTRY OF DETACHED PROCESSES — one implementation,
|
|
2
|
+
* A DISK REGISTRY OF DETACHED PROCESSES — one implementation, every user.
|
|
3
3
|
*
|
|
4
4
|
* The daemon spawns things that outlive it: cloudflared tunnels, and now dev
|
|
5
5
|
* servers. If the daemon dies without tearing them down, the successor has to
|
|
@@ -7,10 +7,11 @@
|
|
|
7
7
|
* in a worktree about to be `git worktree remove`d, are both worse than the
|
|
8
8
|
* crash that caused them.
|
|
9
9
|
*
|
|
10
|
-
* Lifted verbatim out of `preview.mjs`, which grew all of this for the tunnel
|
|
11
|
-
* and
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* Lifted verbatim out of `preview.mjs`, which grew all of this for the tunnel —
|
|
11
|
+
* and which kept its own drifted copy until 2026-09-26 (F006); the tunnel
|
|
12
|
+
* registry is now `previewRegistry.mjs`, a policy over these primitives. Two
|
|
13
|
+
* things were ADDED on the way out, because a dev server lives for hours where
|
|
14
|
+
* a tunnel lived for minutes and long-lived rows make both matter:
|
|
14
15
|
*
|
|
15
16
|
* - an entry CAP, so a registry cannot grow without bound;
|
|
16
17
|
* - a TTL sweep for entries whose pid is long dead, so a file nobody prunes
|
|
@@ -171,26 +172,35 @@ export function processAlive(pid) {
|
|
|
171
172
|
}
|
|
172
173
|
}
|
|
173
174
|
|
|
175
|
+
/**
|
|
176
|
+
* THE DEFAULT PRUNE: a row whose pid is alive, or dead for less than a week,
|
|
177
|
+
* capped at the newest `MAX_ENTRIES`. An entry whose pid has been dead for a
|
|
178
|
+
* week is not a process anybody is going to reap, and keeping it only makes
|
|
179
|
+
* the next reader slower and the next pid collision more likely.
|
|
180
|
+
*/
|
|
181
|
+
export function pruneAgedDead(list, now = Date.now()) {
|
|
182
|
+
return list
|
|
183
|
+
.filter((e) => {
|
|
184
|
+
if (processAlive(e?.pid)) return true;
|
|
185
|
+
const started = Number(e?.startedAt ?? 0);
|
|
186
|
+
return started > 0 && now - started < ENTRY_TTL_MS;
|
|
187
|
+
})
|
|
188
|
+
.slice(-MAX_ENTRIES);
|
|
189
|
+
}
|
|
190
|
+
|
|
174
191
|
/**
|
|
175
192
|
* Read-modify-write under the lock, then prune.
|
|
176
193
|
*
|
|
177
194
|
* The prune is here rather than at the call sites so it cannot be forgotten by
|
|
178
|
-
* one of them
|
|
179
|
-
*
|
|
180
|
-
*
|
|
195
|
+
* one of them. A registry whose rows must never be dropped on a count — the
|
|
196
|
+
* preview tunnels, where the only record of a live public hostname cannot be
|
|
197
|
+
* the 33rd row — passes its own `prune` and states its bound there
|
|
198
|
+
* (`previewRegistry.mjs`); everything else takes `pruneAgedDead`.
|
|
181
199
|
*/
|
|
182
|
-
export function mutateRegistry(dir, path, lockPath, fn) {
|
|
200
|
+
export function mutateRegistry(dir, path, lockPath, fn, { prune = pruneAgedDead } = {}) {
|
|
183
201
|
const release = acquireLock(dir, lockPath);
|
|
184
202
|
try {
|
|
185
|
-
const
|
|
186
|
-
const now = Date.now();
|
|
187
|
-
const pruned = next
|
|
188
|
-
.filter((e) => {
|
|
189
|
-
if (processAlive(e?.pid)) return true;
|
|
190
|
-
const started = Number(e?.startedAt ?? 0);
|
|
191
|
-
return started > 0 && now - started < ENTRY_TTL_MS;
|
|
192
|
-
})
|
|
193
|
-
.slice(-MAX_ENTRIES);
|
|
203
|
+
const pruned = prune(fn(readRegistry(path)));
|
|
194
204
|
writeRegistry(dir, path, pruned);
|
|
195
205
|
return pruned;
|
|
196
206
|
} finally {
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WALKING /proc — the one bounded enumeration every process scan shares, and
|
|
3
|
+
* the one "which processes stand in this directory, holding which sockets"
|
|
4
|
+
* attribution the listener measurements share.
|
|
5
|
+
*
|
|
6
|
+
* ── WHY IT IS ONE FILE (SOLID audit 2026-09-26, F007) ──
|
|
7
|
+
*
|
|
8
|
+
* Three scans spelled the runaway bound three ways. `processes.mjs` had
|
|
9
|
+
* already been fixed to bound the WORK — stop after 4000 matching rows — after
|
|
10
|
+
* it lost a tab's watcher: readdir answers in STRING order, so cutting the pid
|
|
11
|
+
* LIST kept '1', '10', '100'… and dropped pid 912345. `listeners.mjs` still
|
|
12
|
+
* cut the list, twice (the inventory and the dial-address check), so on a busy
|
|
13
|
+
* box a dev server at a high pid was reported ABSENT by both — "nothing is
|
|
14
|
+
* listening" about a socket the kernel's own table held. The rule now has one
|
|
15
|
+
* home: EVERY pid is looked at, and the bound is on the rows a scan collects.
|
|
16
|
+
*
|
|
17
|
+
* WHAT THAT COSTS, said plainly (review 2026-09-26). The old slice bounded the
|
|
18
|
+
* WALK at 4000 pids; the bound here counts MATCHED rows (for the listener
|
|
19
|
+
* scans, processes standing in the worktree), so on a box of N processes one
|
|
20
|
+
* scan does N cwd readlinks, and a reconcile runs two per worktree (inventory
|
|
21
|
+
* and dial check). That is the price of never measuring a high pid as absent,
|
|
22
|
+
* and it is a readlink each — the fd walk, the expensive part, is paid only
|
|
23
|
+
* by processes inside the directory. A bound on pids VISITED would bring the
|
|
24
|
+
* dropped-listener bug straight back, so there is none.
|
|
25
|
+
*
|
|
26
|
+
* ── AND A CUT IS SAID ──
|
|
27
|
+
*
|
|
28
|
+
* `scanProcs` answers `complete: false` when it stopped with pids unread, so a
|
|
29
|
+
* caller can refuse to turn "not looked at" into "not there" — the three-state
|
|
30
|
+
* rule the rest of this product keeps. A /proc that cannot be listed at all is
|
|
31
|
+
* `null` from `procPids`, which is "cannot look", a third answer again.
|
|
32
|
+
*
|
|
33
|
+
* Linux only by construction; callers keep their own macOS paths.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { readdirSync, readlinkSync, realpathSync } from 'node:fs';
|
|
37
|
+
import { sep } from 'node:path';
|
|
38
|
+
|
|
39
|
+
/** Rows collected per scan before the walk stops early — a bound on the ROWS
|
|
40
|
+
* held (and the per-row work: an fd walk, a stat read) for one pathological
|
|
41
|
+
* box, never on which part of /proc is looked at. Every pid still costs one
|
|
42
|
+
* cheap filter read (see the header). The runaway bound, not a capacity
|
|
43
|
+
* statement. */
|
|
44
|
+
export const SCAN_MATCH_BOUND = 4000;
|
|
45
|
+
|
|
46
|
+
/** Every numeric entry of /proc, or null when /proc cannot be listed. */
|
|
47
|
+
export function procPids(list = () => readdirSync('/proc')) {
|
|
48
|
+
try {
|
|
49
|
+
return list().filter((d) => /^\d+$/.test(d));
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Walk `pids` in order; `match(raw)` returns a row or null. Stops once `bound`
|
|
57
|
+
* rows are held AND a pid is still unread, answering `complete: false`.
|
|
58
|
+
*/
|
|
59
|
+
export function scanProcs(pids, match, bound = SCAN_MATCH_BOUND) {
|
|
60
|
+
const rows = [];
|
|
61
|
+
for (const raw of pids) {
|
|
62
|
+
if (rows.length >= bound) return { rows, complete: false };
|
|
63
|
+
const row = match(raw);
|
|
64
|
+
if (row != null) rows.push(row);
|
|
65
|
+
}
|
|
66
|
+
return { rows, complete: true };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A predicate for "this path is `dir` or inside it", on the RESOLVED path —
|
|
70
|
+
* or null when `dir` is gone (retired under us). */
|
|
71
|
+
export function insideDir(dir) {
|
|
72
|
+
let root;
|
|
73
|
+
try {
|
|
74
|
+
root = realpathSync(dir);
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
const prefix = root.endsWith(sep) ? root : root + sep;
|
|
79
|
+
return (p) => p === root || p.startsWith(prefix);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function cwdOf(raw) {
|
|
83
|
+
try {
|
|
84
|
+
return readlinkSync(`/proc/${raw}/cwd`);
|
|
85
|
+
} catch {
|
|
86
|
+
return null; // not ours to read, or gone
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The socket inodes one process holds open, or null when its fd table is
|
|
91
|
+
* unreadable. */
|
|
92
|
+
function socketInodesOf(raw) {
|
|
93
|
+
let fds;
|
|
94
|
+
try {
|
|
95
|
+
fds = readdirSync(`/proc/${raw}/fd`);
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
const out = [];
|
|
100
|
+
for (const fd of fds) {
|
|
101
|
+
let link;
|
|
102
|
+
try {
|
|
103
|
+
link = readlinkSync(`/proc/${raw}/fd/${fd}`);
|
|
104
|
+
} catch {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const m = /^socket:\[(\d+)\]$/.exec(link);
|
|
108
|
+
if (m) out.push(m[1]);
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* EVERY PROCESS STANDING IN `dir`, with the socket inodes it holds —
|
|
115
|
+
* `{ holders: [{ pid, inodes }], complete }` in /proc order.
|
|
116
|
+
*
|
|
117
|
+
* Attribution is by the CWD of the process, the rule `listeners.mjs` argues
|
|
118
|
+
* for. The cheap filter runs first — one readlink rejects almost every process
|
|
119
|
+
* on the box, and only processes inside `dir` pay for a walk of their fd
|
|
120
|
+
* table — and those are the rows the bound counts. `skip(pid)` drops a process
|
|
121
|
+
* that is Flowviant itself (the daemon, its tunnels) before its fds are read.
|
|
122
|
+
*
|
|
123
|
+
* `{ holders: [], complete: true }` when `dir` is gone; null when /proc cannot
|
|
124
|
+
* be listed. `io` exists ONLY so a test can hand in a pid list (and a bound)
|
|
125
|
+
* without a box that runs four thousand processes; nothing in the daemon
|
|
126
|
+
* passes it.
|
|
127
|
+
*/
|
|
128
|
+
export function socketHoldersIn(dir, { skip = () => false, io = {} } = {}) {
|
|
129
|
+
const inside = insideDir(dir);
|
|
130
|
+
if (!inside) return { holders: [], complete: true };
|
|
131
|
+
const pids = procPids(io.list);
|
|
132
|
+
if (pids === null) return null;
|
|
133
|
+
const { rows, complete } = scanProcs(
|
|
134
|
+
pids,
|
|
135
|
+
(raw) => {
|
|
136
|
+
const cwd = cwdOf(raw);
|
|
137
|
+
if (cwd === null || !inside(cwd)) return null;
|
|
138
|
+
const pid = Number(raw);
|
|
139
|
+
if (skip(pid)) return null;
|
|
140
|
+
const inodes = socketInodesOf(raw);
|
|
141
|
+
return inodes === null ? null : { pid, inodes };
|
|
142
|
+
},
|
|
143
|
+
io.bound
|
|
144
|
+
);
|
|
145
|
+
return { holders: rows, complete };
|
|
146
|
+
}
|
package/bin/lib/processes.mjs
CHANGED
|
@@ -44,17 +44,18 @@
|
|
|
44
44
|
*/
|
|
45
45
|
|
|
46
46
|
import { execFileSync } from 'node:child_process';
|
|
47
|
-
import { readFileSync
|
|
47
|
+
import { readFileSync } from 'node:fs';
|
|
48
48
|
import { platform } from 'node:os';
|
|
49
|
+
import { procPids, scanProcs } from './procScan.mjs';
|
|
49
50
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
51
|
+
// THE WALK'S BOUND is `procScan.mjs`'s: matched rows collected before the walk
|
|
52
|
+
// stops early — a bound on the WORK done for one pathological group, never on
|
|
53
|
+
// which part of /proc is looked at. It used to cut the pid LIST before
|
|
54
|
+
// filtering, and readdir answers in string order, so a busy box kept '1',
|
|
55
|
+
// '10', '100'… and dropped a tab's watcher at pid 912345 while `liveGroups`
|
|
56
|
+
// (which has no cap) still called its group alive: the tab read "looked,
|
|
57
|
+
// found none" and a Stop on it answered not_found. The listener scans made
|
|
58
|
+
// the same mistake after this one was fixed, which is why the rule moved.
|
|
58
59
|
/** Rows reported per session. A watcher, a dev server and its child is three;
|
|
59
60
|
* twenty is somebody's compose stack and the extra rows say nothing. */
|
|
60
61
|
export const MAX_PROCS = 12;
|
|
@@ -123,31 +124,26 @@ function cmdlineOf(pid) {
|
|
|
123
124
|
* pinned without a box that runs four thousand processes. Nothing in the
|
|
124
125
|
* daemon passes `io`. */
|
|
125
126
|
export function scanLinux(pgids, io = {}) {
|
|
126
|
-
const list = io.list ?? (() => readdirSync('/proc'));
|
|
127
127
|
const pgrpFor = io.pgrpOf ?? pgrpOf;
|
|
128
128
|
const cmdFor = io.cmdlineOf ?? cmdlineOf;
|
|
129
129
|
const rssFor = io.rssBytes ?? rssBytes;
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
const rss = rssFor(raw);
|
|
148
|
-
out.push({ pid, pgid: pgrp, cmd, ...(rss != null ? { rss } : {}) });
|
|
149
|
-
}
|
|
150
|
-
return out;
|
|
130
|
+
const pids = procPids(io.list);
|
|
131
|
+
if (pids === null) return [];
|
|
132
|
+
return scanProcs(
|
|
133
|
+
pids,
|
|
134
|
+
(raw) => {
|
|
135
|
+
const pid = Number(raw);
|
|
136
|
+
const pgrp = pgrpFor(raw);
|
|
137
|
+
if (pgrp === null || !pgids.has(pgrp)) return null;
|
|
138
|
+
// The CLI itself — its argv is the prompt. See the header.
|
|
139
|
+
if (pid === pgrp) return null;
|
|
140
|
+
const cmd = cmdFor(raw);
|
|
141
|
+
if (!cmd) return null;
|
|
142
|
+
const rss = rssFor(raw);
|
|
143
|
+
return { pid, pgid: pgrp, cmd, ...(rss != null ? { rss } : {}) };
|
|
144
|
+
},
|
|
145
|
+
io.bound
|
|
146
|
+
).rows;
|
|
151
147
|
}
|
|
152
148
|
|
|
153
149
|
function scanDarwin(pgids) {
|
|
@@ -242,12 +238,8 @@ export function liveGroups(pgids) {
|
|
|
242
238
|
}
|
|
243
239
|
return alive;
|
|
244
240
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
pids = readdirSync('/proc').filter((d) => /^\d+$/.test(d));
|
|
248
|
-
} catch {
|
|
249
|
-
return want; // cannot tell — keep what we had rather than forget a live tab
|
|
250
|
-
}
|
|
241
|
+
const pids = procPids();
|
|
242
|
+
if (pids === null) return want; // cannot tell — keep what we had rather than forget a live tab
|
|
251
243
|
for (const raw of pids) {
|
|
252
244
|
const pgrp = pgrpOf(raw);
|
|
253
245
|
if (pgrp !== null && want.has(pgrp)) alive.add(pgrp);
|