flowviant 0.51.2 → 0.53.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/lib/authproxy.mjs +131 -32
- package/bin/lib/claude.mjs +16 -3
- package/bin/lib/config.mjs +16 -0
- package/bin/lib/fleet.mjs +52 -16
- package/bin/lib/listeners.mjs +269 -0
- package/bin/lib/preview.mjs +294 -390
- package/bin/lib/prompts.mjs +62 -13
- package/bin/lib/runtimes.mjs +47 -0
- package/bin/lib/work.mjs +218 -4
- package/package.json +1 -1
package/bin/lib/authproxy.mjs
CHANGED
|
@@ -1,44 +1,124 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* it reaches the running app. This gates it: cloudflared → this proxy → dev
|
|
5
|
-
* server, with a generated password the app surfaces to the reviewer.
|
|
2
|
+
* The password gate in front of a shared preview. cloudflared → this proxy →
|
|
3
|
+
* the dev server the DRIVER started in their own worktree.
|
|
6
4
|
*
|
|
7
|
-
*
|
|
5
|
+
* MANDATORY, not opt-in (2026-08-21). It was `.flowviant/preview.json`
|
|
6
|
+
* "auth": true, defaulting OFF, and its caller logged "tunneling WITHOUT a
|
|
7
|
+
* password" to a console nobody reads and opened the tunnel anyway. A tunnel
|
|
8
|
+
* publishes a worktree holding the project's materialized dev secrets; there is
|
|
9
|
+
* no honest default but closed. `startAuthProxy` returning null now means the
|
|
10
|
+
* share is ABORTED and the machine says why.
|
|
11
|
+
*
|
|
12
|
+
* Minimal and dependency-free: forwards HTTP, and pipes WS upgrades (HMR) — the
|
|
8
13
|
* browser re-sends the cached Basic-auth header on same-origin upgrades, so HMR
|
|
9
|
-
* still authenticates.
|
|
10
|
-
*
|
|
14
|
+
* still authenticates.
|
|
15
|
+
*
|
|
16
|
+
* Three things this file gets wrong easily, all of them fixed here and all of
|
|
17
|
+
* them worth keeping fixed:
|
|
18
|
+
* - the credential must NOT reach the origin. `headers: req.headers` forwarded
|
|
19
|
+
* `authorization` verbatim, handing the gate password to whatever code the
|
|
20
|
+
* branch happens to be running. It is stripped now.
|
|
21
|
+
* - the comparison is over a secret, so it is constant-time over a digest
|
|
22
|
+
* rather than `===` over a string.
|
|
23
|
+
* - `stop()` was a bare `server.close()`, which refuses NEW connections and
|
|
24
|
+
* leaves live ones alone — so a held HMR websocket kept the page alive for
|
|
25
|
+
* the one most-engaged viewer after teardown. Live sockets are tracked and
|
|
26
|
+
* destroyed.
|
|
11
27
|
*/
|
|
12
28
|
|
|
13
29
|
import { createServer, request } from 'node:http';
|
|
14
|
-
import { randomBytes } from 'node:crypto';
|
|
30
|
+
import { randomBytes, createHash, timingSafeEqual } from 'node:crypto';
|
|
31
|
+
|
|
32
|
+
/** Failed attempts before the proxy stops answering at all. A quick tunnel's
|
|
33
|
+
* hostname is unguessable, so this is not the primary control — it is what
|
|
34
|
+
* turns a discovered URL from an offline guessing target into a visible,
|
|
35
|
+
* self-closing incident. */
|
|
36
|
+
const MAX_FAILED = 25;
|
|
37
|
+
|
|
38
|
+
const digest = (s) => createHash('sha256').update(String(s)).digest();
|
|
39
|
+
|
|
40
|
+
/** Constant-time over sha256 digests, so length never leaks and a missing
|
|
41
|
+
* header costs the same as a wrong one. */
|
|
42
|
+
function sameSecret(a, b) {
|
|
43
|
+
try {
|
|
44
|
+
return timingSafeEqual(digest(a ?? ''), digest(b ?? ''));
|
|
45
|
+
} catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
15
49
|
|
|
16
|
-
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Start the gate in front of a dev server on `targetPort`. Resolves
|
|
52
|
+
* { port, user, password, stop } — or NULL, which the caller must treat as a
|
|
53
|
+
* hard failure. Binds loopback only; cloudflared connects locally, and the
|
|
54
|
+
* password is what gates the public hostname.
|
|
55
|
+
*
|
|
56
|
+
* `onAbuse` fires once, after MAX_FAILED rejected attempts, so the caller can
|
|
57
|
+
* tear the whole share down rather than leaving a URL under attack.
|
|
58
|
+
*/
|
|
59
|
+
export function startAuthProxy({ targetPort, log, onAbuse }) {
|
|
20
60
|
const user = 'preview';
|
|
21
|
-
|
|
61
|
+
// 24 bytes → 32 url-safe chars. It was 9 bytes, chosen when this was an
|
|
62
|
+
// opt-in convenience; it is the only thing between a public hostname and a
|
|
63
|
+
// worktree now.
|
|
64
|
+
const password = randomBytes(24).toString('base64url');
|
|
22
65
|
const expected = 'Basic ' + Buffer.from(`${user}:${password}`).toString('base64');
|
|
23
|
-
const authed = (req) => req.headers['authorization'] === expected;
|
|
24
66
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
67
|
+
let failed = 0;
|
|
68
|
+
let abused = false;
|
|
69
|
+
const authed = (req) => {
|
|
70
|
+
if (abused) return false;
|
|
71
|
+
if (sameSecret(req.headers['authorization'], expected)) {
|
|
72
|
+
failed = 0;
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
// A bare GET with no header is the browser's first move on every load, and
|
|
76
|
+
// it is answered with a 401 challenge — it is not an attempt.
|
|
77
|
+
if (req.headers['authorization']) failed += 1;
|
|
78
|
+
if (failed >= MAX_FAILED && !abused) {
|
|
79
|
+
abused = true;
|
|
80
|
+
log?.(`preview gate: ${failed} failed attempts — closing the share.`);
|
|
81
|
+
try {
|
|
82
|
+
onAbuse?.();
|
|
83
|
+
} catch {
|
|
84
|
+
/* the caller's teardown is best-effort */
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return false;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// The gate credential is OURS and stops here. Everything else is passed
|
|
91
|
+
// through untouched: the origin is the driver's own dev server and rewriting
|
|
92
|
+
// its request would be us editing their app's input.
|
|
93
|
+
const forwardOpts = (req) => {
|
|
94
|
+
const headers = { ...req.headers };
|
|
95
|
+
delete headers.authorization;
|
|
96
|
+
delete headers['proxy-authorization'];
|
|
97
|
+
return {
|
|
98
|
+
host: '127.0.0.1',
|
|
99
|
+
port: targetPort,
|
|
100
|
+
method: req.method,
|
|
101
|
+
path: req.url,
|
|
102
|
+
headers,
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const challenge = (res) => {
|
|
107
|
+
res.writeHead(401, {
|
|
108
|
+
'WWW-Authenticate': 'Basic realm="Flowviant preview"',
|
|
109
|
+
'Content-Type': 'text/plain',
|
|
110
|
+
// A preview is a moving target by definition; nothing about it should sit
|
|
111
|
+
// in a cache the viewer cannot see.
|
|
112
|
+
'Cache-Control': 'no-store',
|
|
113
|
+
});
|
|
114
|
+
res.end('This preview is password-protected. Enter the password shown in Flowviant.');
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// Every live socket, so stop() can actually end the ones already talking.
|
|
118
|
+
const sockets = new Set();
|
|
32
119
|
|
|
33
120
|
const server = createServer((req, res) => {
|
|
34
|
-
if (!authed(req))
|
|
35
|
-
res.writeHead(401, {
|
|
36
|
-
'WWW-Authenticate': 'Basic realm="Flowviant preview"',
|
|
37
|
-
'Content-Type': 'text/plain',
|
|
38
|
-
});
|
|
39
|
-
res.end('This preview is password-protected. Enter the password shown in Flowviant.');
|
|
40
|
-
return;
|
|
41
|
-
}
|
|
121
|
+
if (!authed(req)) return challenge(res);
|
|
42
122
|
const proxyReq = request(forwardOpts(req), (proxyRes) => {
|
|
43
123
|
res.writeHead(proxyRes.statusCode || 502, proxyRes.headers);
|
|
44
124
|
proxyRes.pipe(res);
|
|
@@ -50,8 +130,13 @@ export function startAuthProxy({ targetPort, log }) {
|
|
|
50
130
|
req.pipe(proxyReq);
|
|
51
131
|
});
|
|
52
132
|
|
|
133
|
+
server.on('connection', (socket) => {
|
|
134
|
+
sockets.add(socket);
|
|
135
|
+
socket.on('close', () => sockets.delete(socket));
|
|
136
|
+
});
|
|
137
|
+
|
|
53
138
|
// WS upgrade (HMR). The browser resends the Basic-auth header on same-origin
|
|
54
|
-
// upgrades, so we
|
|
139
|
+
// upgrades, so we gate it too, then pipe the two sockets together.
|
|
55
140
|
server.on('upgrade', (req, socket, head) => {
|
|
56
141
|
if (!authed(req)) {
|
|
57
142
|
socket.write('HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm="Flowviant preview"\r\n\r\n');
|
|
@@ -65,6 +150,8 @@ export function startAuthProxy({ targetPort, log }) {
|
|
|
65
150
|
if (proxyHead && proxyHead.length) proxySocket.unshift(proxyHead);
|
|
66
151
|
proxySocket.pipe(socket);
|
|
67
152
|
socket.pipe(proxySocket);
|
|
153
|
+
sockets.add(proxySocket);
|
|
154
|
+
proxySocket.on('close', () => sockets.delete(proxySocket));
|
|
68
155
|
proxySocket.on('error', () => socket.destroy());
|
|
69
156
|
socket.on('error', () => proxySocket.destroy());
|
|
70
157
|
});
|
|
@@ -74,10 +161,12 @@ export function startAuthProxy({ targetPort, log }) {
|
|
|
74
161
|
});
|
|
75
162
|
|
|
76
163
|
return new Promise((resolve) => {
|
|
77
|
-
|
|
164
|
+
// Could not bind → the caller ABORTS the share. There is no no-proxy path
|
|
165
|
+
// to fall back to any more.
|
|
166
|
+
server.on('error', () => resolve(null));
|
|
78
167
|
server.listen(0, '127.0.0.1', () => {
|
|
79
168
|
const port = server.address().port;
|
|
80
|
-
log?.(`
|
|
169
|
+
log?.(`preview gate on :${port}`);
|
|
81
170
|
resolve({
|
|
82
171
|
port,
|
|
83
172
|
user,
|
|
@@ -88,6 +177,16 @@ export function startAuthProxy({ targetPort, log }) {
|
|
|
88
177
|
} catch {
|
|
89
178
|
/* already closed */
|
|
90
179
|
}
|
|
180
|
+
// close() only stops NEW connections. An open HMR socket would keep
|
|
181
|
+
// serving the viewer who is still looking at it.
|
|
182
|
+
for (const s of sockets) {
|
|
183
|
+
try {
|
|
184
|
+
s.destroy();
|
|
185
|
+
} catch {
|
|
186
|
+
/* already gone */
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
sockets.clear();
|
|
91
190
|
},
|
|
92
191
|
});
|
|
93
192
|
});
|
package/bin/lib/claude.mjs
CHANGED
|
@@ -197,7 +197,7 @@ const oneLine = (s, n = 160) => String(s).replace(/\s+/g, ' ').trim().slice(0, n
|
|
|
197
197
|
// every intermediate text block still NARRATES, but only the final `result`
|
|
198
198
|
// event contributes text — otherwise the same sentences arrive twice, once as
|
|
199
199
|
// they stream and once in the result, and the tab posts the duplicate.
|
|
200
|
-
function handleStreamLine(line, { cwd, emit, onActivity, appendText, answerFromResult }) {
|
|
200
|
+
function handleStreamLine(line, { cwd, emit, onActivity, appendText, answerFromResult, onInit }) {
|
|
201
201
|
let ev;
|
|
202
202
|
try {
|
|
203
203
|
ev = JSON.parse(line);
|
|
@@ -224,6 +224,19 @@ function handleStreamLine(line, { cwd, emit, onActivity, appendText, answerFromR
|
|
|
224
224
|
push(humanizeToolUse(b.name, b.input || {}, cwd));
|
|
225
225
|
}
|
|
226
226
|
}
|
|
227
|
+
} else if (ev.type === 'system' && ev.subtype === 'init') {
|
|
228
|
+
// WHAT THIS MACHINE'S CLI CAN BE ASKED FOR BY NAME. The init event is the
|
|
229
|
+
// CLI's OWN answer — it has already resolved personal skills, this repo's
|
|
230
|
+
// skills, plugins and whatever the project settings enable or disable — so
|
|
231
|
+
// reading it costs nothing and cannot drift the way a `~/.claude/skills`
|
|
232
|
+
// scan of our own would. `skills` (rather than `slash_commands`) is the
|
|
233
|
+
// deliberate narrowing: the 50-odd commands beside it are the CLI's own
|
|
234
|
+
// interactive furniture (/clear, /model, /compact), and offering those in a
|
|
235
|
+
// relayed tab would be an offer wired to nothing.
|
|
236
|
+
//
|
|
237
|
+
// Only ever REPORTED, never enforced. Flowviant does not decide what your
|
|
238
|
+
// Claude can do; it relays what your Claude said it has.
|
|
239
|
+
if (Array.isArray(ev.skills)) onInit?.({ skills: ev.skills.map(String) });
|
|
227
240
|
} else if (ev.type === 'result') {
|
|
228
241
|
// The final assistant text (carries WIKI_DONE / REGROUND_DONE).
|
|
229
242
|
if (typeof ev.result === 'string') appendText(ev.result + '\n');
|
|
@@ -247,7 +260,7 @@ function handleStreamLine(line, { cwd, emit, onActivity, appendText, answerFromR
|
|
|
247
260
|
// returned string for sentinel detection, and each activity is handed to
|
|
248
261
|
// `onActivity` so the caller can forward progress. Build-agent turns leave it
|
|
249
262
|
// off and keep the raw text passthrough + line sentinels.
|
|
250
|
-
export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEnv, runtime = 'claude', label, onSpawn, streamJson, answerFromResult, onActivity, onThreadId, wikiPerm, readOnly, planPerm, vaultDir, resultSchemaArgs, model, effort, adoptResumeId, resumeThreadId, resumeConversationId }) {
|
|
263
|
+
export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEnv, runtime = 'claude', label, onSpawn, streamJson, answerFromResult, onActivity, onInit, onThreadId, wikiPerm, readOnly, planPerm, vaultDir, resultSchemaArgs, model, effort, adoptResumeId, resumeThreadId, resumeConversationId }) {
|
|
251
264
|
return new Promise((resolve) => {
|
|
252
265
|
const rt = runtimeById(runtime);
|
|
253
266
|
if (!rt.args) {
|
|
@@ -359,7 +372,7 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEn
|
|
|
359
372
|
/** One line of the child's stdout, in whichever dialect it speaks. */
|
|
360
373
|
const onLine = (line) => {
|
|
361
374
|
if (!rt.parse)
|
|
362
|
-
return handleStreamLine(line, { cwd, emit, onActivity, appendText, answerFromResult });
|
|
375
|
+
return handleStreamLine(line, { cwd, emit, onActivity, appendText, answerFromResult, onInit });
|
|
363
376
|
const ev = rt.parse(line, cwd);
|
|
364
377
|
if (!ev) return;
|
|
365
378
|
// The conversation id, when the runtime announces one (codex's
|
package/bin/lib/config.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** Parsed configuration: env vars, CLI flags, and the chosen credentials. */
|
|
2
2
|
|
|
3
|
+
import { randomBytes } from 'node:crypto';
|
|
3
4
|
import { readFileSync } from 'node:fs';
|
|
4
5
|
import { dirname, join } from 'node:path';
|
|
5
6
|
import { fileURLToPath } from 'node:url';
|
|
@@ -168,6 +169,21 @@ export const ALLOW_PATCHES =
|
|
|
168
169
|
// them (Node's default UA is treated as a bot). Claude Code sends its own UA.
|
|
169
170
|
export const USER_AGENT = `flowviant/${VERSION}`;
|
|
170
171
|
|
|
172
|
+
/**
|
|
173
|
+
* WHICH DAEMON PROCESS this is — not which credential.
|
|
174
|
+
*
|
|
175
|
+
* Two daemons legitimately share one fleet token (that is exactly what
|
|
176
|
+
* `machineDaemonsDisagree` reports, and the instance lock added in 0.51.2
|
|
177
|
+
* cannot see a peer OLDER than itself). So the token cannot identify a lease
|
|
178
|
+
* holder, and anything that must be done exactly once needs this instead.
|
|
179
|
+
*
|
|
180
|
+
* Regenerated every start, deliberately: a daemon that restarted is a daemon
|
|
181
|
+
* that lost whatever it was holding, and a stale lease should not follow it
|
|
182
|
+
* back. Sent on the poll as `di`; its ABSENCE is what an older daemon looks
|
|
183
|
+
* like, and the server hands preview work to nobody who cannot name themselves.
|
|
184
|
+
*/
|
|
185
|
+
export const DAEMON_INSTANCE = randomBytes(12).toString('hex');
|
|
186
|
+
|
|
171
187
|
// The ONE credential. `tokens` (FLOWVIANT_TOKEN / FLOWVIANT_TOKENS / --token /
|
|
172
188
|
// --tokens) stood beside it and carried WORKER tokens into the pre-daemon loop;
|
|
173
189
|
// that principal owns zero tools since dispatch was deleted, and the kind can no
|
package/bin/lib/fleet.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
USER_AGENT,
|
|
24
24
|
MCP_URL,
|
|
25
25
|
SAFE,
|
|
26
|
+
DAEMON_INSTANCE,
|
|
26
27
|
POLL_SECONDS,
|
|
27
28
|
MAX_CONCURRENT,
|
|
28
29
|
IDLE_SECONDS,
|
|
@@ -73,11 +74,11 @@ import {
|
|
|
73
74
|
} from './env.mjs';
|
|
74
75
|
import { processDeployJobs, reportDeployConfig } from './deploy.mjs';
|
|
75
76
|
import { machineSnapshot } from './resources.mjs';
|
|
76
|
-
import { detectRuntimes, pickRuntimeFor, RUNTIMES } from './runtimes.mjs';
|
|
77
|
+
import { detectRuntimes, knownSkills, pickRuntimeFor, RUNTIMES } from './runtimes.mjs';
|
|
77
78
|
import { createWorkManager } from './work.mjs';
|
|
78
79
|
import { scanLocalSessions } from './localSessions.mjs';
|
|
79
80
|
|
|
80
|
-
async function fetchRoster(haveIds) {
|
|
81
|
+
async function fetchRoster(haveIds, livePreviewSessionIds = []) {
|
|
81
82
|
const url = new URL(FLEET_URL);
|
|
82
83
|
if (haveIds.length) url.searchParams.set('have', haveIds.join(','));
|
|
83
84
|
// What this machine will run at once. The server grows lanes to meet waiting
|
|
@@ -98,6 +99,15 @@ async function fetchRoster(haveIds) {
|
|
|
98
99
|
// so a team can see whether the shared box runs wide open, and enforces
|
|
99
100
|
// nothing (membership is the consent boundary). Older servers ignore it.
|
|
100
101
|
url.searchParams.set('safe', SAFE ? '1' : '0');
|
|
102
|
+
// WHICH PROCESS, so the server can lease preview work to exactly one of two
|
|
103
|
+
// daemons on one credential. Older servers ignore unknown params.
|
|
104
|
+
url.searchParams.set('di', DAEMON_INSTANCE);
|
|
105
|
+
// Which shares this machine is still serving. It rides the poll rather than
|
|
106
|
+
// taking an endpoint of its own: one beat, no floor, and the stale window is
|
|
107
|
+
// the reconcile interval instead of minutes — which matters, because a share
|
|
108
|
+
// the server still calls live is a 530 on somebody's phone. Always set, even
|
|
109
|
+
// empty: '' means "serving none", absent would mean "an older daemon".
|
|
110
|
+
url.searchParams.set('pv', livePreviewSessionIds.join(','));
|
|
101
111
|
// WHICH CLIs this machine actually has, so the app can stop guessing.
|
|
102
112
|
//
|
|
103
113
|
// Until now every surface that listed Gemini or Codex said "not wired up yet"
|
|
@@ -118,6 +128,21 @@ async function fetchRoster(haveIds) {
|
|
|
118
128
|
} catch {
|
|
119
129
|
/* detection is best-effort — a probe must never fail the poll */
|
|
120
130
|
}
|
|
131
|
+
// WHAT THE CLI CAN BE ASKED FOR BY NAME, so the composer can autocomplete a
|
|
132
|
+
// `/` the way the terminal does. Learned from the init event of a turn we
|
|
133
|
+
// already ran (runtimes.mjs) — never probed, because spawning a CLI to fill a
|
|
134
|
+
// dropdown would spend the operator's quota on an affordance.
|
|
135
|
+
//
|
|
136
|
+
// NOT SENT until a turn has taught us: absent means "no turn has run here
|
|
137
|
+
// yet", and the app renders no menu rather than asserting this machine has no
|
|
138
|
+
// skills. An empty report, though, IS a fact and is sent as such — hence the
|
|
139
|
+
// null check rather than a truthiness check on the array.
|
|
140
|
+
try {
|
|
141
|
+
const skills = knownSkills();
|
|
142
|
+
if (skills !== null) url.searchParams.set('skills', skills.join(','));
|
|
143
|
+
} catch {
|
|
144
|
+
/* best-effort — the poll must never fail on a readout */
|
|
145
|
+
}
|
|
121
146
|
// Env-sync identity + materialized version (the Settings "env vN" chip).
|
|
122
147
|
try {
|
|
123
148
|
for (const [k, v] of Object.entries(await envQueryParams())) {
|
|
@@ -359,6 +384,14 @@ export async function runFleetDaemon() {
|
|
|
359
384
|
const nextByAgent = new Map();
|
|
360
385
|
let leaseTtlSeconds = 24 * 60 * 60; // updated from each roster response
|
|
361
386
|
let mcpUrl = MCP_URL;
|
|
387
|
+
// DEAD, and kept only because unpicking it is a rewire rather than a
|
|
388
|
+
// deletion: nothing calls `workers.set` anywhere in this tree. It held
|
|
389
|
+
// DISPATCH lanes, and the server has sent `agents: []` permanently since
|
|
390
|
+
// 2026-08-19, so every loop below iterates nothing. Two `stopPreview` calls
|
|
391
|
+
// hung off it until 2026-08-21 and read as live preview wiring; they were
|
|
392
|
+
// deleted, not rewired. When the Workbench preview lands, its teardown is
|
|
393
|
+
// keyed on sessionId and belongs beside retireWorkSessions in work.mjs —
|
|
394
|
+
// NOT here.
|
|
362
395
|
const workers = new Map(); // agentId -> { state, promise, wt, label }
|
|
363
396
|
let daemonAlive = true; // flipped false on shutdown so the stream stops reconnecting
|
|
364
397
|
let stream = null; // push channel handle (set once the loop is set up)
|
|
@@ -398,15 +431,10 @@ export async function runFleetDaemon() {
|
|
|
398
431
|
} catch {
|
|
399
432
|
/* best-effort */
|
|
400
433
|
}
|
|
401
|
-
// Stop the detached preview (dev server + cloudflared tunnel) — it's its
|
|
402
|
-
// own process group and survives our exit, otherwise leaking a port-bound
|
|
403
|
-
// server + a live tunnel serving a stale branch until reboot.
|
|
404
|
-
try {
|
|
405
|
-
w.state.stopPreview?.();
|
|
406
|
-
} catch {
|
|
407
|
-
/* best-effort */
|
|
408
|
-
}
|
|
409
434
|
}
|
|
435
|
+
// Detached tunnels survive our exit by design, so leaving them would strand
|
|
436
|
+
// a public hostname until the box rebooted.
|
|
437
|
+
shutdownPreviews();
|
|
410
438
|
};
|
|
411
439
|
process.on('SIGINT', () => {
|
|
412
440
|
console.log('');
|
|
@@ -578,6 +606,10 @@ export async function runFleetDaemon() {
|
|
|
578
606
|
processWorkTurns,
|
|
579
607
|
processShipJobs,
|
|
580
608
|
processDiffJobs,
|
|
609
|
+
processPreviewJobs,
|
|
610
|
+
livePreviewIds,
|
|
611
|
+
retirePreviews,
|
|
612
|
+
shutdownPreviews,
|
|
581
613
|
retireWorkSessions,
|
|
582
614
|
reportWorktrees,
|
|
583
615
|
shutdownWork,
|
|
@@ -1138,7 +1170,7 @@ export async function runFleetDaemon() {
|
|
|
1138
1170
|
for (;;) {
|
|
1139
1171
|
let roster;
|
|
1140
1172
|
try {
|
|
1141
|
-
roster = await fetchRoster(buildHave());
|
|
1173
|
+
roster = await fetchRoster(buildHave(), livePreviewIds());
|
|
1142
1174
|
} catch (e) {
|
|
1143
1175
|
if (e.auth) {
|
|
1144
1176
|
fail(`${e.message} — credential revoked or invalid. Shutting down.`);
|
|
@@ -1200,11 +1232,20 @@ export async function runFleetDaemon() {
|
|
|
1200
1232
|
// AFTER the work/ship intake: retirement is the server saying which
|
|
1201
1233
|
// sessions are LIVE, and the guards above (chains, shipping) are populated
|
|
1202
1234
|
// by the intake this same tick.
|
|
1235
|
+
// BEFORE retirement, and the order is load-bearing: `git worktree remove`
|
|
1236
|
+
// under a running dev server leaves it serving bytes from open file handles
|
|
1237
|
+
// in a directory that no longer exists — a human is shown the wrong thing
|
|
1238
|
+
// and nothing errors anywhere.
|
|
1239
|
+
retirePreviews(roster.activeWorkSessions);
|
|
1203
1240
|
retireWorkSessions(roster.activeWorkSessions);
|
|
1204
1241
|
// Diffs somebody has open and is waiting on. Project-scoped rather than
|
|
1205
1242
|
// per-session: `git show` runs from the repo ROOT, which can see a closed
|
|
1206
1243
|
// tab's branch and a shipped commit on main alike.
|
|
1207
1244
|
processDiffJobs(roster.diffJobs);
|
|
1245
|
+
// Shares to open or tear down. CLAIMED before acted on — two daemons on one
|
|
1246
|
+
// credential are both handed this array, and both opening a tunnel strands
|
|
1247
|
+
// a public hostname nobody can settle.
|
|
1248
|
+
processPreviewJobs(roster.previewJobs);
|
|
1208
1249
|
// …and what the SURVIVING ones hold: branch, ahead-of-base, diffstat.
|
|
1209
1250
|
// Throttled inside, never awaited — a `git status` the human cannot run
|
|
1210
1251
|
// themselves from a browser, relayed. After retirement so a directory that
|
|
@@ -1308,11 +1349,6 @@ export async function runFleetDaemon() {
|
|
|
1308
1349
|
} catch {
|
|
1309
1350
|
/* best-effort */
|
|
1310
1351
|
}
|
|
1311
|
-
try {
|
|
1312
|
-
w.state.stopPreview?.();
|
|
1313
|
-
} catch {
|
|
1314
|
-
/* best-effort */
|
|
1315
|
-
}
|
|
1316
1352
|
// Only poll mode's per-lane tree dies with the lane. A live lane owns no
|
|
1317
1353
|
// checkout: the task it was building has its own, which must SURVIVE —
|
|
1318
1354
|
// removing a lane requeues its task, and the next lane to pick that task
|