flowviant 0.97.0 → 0.99.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 +13 -3
- package/bin/lib/agentPlan.mjs +159 -37
- package/bin/lib/artifacts.mjs +136 -26
- package/bin/lib/authReject.mjs +32 -0
- package/bin/lib/authproxy.mjs +52 -16
- package/bin/lib/childEnv.mjs +33 -1
- package/bin/lib/claude.mjs +166 -71
- package/bin/lib/config.mjs +24 -1
- package/bin/lib/credentials.mjs +27 -4
- package/bin/lib/deploy.mjs +364 -30
- package/bin/lib/env.mjs +130 -13
- package/bin/lib/fleet.mjs +99 -28
- package/bin/lib/git.mjs +131 -3
- package/bin/lib/hooks/readGuard.mjs +24 -0
- package/bin/lib/instance.mjs +115 -14
- package/bin/lib/knowledge.mjs +180 -60
- package/bin/lib/landed.mjs +38 -30
- package/bin/lib/listeners.mjs +211 -0
- package/bin/lib/localSessions.mjs +29 -4
- package/bin/lib/login.mjs +2 -2
- package/bin/lib/machines.mjs +16 -7
- package/bin/lib/preview.mjs +95 -6
- package/bin/lib/printable.mjs +42 -0
- package/bin/lib/procRegistry.mjs +65 -0
- package/bin/lib/processes.mjs +20 -7
- package/bin/lib/projectTools.mjs +248 -0
- package/bin/lib/prompts.mjs +16 -11
- package/bin/lib/resources.mjs +46 -4
- package/bin/lib/runtimeOfferings.mjs +75 -0
- package/bin/lib/runtimes.mjs +71 -9
- package/bin/lib/shipMerge.mjs +8 -4
- package/bin/lib/trace.mjs +16 -6
- package/bin/lib/update.mjs +5 -0
- package/bin/lib/work.mjs +1192 -4584
- package/bin/lib/workAgentMerges.mjs +504 -0
- package/bin/lib/workAgentPlans.mjs +447 -0
- package/bin/lib/workAgentReview.mjs +702 -0
- package/bin/lib/workAgentTurns.mjs +1148 -0
- package/bin/lib/workDiffs.mjs +107 -0
- package/bin/lib/workPreviews.mjs +351 -0
- package/bin/lib/workProcesses.mjs +236 -0
- package/bin/lib/workPullRequests.mjs +313 -0
- package/bin/lib/workShip.mjs +385 -0
- package/bin/lib/worktreeDiff.mjs +99 -43
- package/package.json +2 -2
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/** Commit diff requests and their bounded reports. */
|
|
2
|
+
import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
|
|
3
|
+
import { git } from './git.mjs';
|
|
4
|
+
|
|
5
|
+
export function createWorkDiffs({ repoRoot }) {
|
|
6
|
+
const DIFF_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/diff-done');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* COMMIT DIFFS, on request — the one PULL-shaped thing this daemon does.
|
|
10
|
+
*
|
|
11
|
+
* Everything else here is a push: the machine knows something and says it.
|
|
12
|
+
* A patch cannot work that way, because most are never opened and pushing
|
|
13
|
+
* every one would be storage and bandwidth for nothing. So the server leaves
|
|
14
|
+
* a job on the roster and this drains it.
|
|
15
|
+
*
|
|
16
|
+
* RUN FROM THE REPO ROOT, never a session worktree. A tab can be closed and
|
|
17
|
+
* its directory gone, but `session/<id>` outlives the tab and after a ship
|
|
18
|
+
* the commit is on main — the root checkout can see all of it, and a
|
|
19
|
+
* worktree can see only its own branch.
|
|
20
|
+
*
|
|
21
|
+
* Every failure is REPORTED rather than swallowed: "no such commit on this
|
|
22
|
+
* machine" is a real answer, and a viewer spinning forever is the worst thing
|
|
23
|
+
* this can do. The one thing never sent is a guess — an empty patch would
|
|
24
|
+
* tell a reader the commit changed nothing.
|
|
25
|
+
*/
|
|
26
|
+
const MAX_PATCH_BYTES = 256 * 1024;
|
|
27
|
+
const servedDiffs = new Set();
|
|
28
|
+
const postDiff = async (body) => {
|
|
29
|
+
try {
|
|
30
|
+
await fetch(DIFF_DONE_URL, {
|
|
31
|
+
method: 'POST',
|
|
32
|
+
headers: {
|
|
33
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
34
|
+
'User-Agent': USER_AGENT,
|
|
35
|
+
'Content-Type': 'application/json',
|
|
36
|
+
},
|
|
37
|
+
signal: AbortSignal.timeout(30_000),
|
|
38
|
+
body: JSON.stringify(body),
|
|
39
|
+
});
|
|
40
|
+
} catch {
|
|
41
|
+
/* the row stays pending and expires; the next click re-requests */
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
const processDiffJobs = (jobs) => {
|
|
45
|
+
if (!Array.isArray(jobs) || jobs.length === 0) return;
|
|
46
|
+
for (const job of jobs.slice(0, 5)) {
|
|
47
|
+
const sha = String(job?.sha || '').toLowerCase();
|
|
48
|
+
// A sha becomes a `git` argument. The server validates too; one place
|
|
49
|
+
// doing this check is one deploy away from being zero places.
|
|
50
|
+
if (!/^[0-9a-f]{7,64}$/.test(sha)) continue;
|
|
51
|
+
// In-flight guard, not a cache: the server stops offering a sha the
|
|
52
|
+
// moment it settles, so this only stops the SAME poll's job being
|
|
53
|
+
// started twice while its `git show` is still running.
|
|
54
|
+
if (servedDiffs.has(sha)) continue;
|
|
55
|
+
servedDiffs.add(sha);
|
|
56
|
+
void (async () => {
|
|
57
|
+
try {
|
|
58
|
+
let patch = '';
|
|
59
|
+
const files = [];
|
|
60
|
+
try {
|
|
61
|
+
// `--format=` so the body is pure diff: the subject, author and
|
|
62
|
+
// date already reached the card on the worktree sweep, and
|
|
63
|
+
// repeating them inside the patch would put a second copy above
|
|
64
|
+
// every hunk.
|
|
65
|
+
patch = git(['show', '--patch', '--format=', sha], repoRoot);
|
|
66
|
+
} catch (e) {
|
|
67
|
+
await postDiff({ sha, error: String(e?.message || e).slice(0, 500) });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const raw = git(['show', '--numstat', '--format=', sha], repoRoot);
|
|
72
|
+
for (const line of raw.split('\n')) {
|
|
73
|
+
if (!line.trim()) continue;
|
|
74
|
+
const [a, d, ...rest] = line.split('\t');
|
|
75
|
+
const path = rest.join('\t');
|
|
76
|
+
if (!path) continue;
|
|
77
|
+
const binary = a === '-' || d === '-';
|
|
78
|
+
files.push({
|
|
79
|
+
path: path.slice(0, 300),
|
|
80
|
+
added: binary ? 0 : Number(a) || 0,
|
|
81
|
+
deleted: binary ? 0 : Number(d) || 0,
|
|
82
|
+
...(binary ? { binary: true } : {}),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
/* a header without counts still beats no diff */
|
|
87
|
+
}
|
|
88
|
+
const truncated = Buffer.byteLength(patch, 'utf8') > MAX_PATCH_BYTES;
|
|
89
|
+
await postDiff({
|
|
90
|
+
sha,
|
|
91
|
+
// Cut on a LINE boundary: half a hunk header renders as garbage,
|
|
92
|
+
// and the viewer says the patch was truncated either way.
|
|
93
|
+
patch: truncated
|
|
94
|
+
? patch.slice(0, MAX_PATCH_BYTES).replace(/\n[^\n]*$/, '\n')
|
|
95
|
+
: patch,
|
|
96
|
+
files: files.slice(0, 200),
|
|
97
|
+
truncated,
|
|
98
|
+
});
|
|
99
|
+
} finally {
|
|
100
|
+
servedDiffs.delete(sha);
|
|
101
|
+
}
|
|
102
|
+
})();
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
return { processDiffJobs };
|
|
107
|
+
}
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/** Session preview claims, tunnels, and retirement. */
|
|
2
|
+
import { FLEET_URL, FLEET_TOKEN, USER_AGENT, DAEMON_INSTANCE } from './config.mjs';
|
|
3
|
+
import { isSafePathSegment } from './git.mjs';
|
|
4
|
+
import { originFor } from './listeners.mjs';
|
|
5
|
+
import { openTunnel } from './preview.mjs';
|
|
6
|
+
import { note } from './ui.mjs';
|
|
7
|
+
|
|
8
|
+
export function createWorkPreviews({ placeDir }) {
|
|
9
|
+
const PREVIEW_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/preview-done');
|
|
10
|
+
const PREVIEW_CLAIM_URL = FLEET_URL.replace(/\/agents\/?$/, '/preview-claim');
|
|
11
|
+
|
|
12
|
+
// ── SESSION PREVIEWS ──────────────────────────────────────────────────────
|
|
13
|
+
//
|
|
14
|
+
// Share the dev server the DRIVER is already running in their tab, behind a
|
|
15
|
+
// generated password, on a quick tunnel. This daemon never starts an app: the
|
|
16
|
+
// deleted live-preview feature ran a repo-declared command through a shell,
|
|
17
|
+
// and that is the reason it is deleted. Here the human runs their own server,
|
|
18
|
+
// `listenersIn` notices it, and this only ever wraps a port that measurement
|
|
19
|
+
// already named for that session.
|
|
20
|
+
//
|
|
21
|
+
// CLAIM BEFORE ACTING. Two daemons legitimately share one fleet credential —
|
|
22
|
+
// the case `machineDaemonsDisagree` exists because it happens, and the 0.51.2
|
|
23
|
+
// instance lock is blind to an OLDER peer — so both are handed the same job
|
|
24
|
+
// array. Both opening a tunnel leaves a public hostname alive that nobody
|
|
25
|
+
// owns and nobody can tear down, because only the lease holder can settle the
|
|
26
|
+
// row. `processDiffJobs` gets away without this because running `git show`
|
|
27
|
+
// twice costs nothing.
|
|
28
|
+
const livePreviews = new Map(); // sessionId -> { port, shareId, url, stop }
|
|
29
|
+
const previewClaiming = new Set(); // sessionIds mid-claim on this tick
|
|
30
|
+
|
|
31
|
+
// Returns the parsed JSON (or null on any failure) rather than discarding
|
|
32
|
+
// it — an OPEN settle's caller needs to see `data.settled === false`, the
|
|
33
|
+
// sharper share-id check on the server's side of a re-share racing the
|
|
34
|
+
// open's own round trip (see the openTunnel call site below).
|
|
35
|
+
const postPreview = async (body) => {
|
|
36
|
+
try {
|
|
37
|
+
const res = await fetch(PREVIEW_DONE_URL, {
|
|
38
|
+
method: 'POST',
|
|
39
|
+
headers: {
|
|
40
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
41
|
+
'User-Agent': USER_AGENT,
|
|
42
|
+
'Content-Type': 'application/json',
|
|
43
|
+
},
|
|
44
|
+
signal: AbortSignal.timeout(30_000),
|
|
45
|
+
body: JSON.stringify({ ...body, instance: DAEMON_INSTANCE }),
|
|
46
|
+
});
|
|
47
|
+
return await res.json().catch(() => null);
|
|
48
|
+
} catch {
|
|
49
|
+
/* the row stops being confirmed and reads as ended — which is true */
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// `shareId` is the share the daemon was OFFERED, echoed off the job. A
|
|
55
|
+
// re-share inside the claim's round trip rotates the id server-side, so a
|
|
56
|
+
// claim naming the old one matches nothing and this daemon opens no tunnel
|
|
57
|
+
// for a request that no longer exists. Absent-safe: an older server that
|
|
58
|
+
// does not read the field just claims on the place alone, the old rule.
|
|
59
|
+
const claimPreview = async (sessionId, shareId) => {
|
|
60
|
+
try {
|
|
61
|
+
const res = await fetch(PREVIEW_CLAIM_URL, {
|
|
62
|
+
method: 'POST',
|
|
63
|
+
headers: {
|
|
64
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
65
|
+
'User-Agent': USER_AGENT,
|
|
66
|
+
'Content-Type': 'application/json',
|
|
67
|
+
},
|
|
68
|
+
signal: AbortSignal.timeout(15_000),
|
|
69
|
+
body: JSON.stringify({ sessionId, instance: DAEMON_INSTANCE, ...(shareId ? { shareId } : {}) }),
|
|
70
|
+
});
|
|
71
|
+
const j = await res.json().catch(() => null);
|
|
72
|
+
return j?.data?.claimed === true;
|
|
73
|
+
} catch {
|
|
74
|
+
return false; // could not claim → do nothing at all. The other daemon may have.
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** Tear one down here, and say so. `reason` is why, stored server-side rather
|
|
79
|
+
* than inferred: "the origin stopped listening" and "the owner pressed Stop"
|
|
80
|
+
* are different sentences to a teammate holding a phone. */
|
|
81
|
+
const stopPreview = async (sessionId, reason) => {
|
|
82
|
+
const live = livePreviews.get(sessionId);
|
|
83
|
+
livePreviews.delete(sessionId);
|
|
84
|
+
if (live) {
|
|
85
|
+
try {
|
|
86
|
+
live.stop();
|
|
87
|
+
} catch {
|
|
88
|
+
/* best-effort */
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// Confirm only a teardown we actually PERFORMED. The stop job is a
|
|
92
|
+
// broadcast — every daemon on the credential gets it — and the one holding
|
|
93
|
+
// nothing used to answer instantly, flipping the row to 'ended' so the
|
|
94
|
+
// real holder was never told to stop and its tunnel outlived every
|
|
95
|
+
// surface. (The server drops mismatched confirms too; this is the copy on
|
|
96
|
+
// the component that can be published ahead of a deploy.) A stop for a
|
|
97
|
+
// tunnel whose daemon crashed resolves server-side: an unanswered 'ending'
|
|
98
|
+
// row reads as over once it goes stale.
|
|
99
|
+
if (live) await postPreview({ sessionId, ended: true, endedReason: reason });
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const processPreviewJobs = (jobs) => {
|
|
103
|
+
if (!Array.isArray(jobs) || jobs.length === 0) return;
|
|
104
|
+
for (const job of jobs.slice(0, 5)) {
|
|
105
|
+
const sessionId = String(job?.sessionId || '');
|
|
106
|
+
const port = Number(job?.port);
|
|
107
|
+
if (!isSafePathSegment(sessionId)) continue;
|
|
108
|
+
|
|
109
|
+
if (job?.action === 'stop') {
|
|
110
|
+
if (previewClaiming.has(sessionId)) continue;
|
|
111
|
+
previewClaiming.add(sessionId);
|
|
112
|
+
void stopPreview(sessionId, 'stopped').finally(() => previewClaiming.delete(sessionId));
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) continue;
|
|
117
|
+
|
|
118
|
+
// VALIDATE THE MEMBERS-GATE TRIPLE AT THE BOUNDARY, the way sessionId and
|
|
119
|
+
// port already are — one place doing the check is one deploy away from
|
|
120
|
+
// being zero places. A malformed value is DROPPED rather than errored and
|
|
121
|
+
// the share opens password-only: a gate is never degraded to open, but it
|
|
122
|
+
// is also never left un-opened over a field we could not read.
|
|
123
|
+
const secret = /^[A-Za-z0-9_-]{32,128}$/.test(String(job?.secret ?? ''))
|
|
124
|
+
? String(job.secret)
|
|
125
|
+
: null;
|
|
126
|
+
const shareId = isSafePathSegment(String(job?.shareId ?? '')) ? String(job.shareId) : null;
|
|
127
|
+
let authorizeUrl = null;
|
|
128
|
+
try {
|
|
129
|
+
const u = new URL(String(job?.authorizeUrl ?? ''));
|
|
130
|
+
if (u.protocol === 'https:') authorizeUrl = u.toString();
|
|
131
|
+
} catch {
|
|
132
|
+
/* not a URL — password-only, which is honest */
|
|
133
|
+
}
|
|
134
|
+
// All three or none: two of the three is a gate that cannot bounce.
|
|
135
|
+
const gateOk = Boolean(secret && shareId && authorizeUrl);
|
|
136
|
+
|
|
137
|
+
// Already serving exactly this. Re-opening would replace a working URL
|
|
138
|
+
// somebody may be looking at right now.
|
|
139
|
+
//
|
|
140
|
+
// NOTE this key is (session, port) and NOT the secret. Rotating a secret
|
|
141
|
+
// under a LIVE share is deliberately unsupported: `requestPreview`
|
|
142
|
+
// early-returns on a live row of the same port, so a new secret only ever
|
|
143
|
+
// arrives with a genuinely new row, by which time this map has been
|
|
144
|
+
// cleared. Anyone adding rotation must widen the key first.
|
|
145
|
+
//
|
|
146
|
+
// WIDENED for shareId: a live entry that has no shareId of its own (an
|
|
147
|
+
// older server, or one opened before shareId rode the wire) or this
|
|
148
|
+
// job's own shareId that is blank still matches on port alone, the old
|
|
149
|
+
// rule — but a live entry whose shareId is SET and DIFFERS from this
|
|
150
|
+
// job's is a re-share of the same port under a NEW request, and must
|
|
151
|
+
// not be skipped as "already serving exactly this".
|
|
152
|
+
const curLive = livePreviews.get(sessionId);
|
|
153
|
+
if (curLive?.port === port && (!shareId || !curLive.shareId || curLive.shareId === shareId))
|
|
154
|
+
continue;
|
|
155
|
+
if (previewClaiming.has(sessionId)) continue;
|
|
156
|
+
previewClaiming.add(sessionId);
|
|
157
|
+
|
|
158
|
+
void (async () => {
|
|
159
|
+
try {
|
|
160
|
+
if (!(await claimPreview(sessionId, shareId))) return; // somebody else has it
|
|
161
|
+
const wt = placeDir(sessionId);
|
|
162
|
+
// RE-VALIDATE the attribution here, not just the liveness. The server
|
|
163
|
+
// checked this port against a report up to a minute old; more
|
|
164
|
+
// importantly, checking `originFor` again is what keeps the answer
|
|
165
|
+
// to "whose port is this" on the machine that can actually see it —
|
|
166
|
+
// the same rule the gate itself dials by (below).
|
|
167
|
+
const measured = !originFor(wt, port).error;
|
|
168
|
+
if (!measured) {
|
|
169
|
+
await postPreview({
|
|
170
|
+
sessionId,
|
|
171
|
+
error: `nothing is listening on port ${port} in this worktree.`,
|
|
172
|
+
...(shareId ? { shareId } : {}),
|
|
173
|
+
});
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
// Replace anything this session already had — one tab, one door.
|
|
177
|
+
const prev = livePreviews.get(sessionId);
|
|
178
|
+
if (prev) {
|
|
179
|
+
try {
|
|
180
|
+
prev.stop();
|
|
181
|
+
} catch {
|
|
182
|
+
/* best-effort */
|
|
183
|
+
}
|
|
184
|
+
livePreviews.delete(sessionId);
|
|
185
|
+
}
|
|
186
|
+
const t = await openTunnel({
|
|
187
|
+
port,
|
|
188
|
+
log: (m) => note(`preview ${sessionId.slice(0, 8)}: ${m}`),
|
|
189
|
+
// The worktree the port was attributed to — the gate then dials
|
|
190
|
+
// the address the ATTRIBUTED socket holds (`originFor`, ::1 for a
|
|
191
|
+
// v6-loopback dev server) and refuses when an outside process
|
|
192
|
+
// holds the same address, rather than assuming 127.0.0.1.
|
|
193
|
+
worktree: wt,
|
|
194
|
+
// The origin died under a live tunnel. cloudflared happily outlives
|
|
195
|
+
// a dead dev server and the gate answers a dead origin with 502, so
|
|
196
|
+
// without this the app would print "live" over a 502.
|
|
197
|
+
onDead: () => {
|
|
198
|
+
livePreviews.delete(sessionId);
|
|
199
|
+
void postPreview({
|
|
200
|
+
sessionId,
|
|
201
|
+
ended: true,
|
|
202
|
+
endedReason: 'origin_gone',
|
|
203
|
+
...(shareId ? { shareId } : {}),
|
|
204
|
+
});
|
|
205
|
+
},
|
|
206
|
+
// ATTRIBUTION rides the probe, not just the open: a freed default
|
|
207
|
+
// port (5173…) rebound by any other process on the box would keep
|
|
208
|
+
// a bare TCP probe green, and the share's URL+password would serve
|
|
209
|
+
// a worktree nobody consented to publish.
|
|
210
|
+
stillServing: async () => !originFor(wt, port).error,
|
|
211
|
+
...(gateOk ? { grantSecret: secret, shareId, authorizeUrl } : {}),
|
|
212
|
+
// The gate closed itself after repeated failed passwords. Stored,
|
|
213
|
+
// so the incident is visible — and the entry is dropped so the
|
|
214
|
+
// owner can re-share the port without restarting the daemon.
|
|
215
|
+
onAbuse: () => {
|
|
216
|
+
livePreviews.delete(sessionId);
|
|
217
|
+
void postPreview({
|
|
218
|
+
sessionId,
|
|
219
|
+
ended: true,
|
|
220
|
+
endedReason: 'abuse',
|
|
221
|
+
...(shareId ? { shareId } : {}),
|
|
222
|
+
});
|
|
223
|
+
},
|
|
224
|
+
// cloudflared died AFTER publishing (quick tunnels get dropped).
|
|
225
|
+
// Without this the daemon kept heartbeating a hostname that 530s.
|
|
226
|
+
onTunnelGone: () => {
|
|
227
|
+
livePreviews.delete(sessionId);
|
|
228
|
+
void postPreview({
|
|
229
|
+
sessionId,
|
|
230
|
+
error: 'the tunnel dropped — share it again to reopen.',
|
|
231
|
+
...(shareId ? { shareId } : {}),
|
|
232
|
+
});
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
if (t.error) {
|
|
236
|
+
await postPreview({ sessionId, error: t.error, ...(shareId ? { shareId } : {}) });
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
livePreviews.set(sessionId, { port, shareId, url: t.url, stop: t.stop });
|
|
240
|
+
// The gate we ACTUALLY installed, so the app never asserts a door
|
|
241
|
+
// nobody observed. An older server ignores the field.
|
|
242
|
+
const settled = await postPreview({
|
|
243
|
+
sessionId,
|
|
244
|
+
url: t.url,
|
|
245
|
+
user: t.user,
|
|
246
|
+
password: t.password,
|
|
247
|
+
gate: t.gateMode,
|
|
248
|
+
...(shareId ? { shareId } : {}),
|
|
249
|
+
});
|
|
250
|
+
// A re-share (or a Stop) can land inside this open's own round
|
|
251
|
+
// trip and rotate the share id server-side — the settle is then
|
|
252
|
+
// refused for naming a request that no longer exists, and nobody
|
|
253
|
+
// holds a live row for the tunnel we just opened. Tear it down
|
|
254
|
+
// rather than leave a public hostname serving with no door back to
|
|
255
|
+
// it, and only if this call is still the entry's own (a newer open
|
|
256
|
+
// for the same session must not be undone by a late-arriving
|
|
257
|
+
// settle for an older one).
|
|
258
|
+
if (settled?.data?.settled === false) {
|
|
259
|
+
if (livePreviews.get(sessionId)?.stop === t.stop) livePreviews.delete(sessionId);
|
|
260
|
+
try {
|
|
261
|
+
t.stop();
|
|
262
|
+
} catch {
|
|
263
|
+
/* best-effort */
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
} finally {
|
|
267
|
+
previewClaiming.delete(sessionId);
|
|
268
|
+
}
|
|
269
|
+
})();
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
/** The sessionIds this machine is still serving — sent on the poll so the
|
|
274
|
+
* server can tell a live share from one whose machine went away. Silence
|
|
275
|
+
* must never read as "live". */
|
|
276
|
+
/**
|
|
277
|
+
* The daemon's own shape check on an argv the server parsed.
|
|
278
|
+
*
|
|
279
|
+
* Deliberately a SHAPE check and not a re-parse: the server owns the policy
|
|
280
|
+
* (which argv[0] are allowed, the install refusal, the length caps) and the
|
|
281
|
+
* machine owns the refusal to EXECUTE something malformed. It is duplicated
|
|
282
|
+
* rather than imported because this package ships standalone and cannot
|
|
283
|
+
* depend on the monorepo — the mirror is small, and `devCommand.ts` is where
|
|
284
|
+
* the real rules live.
|
|
285
|
+
*/
|
|
286
|
+
const isPlausibleDevArgv = (argv) =>
|
|
287
|
+
Array.isArray(argv) &&
|
|
288
|
+
argv.length > 0 &&
|
|
289
|
+
argv.length <= 8 &&
|
|
290
|
+
argv.every((a) => typeof a === 'string' && a.length > 0 && a.length <= 200) &&
|
|
291
|
+
!argv.some((a) => /[&|;<>`$(){}*?~\\]/.test(a));
|
|
292
|
+
|
|
293
|
+
// ── DEV RUNS ARE DELETED (2026-08-26) ─────────────────────────────────
|
|
294
|
+
//
|
|
295
|
+
// The machine no longer starts application processes. It never learned to
|
|
296
|
+
// decide what "run dev" means for a stack nobody enumerated — `rojo serve`
|
|
297
|
+
// and `rbxtsc -w` are both correct for one Roblox repo and neither could
|
|
298
|
+
// clear the server's argv0 allowlist, and widening that list is a code change
|
|
299
|
+
// per ecosystem forever.
|
|
300
|
+
//
|
|
301
|
+
// Nothing downstream is lost, because SHARING NEVER DEPENDED ON US STARTING
|
|
302
|
+
// IT. `listenersIn` attributes a listening socket to a place by the cwd of
|
|
303
|
+
// the process holding it, so a server the driver's own agent started in the
|
|
304
|
+
// tab is measured exactly like one this file used to spawn. The web renders
|
|
305
|
+
// that measured list and a person picks which port to share.
|
|
306
|
+
//
|
|
307
|
+
// Gone with it: `devServer.mjs`, `devResolve.mjs`, the four `/fleet/dev-run-*`
|
|
308
|
+
// endpoints, the claim lease, the orphan registry at ~/.flowviant/devruns.json
|
|
309
|
+
// and its adopt-across-re-exec dance. If supervision is ever wanted back it
|
|
310
|
+
// returns as "supervise this process", never as "run dev".
|
|
311
|
+
|
|
312
|
+
const livePreviewIds = () => [...livePreviews.keys()];
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The tab closed (or the server stopped listing it). Ordered BEFORE
|
|
316
|
+
* `retireWorkSessions`, and that ordering is load-bearing: `git worktree
|
|
317
|
+
* remove` under a running dev server reintroduces the stale-server bug — on
|
|
318
|
+
* Linux the process keeps serving bytes from open file handles in a directory
|
|
319
|
+
* that no longer exists, which shows a human the wrong thing without erroring
|
|
320
|
+
* anywhere.
|
|
321
|
+
*/
|
|
322
|
+
const retirePreviews = (activeIds) => {
|
|
323
|
+
// Same guard `retireWorkSessions` keeps: a roster response missing the
|
|
324
|
+
// field is an older server, not a close, and must not tear down every live
|
|
325
|
+
// share at once.
|
|
326
|
+
if (!Array.isArray(activeIds)) return;
|
|
327
|
+
const live = new Set(activeIds);
|
|
328
|
+
for (const sessionId of [...livePreviews.keys()]) {
|
|
329
|
+
if (live.has(sessionId)) continue;
|
|
330
|
+
if (previewClaiming.has(sessionId)) continue;
|
|
331
|
+
previewClaiming.add(sessionId);
|
|
332
|
+
void stopPreview(sessionId, 'tab_closed').finally(() => previewClaiming.delete(sessionId));
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
/** Daemon shutdown. Detached tunnels survive our exit by design, so leaving
|
|
337
|
+
* them would strand a public hostname until the box rebooted — the exact
|
|
338
|
+
* case `reapOrphanPreviews` exists to clean up after an UNgraceful death. */
|
|
339
|
+
const shutdownPreviews = () => {
|
|
340
|
+
for (const [, live] of livePreviews) {
|
|
341
|
+
try {
|
|
342
|
+
live.stop();
|
|
343
|
+
} catch {
|
|
344
|
+
/* best-effort */
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
livePreviews.clear();
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
return { processPreviewJobs, livePreviewIds, retirePreviews, shutdownPreviews };
|
|
351
|
+
}
|