flowviant 0.56.0 → 0.57.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/childEnv.mjs +113 -0
- package/bin/lib/deploy.mjs +13 -2
- package/bin/lib/devServer.mjs +306 -0
- package/bin/lib/fleet.mjs +78 -3
- package/bin/lib/procRegistry.mjs +134 -0
- package/bin/lib/runtimes.mjs +191 -3
- package/bin/lib/work.mjs +273 -2
- package/package.json +1 -1
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE ENVIRONMENT A SPAWNED COMMAND GETS — and, more to the point, what it does
|
|
3
|
+
* not get.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS FILE EXISTS: `deploy.mjs` carried
|
|
6
|
+
*
|
|
7
|
+
* const env = { ...process.env, ...deployCreds() };
|
|
8
|
+
* delete env.FLEET_TOKEN; // ... keep it out of a command that might echo its env
|
|
9
|
+
*
|
|
10
|
+
* and that `delete` was a LIVE NO-OP. `FLEET_TOKEN` is a JS module constant in
|
|
11
|
+
* `config.mjs`; the environment variable is `FLOWVIANT_FLEET`. So the machine
|
|
12
|
+
* credential sat in the environment of every deploy command — including
|
|
13
|
+
* `target.build`, which is a string the REPO controls — under a confident
|
|
14
|
+
* comment saying it did not. That is the whole argument for an allowlist: a
|
|
15
|
+
* denylist is a claim about a set you cannot see, and it rots into a lie the
|
|
16
|
+
* moment one identifier is wrong or one new secret is added upstream.
|
|
17
|
+
*
|
|
18
|
+
* SO: BUILT FROM `{}`, NEVER FROM `{...process.env}` MINUS NAMES. Anything not
|
|
19
|
+
* named below is absent by construction, and a secret added to the daemon's
|
|
20
|
+
* environment next year is absent without anybody remembering this file.
|
|
21
|
+
*
|
|
22
|
+
* WHAT THIS IS NOT. A spawned command runs as the SAME UID as the daemon.
|
|
23
|
+
* `~/.flowviant/credentials.json` and `~/.flowviant/env-keypair.json` are 0600
|
|
24
|
+
* and readable by it. This is a control against ACCIDENT AND INHERITANCE — a
|
|
25
|
+
* crash reporter, a framework error page that dumps `process.env`, a build log,
|
|
26
|
+
* a process that echoes its own environment — and it is NOT confinement. Real
|
|
27
|
+
* confinement is a separate uid or a namespace and is not in this product. No
|
|
28
|
+
* surface may describe anything here as "sandboxed" or "isolated".
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The complete kept set. Two groups, and both are here for a reason that bit
|
|
33
|
+
* somebody:
|
|
34
|
+
*
|
|
35
|
+
* - the basics a process needs to exist at all;
|
|
36
|
+
* - the TOOLCHAIN SHIMS. An operator on nvm, asdf, volta, pnpm or bun has a
|
|
37
|
+
* PATH that points into a version-manager directory, and without these the
|
|
38
|
+
* PATH we hand over resolves to nothing — the command fails with ENOENT and
|
|
39
|
+
* the failure looks like a bad command rather than a stripped environment.
|
|
40
|
+
*/
|
|
41
|
+
const KEEP = [
|
|
42
|
+
'PATH',
|
|
43
|
+
'HOME',
|
|
44
|
+
'USER',
|
|
45
|
+
'LOGNAME',
|
|
46
|
+
'SHELL',
|
|
47
|
+
'TZ',
|
|
48
|
+
'LANG',
|
|
49
|
+
'LC_ALL',
|
|
50
|
+
'TMPDIR',
|
|
51
|
+
// Toolchain shims.
|
|
52
|
+
'NVM_DIR',
|
|
53
|
+
'NVM_BIN',
|
|
54
|
+
'ASDF_DIR',
|
|
55
|
+
'ASDF_DATA_DIR',
|
|
56
|
+
'VOLTA_HOME',
|
|
57
|
+
'PNPM_HOME',
|
|
58
|
+
'BUN_INSTALL',
|
|
59
|
+
'N_PREFIX',
|
|
60
|
+
'XDG_CACHE_HOME',
|
|
61
|
+
'XDG_DATA_HOME',
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A child environment for a command Flowviant runs on the operator's behalf.
|
|
66
|
+
*
|
|
67
|
+
* `extra` is layered LAST and is the caller's own material — deploy
|
|
68
|
+
* credentials, or nothing. It is never repo-supplied: the deleted preview
|
|
69
|
+
* feature let `.flowviant/preview.json` contribute an `env` map that was
|
|
70
|
+
* layered last and therefore won every collision, which is how a branch got to
|
|
71
|
+
* set `PATH`.
|
|
72
|
+
*/
|
|
73
|
+
export function childEnv({ cwd, extra } = {}) {
|
|
74
|
+
const env = {};
|
|
75
|
+
for (const k of KEEP) {
|
|
76
|
+
if (typeof process.env[k] === 'string') env[k] = process.env[k];
|
|
77
|
+
}
|
|
78
|
+
// Set by us rather than inherited.
|
|
79
|
+
//
|
|
80
|
+
// TERM=dumb: a process that believes it owns a TTY draws progress bars and
|
|
81
|
+
// spinners into a pipe forever, which is unreadable in a log tail and pins a
|
|
82
|
+
// CPU on some tools.
|
|
83
|
+
env.TERM = 'dumb';
|
|
84
|
+
// BROWSER=none: nothing should try to open a browser on a headless box. This
|
|
85
|
+
// is the one survivor of the deleted feature's env extras, and it is
|
|
86
|
+
// anti-annoyance rather than security.
|
|
87
|
+
env.BROWSER = 'none';
|
|
88
|
+
if (cwd) env.PWD = cwd;
|
|
89
|
+
// Deliberately NOT set: NODE_ENV (asserting 'development' would be Flowviant
|
|
90
|
+
// choosing what the framework should decide) and PORT (we never hint a port —
|
|
91
|
+
// the port is DISCOVERED by cwd attribution, and a hinted one has no
|
|
92
|
+
// attribution behind it).
|
|
93
|
+
return extra ? { ...env, ...extra } : env;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The names this deliberately drops, for the test to assert against. NOT the
|
|
97
|
+
* mechanism — the mechanism is the allowlist above, and this list is only ever
|
|
98
|
+
* a sample of what it excludes. Adding a name here changes nothing. */
|
|
99
|
+
export const DROPPED_SAMPLE = [
|
|
100
|
+
'FLOWVIANT_FLEET',
|
|
101
|
+
'FLOWVIANT_FLEET_URL',
|
|
102
|
+
'FLOWVIANT_API_URL',
|
|
103
|
+
'FLOWVIANT_MCP_URL',
|
|
104
|
+
'FLOWVIANT_MODEL',
|
|
105
|
+
'ANTHROPIC_API_KEY',
|
|
106
|
+
'ANTHROPIC_AUTH_TOKEN',
|
|
107
|
+
'GH_TOKEN',
|
|
108
|
+
'GITHUB_TOKEN',
|
|
109
|
+
'SSH_AUTH_SOCK',
|
|
110
|
+
'CLOUDFLARE_API_TOKEN',
|
|
111
|
+
'AWS_SECRET_ACCESS_KEY',
|
|
112
|
+
'npm_config__authToken',
|
|
113
|
+
];
|
package/bin/lib/deploy.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import { join } from 'node:path';
|
|
|
16
16
|
import { FLEET_URL, FLEET_TOKEN, USER_AGENT, DAEMON_INSTANCE } from './config.mjs';
|
|
17
17
|
import { c, note, ok, warn } from './ui.mjs';
|
|
18
18
|
import { deployCreds, appSecretsFor, scrub, myPubB64 } from './env.mjs';
|
|
19
|
+
import { childEnv } from './childEnv.mjs';
|
|
19
20
|
|
|
20
21
|
const deployUrl = (tail) => FLEET_URL.replace(/\/agents\/?$/, `/${tail}`);
|
|
21
22
|
|
|
@@ -206,8 +207,18 @@ export function processDeployJobs(jobs, ctx) {
|
|
|
206
207
|
}
|
|
207
208
|
|
|
208
209
|
async function runDeploy(job, target, ctx) {
|
|
209
|
-
|
|
210
|
-
|
|
210
|
+
// AN ALLOWLIST, not `{...process.env}` minus names. What stood here was
|
|
211
|
+
//
|
|
212
|
+
// const env = { ...process.env, ...deployCreds() };
|
|
213
|
+
// delete env.FLEET_TOKEN;
|
|
214
|
+
//
|
|
215
|
+
// and that delete was a NO-OP: `FLEET_TOKEN` is a module constant in
|
|
216
|
+
// config.mjs, while the environment variable is `FLOWVIANT_FLEET`. The
|
|
217
|
+
// machine credential was therefore in the environment of every deploy
|
|
218
|
+
// command — and of `target.build`, which is a string the REPO controls —
|
|
219
|
+
// under a comment asserting the opposite. A denylist is a claim about a set
|
|
220
|
+
// you cannot see; this is built from {} instead.
|
|
221
|
+
const env = childEnv({ cwd: ctx.repoRoot, extra: deployCreds() }); // infra creds; never a file
|
|
211
222
|
const logs = [];
|
|
212
223
|
// Rollback is a single wrangler command; deploy is build → secrets → deploy.
|
|
213
224
|
if (job.kind === 'rollback') {
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RUNNING THE PROJECT'S DEV COMMAND IN A TAB'S WORKTREE.
|
|
3
|
+
*
|
|
4
|
+
* THIS FILE MUST NEVER READ A REPO FILE TO DECIDE WHAT TO EXECUTE. That is the
|
|
5
|
+
* one rule, and it is the whole difference between this and the live-preview
|
|
6
|
+
* target deleted in 2026-08-21, whose obituary is in `preview.mjs`: that one
|
|
7
|
+
* read a command out of `.flowviant/preview.json` — a file the BRANCH controls
|
|
8
|
+
* — or inferred one from package.json, then `spawn(cmd, {shell: true})` with
|
|
9
|
+
* `{...process.env}`, running `npm install` and its lifecycle scripts and
|
|
10
|
+
* handing the resulting internet-exposed process the daemon's own credential.
|
|
11
|
+
* One click behind a button and a hostile branch owned the machine.
|
|
12
|
+
*
|
|
13
|
+
* Here the argv arrives ON THE JOB, already parsed from a string a human
|
|
14
|
+
* approved once for this project and stored server-side. The branch cannot
|
|
15
|
+
* change it. This file re-validates the SHAPE at its own boundary — one place
|
|
16
|
+
* doing a check is one deploy away from being zero places — and spawns with
|
|
17
|
+
* `shell: false`.
|
|
18
|
+
*
|
|
19
|
+
* THE HONEST LIMIT, stated here rather than implied: pinning the command does
|
|
20
|
+
* not pin what the command does. `npm run dev` dereferences to `scripts.dev`,
|
|
21
|
+
* which the branch writes. What this buys is that a human chose the ENTRYPOINT
|
|
22
|
+
* in the open, plus a child environment that is a strict subset of what the
|
|
23
|
+
* agent's own `npm run dev` gets today. The child runs as the SAME UID as this
|
|
24
|
+
* daemon and `~/.flowviant/credentials.json` is 0600 and readable by it. The
|
|
25
|
+
* env allowlist is a control against ACCIDENT AND INHERITANCE — a crash
|
|
26
|
+
* reporter, an error page that dumps `process.env`, a build log — and it is NOT
|
|
27
|
+
* confinement. Nothing in the UI may say "sandboxed" or "isolated".
|
|
28
|
+
*
|
|
29
|
+
* NO PORT IS EVER SCRAPED. The deleted feature learned its port from the
|
|
30
|
+
* child's stdout and tunnelled a guess when it found none. A scraped port has
|
|
31
|
+
* no attribution behind it, and "this port was measured, by cwd, inside THIS
|
|
32
|
+
* worktree" is the only real security control this feature family has. The port
|
|
33
|
+
* here comes from `listenersIn` or it does not come at all — and a running
|
|
34
|
+
* server with no measured port is a REAL state that gets a sentence.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { spawn } from 'node:child_process';
|
|
38
|
+
import { existsSync } from 'node:fs';
|
|
39
|
+
import { join } from 'node:path';
|
|
40
|
+
import { homedir } from 'node:os';
|
|
41
|
+
import { childEnv } from './childEnv.mjs';
|
|
42
|
+
import { mutateRegistry, processAlive, readRegistry } from './procRegistry.mjs';
|
|
43
|
+
import { listenersIn } from './listeners.mjs';
|
|
44
|
+
import { scrub } from './env.mjs';
|
|
45
|
+
|
|
46
|
+
const FLOWVIANT_DIR = join(homedir(), '.flowviant');
|
|
47
|
+
const REGISTRY = join(FLOWVIANT_DIR, 'devruns.json');
|
|
48
|
+
const REGISTRY_LOCK = join(FLOWVIANT_DIR, 'devruns.lock');
|
|
49
|
+
|
|
50
|
+
/** Output we keep. Only the tail is ever uplinked, and it is scrubbed on the
|
|
51
|
+
* way out: a dev server routinely prints connection strings. */
|
|
52
|
+
const RING_BYTES = 512 * 1024;
|
|
53
|
+
const TAIL_BYTES = 4096;
|
|
54
|
+
/** How long we wait for the command to bind something inside the worktree
|
|
55
|
+
* before reporting it as running-but-unmeasured. */
|
|
56
|
+
const BIND_WATCH_MS = 45_000;
|
|
57
|
+
/** At most this many restarts inside the window, and only for a run that bound
|
|
58
|
+
* at least once — a server that never bound is a broken command, not a crash,
|
|
59
|
+
* and restarting it burns the box. */
|
|
60
|
+
const MAX_RESTARTS = 3;
|
|
61
|
+
const RESTART_WINDOW_MS = 10 * 60_000;
|
|
62
|
+
const BACKOFF_MS = [2_000, 8_000, 30_000];
|
|
63
|
+
const PACKAGE_MANAGERS = new Set(['npm', 'pnpm', 'yarn', 'bun']);
|
|
64
|
+
|
|
65
|
+
const remember = (entry) =>
|
|
66
|
+
mutateRegistry(FLOWVIANT_DIR, REGISTRY, REGISTRY_LOCK, (list) => [
|
|
67
|
+
...list.filter((e) => e.pid !== entry.pid),
|
|
68
|
+
entry,
|
|
69
|
+
]);
|
|
70
|
+
const forget = (pid) =>
|
|
71
|
+
mutateRegistry(FLOWVIANT_DIR, REGISTRY, REGISTRY_LOCK, (list) =>
|
|
72
|
+
list.filter((e) => e.pid !== pid)
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
/** SIGTERM the GROUP, wait, then SIGKILL it. `npm run dev` spawns grandchildren
|
|
76
|
+
* that outlive a kill of the parent, which is what `detached: true` and the
|
|
77
|
+
* negative pid are for. */
|
|
78
|
+
function killGroup(pid, graceMs = 8_000) {
|
|
79
|
+
const signal = (sig) => {
|
|
80
|
+
try {
|
|
81
|
+
process.kill(-pid, sig);
|
|
82
|
+
} catch {
|
|
83
|
+
try {
|
|
84
|
+
process.kill(pid, sig);
|
|
85
|
+
} catch {
|
|
86
|
+
/* already gone */
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
signal('SIGTERM');
|
|
91
|
+
setTimeout(() => {
|
|
92
|
+
if (processAlive(pid)) signal('SIGKILL');
|
|
93
|
+
}, graceMs).unref?.();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* THE FRESH-WORKTREE ANSWER, measured rather than discovered as a bug.
|
|
98
|
+
*
|
|
99
|
+
* `ensureWorktree` is a bare `git worktree add`, so a new tab has source and no
|
|
100
|
+
* `node_modules`, and the first run there would fail with something unhelpful.
|
|
101
|
+
* This is a measurement, and it routes dependency installation to the one place
|
|
102
|
+
* that should own it: a turn in the tab, with a human asking and an audit row
|
|
103
|
+
* for it. The button is never silently broken and the remedy is one sentence.
|
|
104
|
+
*/
|
|
105
|
+
export function missingDeps(worktree, argv) {
|
|
106
|
+
if (!PACKAGE_MANAGERS.has(argv[0])) return false;
|
|
107
|
+
return !existsSync(join(worktree, 'node_modules'));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Start the command and supervise it.
|
|
112
|
+
*
|
|
113
|
+
* `onState` is called with `{started, port, pid, error, endedReason, logTail,
|
|
114
|
+
* restarts}` at each transition; the caller reports it upward. Resolves once
|
|
115
|
+
* the first outcome is known — bound, or running-unmeasured, or failed — and
|
|
116
|
+
* keeps supervising after that.
|
|
117
|
+
*/
|
|
118
|
+
export function startDevServer({ sessionId, worktree, argv, log, onState, onExit }) {
|
|
119
|
+
if (missingDeps(worktree, argv)) {
|
|
120
|
+
return Promise.resolve({
|
|
121
|
+
ok: false,
|
|
122
|
+
endedReason: 'no_deps',
|
|
123
|
+
error:
|
|
124
|
+
"No dependencies are installed in this tab's worktree. Ask your Claude to install them, then run dev again.",
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
let ring = '';
|
|
129
|
+
let child = null;
|
|
130
|
+
let stopped = false;
|
|
131
|
+
let everBound = false;
|
|
132
|
+
let restarts = 0;
|
|
133
|
+
const restartTimes = [];
|
|
134
|
+
|
|
135
|
+
const append = (buf) => {
|
|
136
|
+
ring = (ring + buf.toString('utf8')).slice(-RING_BYTES);
|
|
137
|
+
};
|
|
138
|
+
const tail = () => scrub(ring.slice(-TAIL_BYTES));
|
|
139
|
+
|
|
140
|
+
const spawnOnce = () => {
|
|
141
|
+
child = spawn(argv[0], argv.slice(1), {
|
|
142
|
+
cwd: worktree,
|
|
143
|
+
env: childEnv({ cwd: worktree }),
|
|
144
|
+
// Its own process group, so the whole tree can be reaped. Load-bearing:
|
|
145
|
+
// a package manager is a wrapper and the server is its grandchild.
|
|
146
|
+
detached: true,
|
|
147
|
+
shell: false,
|
|
148
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
149
|
+
});
|
|
150
|
+
// PERMANENT DRAIN LISTENERS, and this is not optional. A detached
|
|
151
|
+
// long-lived child on piped stdio with no reader BLOCKS ON WRITE once the
|
|
152
|
+
// pipe buffer fills, so a dev server would hang after a few minutes of HMR
|
|
153
|
+
// logs — the least diagnosable failure this feature could have.
|
|
154
|
+
child.stdout?.on('data', append);
|
|
155
|
+
child.stderr?.on('data', append);
|
|
156
|
+
return child;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
return new Promise((resolve) => {
|
|
160
|
+
let settled = false;
|
|
161
|
+
const finish = (v) => {
|
|
162
|
+
if (settled) return;
|
|
163
|
+
settled = true;
|
|
164
|
+
resolve(v);
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const attachExit = () => {
|
|
168
|
+
child.once('error', (e) => {
|
|
169
|
+
forget(child?.pid);
|
|
170
|
+
finish({ ok: false, endedReason: 'spawn_failed', error: String(e?.message || e) });
|
|
171
|
+
});
|
|
172
|
+
child.once('exit', (code, signal) => {
|
|
173
|
+
const pid = child?.pid;
|
|
174
|
+
forget(pid);
|
|
175
|
+
if (stopped) return;
|
|
176
|
+
// A command that NEVER bound is a broken command, not a crash. Restarting
|
|
177
|
+
// it would spin the box on somebody's typo.
|
|
178
|
+
const now = Date.now();
|
|
179
|
+
while (restartTimes.length && now - restartTimes[0] > RESTART_WINDOW_MS) restartTimes.shift();
|
|
180
|
+
if (everBound && restartTimes.length < MAX_RESTARTS) {
|
|
181
|
+
const delay = BACKOFF_MS[Math.min(restartTimes.length, BACKOFF_MS.length - 1)];
|
|
182
|
+
restartTimes.push(now);
|
|
183
|
+
restarts += 1;
|
|
184
|
+
log?.(`dev server exited (${signal || code}); restarting in ${delay / 1000}s`);
|
|
185
|
+
setTimeout(() => {
|
|
186
|
+
if (stopped) return;
|
|
187
|
+
spawnOnce();
|
|
188
|
+
attachExit();
|
|
189
|
+
remember(entryFor());
|
|
190
|
+
onState?.({ started: true, port: null, pid: child.pid, restarts, logTail: tail() });
|
|
191
|
+
}, delay).unref?.();
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
onExit?.({
|
|
195
|
+
exitCode: typeof code === 'number' ? code : null,
|
|
196
|
+
signal: signal || null,
|
|
197
|
+
logTail: tail(),
|
|
198
|
+
endedReason: everBound ? 'crashed' : 'spawn_failed',
|
|
199
|
+
error: everBound
|
|
200
|
+
? `the dev server exited (${signal || `code ${code}`})`
|
|
201
|
+
: `the command exited immediately (${signal || `code ${code}`}) without listening`,
|
|
202
|
+
});
|
|
203
|
+
finish({ ok: false, endedReason: everBound ? 'crashed' : 'spawn_failed' });
|
|
204
|
+
});
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const entryFor = () => ({
|
|
208
|
+
sessionId,
|
|
209
|
+
pid: child.pid,
|
|
210
|
+
cwd: worktree,
|
|
211
|
+
startedAt: Date.now(),
|
|
212
|
+
owner: process.pid,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
spawnOnce();
|
|
217
|
+
} catch (e) {
|
|
218
|
+
finish({ ok: false, endedReason: 'spawn_failed', error: String(e?.message || e) });
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
attachExit();
|
|
222
|
+
remember(entryFor());
|
|
223
|
+
|
|
224
|
+
// WATCH FOR THE BIND through `listenersIn` and nowhere else.
|
|
225
|
+
const deadline = Date.now() + BIND_WATCH_MS;
|
|
226
|
+
const poll = setInterval(() => {
|
|
227
|
+
if (stopped || !child || child.exitCode !== null) {
|
|
228
|
+
clearInterval(poll);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const found = listenersIn(worktree)[0];
|
|
232
|
+
if (found) {
|
|
233
|
+
clearInterval(poll);
|
|
234
|
+
everBound = true;
|
|
235
|
+
onState?.({ started: true, port: found.port, pid: child.pid, restarts, logTail: tail() });
|
|
236
|
+
finish({ ok: true, port: found.port, pid: child.pid, stop, restarts });
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (Date.now() > deadline) {
|
|
240
|
+
clearInterval(poll);
|
|
241
|
+
// RUNNING, NOTHING MEASURED. A real state and not a spinner: a
|
|
242
|
+
// `docker compose up` binds from inside a container whose cwd is not
|
|
243
|
+
// this worktree and will never be attributed.
|
|
244
|
+
onState?.({ started: true, port: null, pid: child.pid, restarts, logTail: tail() });
|
|
245
|
+
finish({ ok: true, port: null, pid: child.pid, stop, restarts });
|
|
246
|
+
}
|
|
247
|
+
}, 1000);
|
|
248
|
+
poll.unref?.();
|
|
249
|
+
|
|
250
|
+
function stop() {
|
|
251
|
+
stopped = true;
|
|
252
|
+
clearInterval(poll);
|
|
253
|
+
const pid = child?.pid;
|
|
254
|
+
if (pid) {
|
|
255
|
+
killGroup(pid);
|
|
256
|
+
forget(pid);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* What survived a daemon restart.
|
|
264
|
+
*
|
|
265
|
+
* IDENTITY IS NOT THE COMMAND STRING. `stillOurs` in `preview.mjs` matches a
|
|
266
|
+
* substring of `/proc/<pid>/cmdline`, which cannot work here: `npm run dev` is
|
|
267
|
+
* a human-authored string identical across two tabs, two worktrees, and the
|
|
268
|
+
* driver's own hand-started server. Identity is the CWD — the same readlink
|
|
269
|
+
* `listeners.mjs` already does — plus the pid still being alive. Both must
|
|
270
|
+
* hold, and a could-not-measure is NOT a match and kills nothing.
|
|
271
|
+
*/
|
|
272
|
+
export function adoptableDevRuns() {
|
|
273
|
+
const out = [];
|
|
274
|
+
for (const e of readRegistry(REGISTRY)) {
|
|
275
|
+
if (!e || typeof e.sessionId !== 'string' || !Number.isInteger(e.pid)) continue;
|
|
276
|
+
// Another LIVE daemon owns it — leave it alone entirely.
|
|
277
|
+
if (e.owner && e.owner !== process.pid && processAlive(e.owner)) continue;
|
|
278
|
+
if (!processAlive(e.pid)) continue;
|
|
279
|
+
out.push(e);
|
|
280
|
+
}
|
|
281
|
+
return out;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Kill a registry entry's process group and drop the row. Used when the
|
|
285
|
+
* session it belonged to is gone. */
|
|
286
|
+
export function killDevRunEntry(entry) {
|
|
287
|
+
if (!entry || !Number.isInteger(entry.pid)) return;
|
|
288
|
+
killGroup(entry.pid);
|
|
289
|
+
forget(entry.pid);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Adopt or reap what the previous process left behind. `activeIds` is the set
|
|
293
|
+
* of sessions still live on the server; a run whose session is gone is killed,
|
|
294
|
+
* and one whose session survives is handed back to the caller to re-supervise. */
|
|
295
|
+
export function reapOrphanDevRuns(activeIds, log) {
|
|
296
|
+
const adopt = [];
|
|
297
|
+
for (const e of adoptableDevRuns()) {
|
|
298
|
+
if (Array.isArray(activeIds) && !activeIds.includes(e.sessionId)) {
|
|
299
|
+
log?.(`dev server for a closed tab (pid ${e.pid}) — stopping it`);
|
|
300
|
+
killDevRunEntry(e);
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
adopt.push(e);
|
|
304
|
+
}
|
|
305
|
+
return adopt;
|
|
306
|
+
}
|
package/bin/lib/fleet.mjs
CHANGED
|
@@ -76,11 +76,23 @@ import {
|
|
|
76
76
|
} from './env.mjs';
|
|
77
77
|
import { processDeployJobs, reportDeployConfig } from './deploy.mjs';
|
|
78
78
|
import { machineSnapshot } from './resources.mjs';
|
|
79
|
-
import {
|
|
79
|
+
import {
|
|
80
|
+
detectRuntimes,
|
|
81
|
+
knownSkills,
|
|
82
|
+
pickRuntimeFor,
|
|
83
|
+
probeSkillsOnce,
|
|
84
|
+
recordSkills,
|
|
85
|
+
RUNTIMES,
|
|
86
|
+
} from './runtimes.mjs';
|
|
80
87
|
import { createWorkManager } from './work.mjs';
|
|
81
88
|
import { scanLocalSessions } from './localSessions.mjs';
|
|
82
89
|
|
|
83
|
-
async function fetchRoster(
|
|
90
|
+
async function fetchRoster(
|
|
91
|
+
haveIds,
|
|
92
|
+
livePreviewSessionIds = [],
|
|
93
|
+
heldSessionIds = [],
|
|
94
|
+
liveDevRunSessionIds = []
|
|
95
|
+
) {
|
|
84
96
|
const url = new URL(FLEET_URL);
|
|
85
97
|
if (haveIds.length) url.searchParams.set('have', haveIds.join(','));
|
|
86
98
|
// What this machine will run at once. The server grows lanes to meet waiting
|
|
@@ -110,6 +122,11 @@ async function fetchRoster(haveIds, livePreviewSessionIds = [], heldSessionIds =
|
|
|
110
122
|
// the server still calls live is a 530 on somebody's phone. Always set, even
|
|
111
123
|
// empty: '' means "serving none", absent would mean "an older daemon".
|
|
112
124
|
url.searchParams.set('pv', livePreviewSessionIds.join(','));
|
|
125
|
+
// The dev runs this machine is still carrying. Same rule as `pv`: always set,
|
|
126
|
+
// even empty — '' means "running none", absent would mean "an older daemon",
|
|
127
|
+
// and a run the server still calls running with nothing behind it is a chip
|
|
128
|
+
// that says Serving over a dead port.
|
|
129
|
+
url.searchParams.set('dr', liveDevRunSessionIds.join(','));
|
|
113
130
|
// The sessions this daemon holds a worktree for. Its LEASE on each renews
|
|
114
131
|
// here — one beat, no extra endpoint, and the server can tell "this daemon is
|
|
115
132
|
// still serving that tab" from "it went away" within a reconcile interval
|
|
@@ -440,6 +457,11 @@ export async function runFleetDaemon() {
|
|
|
440
457
|
// Kill any preview dev-server/tunnel groups a previously-crashed daemon left
|
|
441
458
|
// running (detached children survive an ungraceful exit) before we start fresh.
|
|
442
459
|
reapOrphanPreviews((m) => info(m));
|
|
460
|
+
// Dev servers are ADOPTED rather than reaped when their session is still
|
|
461
|
+
// live: a self-update or a same-repo takeover leaves them running on purpose,
|
|
462
|
+
// and the successor must supervise them or the row says running while nothing
|
|
463
|
+
// owns the process. The activeWorkSessions list is not known yet at startup,
|
|
464
|
+
// so the first reconcile does the adopting — see `adoptDevRuns`.
|
|
443
465
|
|
|
444
466
|
// Persistent worktree home (0.9.0) — survives daemon restarts AND reboots,
|
|
445
467
|
// so Ctrl+C mid-task never loses local work. Keyed per repo path.
|
|
@@ -558,6 +580,25 @@ export async function runFleetDaemon() {
|
|
|
558
580
|
// Detached tunnels survive our exit by design, so leaving them would strand
|
|
559
581
|
// a public hostname until the box rebooted.
|
|
560
582
|
shutdownPreviews();
|
|
583
|
+
// AND THE DEV SERVERS, on every path that reaches here — all four are a
|
|
584
|
+
// stand-down: Ctrl-C, a service manager's SIGTERM, a revoked credential, a
|
|
585
|
+
// commanded stop.
|
|
586
|
+
//
|
|
587
|
+
// A SAME-REPO TAKEOVER IS DELIBERATELY NOT DISTINGUISHED, and that is a
|
|
588
|
+
// stated limitation rather than an oversight. The takeover asks the holder
|
|
589
|
+
// to stand down with SIGTERM, which is byte-for-byte what `systemctl stop`
|
|
590
|
+
// sends, so this handler cannot tell "another daemon is about to serve this
|
|
591
|
+
// repo in one second" from "this box is going down". Killing is the safe
|
|
592
|
+
// reading: the wrong guess in the other direction leaves dev servers
|
|
593
|
+
// running with nothing supervising them until a reboot. The cost is that
|
|
594
|
+
// re-running `flowviant` in your own repo restarts the app you were
|
|
595
|
+
// watching, and the successor's `adoptDevRuns` finds nothing.
|
|
596
|
+
//
|
|
597
|
+
// The SELF-UPDATE re-exec — the common unattended case — does NOT reach
|
|
598
|
+
// teardown, so its runs survive and are adopted, which is the half of
|
|
599
|
+
// "consistently running" this delivers today. Distinguishing a takeover
|
|
600
|
+
// would need the successor to mark the lock before it signals.
|
|
601
|
+
shutdownDevRuns(true);
|
|
561
602
|
};
|
|
562
603
|
process.on('SIGINT', () => {
|
|
563
604
|
console.log('');
|
|
@@ -734,6 +775,11 @@ export async function runFleetDaemon() {
|
|
|
734
775
|
livePreviewIds,
|
|
735
776
|
retirePreviews,
|
|
736
777
|
shutdownPreviews,
|
|
778
|
+
processDevRunJobs,
|
|
779
|
+
liveDevRunIds,
|
|
780
|
+
retireDevRuns,
|
|
781
|
+
shutdownDevRuns,
|
|
782
|
+
adoptDevRuns,
|
|
737
783
|
retireWorkSessions,
|
|
738
784
|
reportWorktrees,
|
|
739
785
|
shutdownWork,
|
|
@@ -1185,6 +1231,13 @@ export async function runFleetDaemon() {
|
|
|
1185
1231
|
label: c.cyan('[wiki]'),
|
|
1186
1232
|
streamJson: true,
|
|
1187
1233
|
onActivity,
|
|
1234
|
+
// FREE SKILLS, off a turn that was running anyway. This stream is
|
|
1235
|
+
// already parsed and its init event already carries the CLI's own
|
|
1236
|
+
// resolved skill set — the same fact a tab turn teaches — so the
|
|
1237
|
+
// only thing missing was the handler. The wiki turn runs in a
|
|
1238
|
+
// detached worktree of THIS repo, so its `.claude/skills` and the
|
|
1239
|
+
// machine's personal ones resolve identically to a tab's.
|
|
1240
|
+
onInit: (i) => recordSkills(i.skills),
|
|
1188
1241
|
onSpawn: (ch) => {
|
|
1189
1242
|
wikiChild = ch;
|
|
1190
1243
|
},
|
|
@@ -1229,6 +1282,8 @@ export async function runFleetDaemon() {
|
|
|
1229
1282
|
label: c.cyan('[wiki]'),
|
|
1230
1283
|
streamJson: true,
|
|
1231
1284
|
onActivity,
|
|
1285
|
+
// Same free harvest as the sweep above.
|
|
1286
|
+
onInit: (i) => recordSkills(i.skills),
|
|
1232
1287
|
onSpawn: (ch) => {
|
|
1233
1288
|
wikiChild = ch;
|
|
1234
1289
|
},
|
|
@@ -1330,7 +1385,7 @@ export async function runFleetDaemon() {
|
|
|
1330
1385
|
for (;;) {
|
|
1331
1386
|
let roster;
|
|
1332
1387
|
try {
|
|
1333
|
-
roster = await fetchRoster(buildHave(), livePreviewIds(), heldSessionIds());
|
|
1388
|
+
roster = await fetchRoster(buildHave(), livePreviewIds(), heldSessionIds(), liveDevRunIds());
|
|
1334
1389
|
} catch (e) {
|
|
1335
1390
|
if (e.auth) {
|
|
1336
1391
|
fail(`${e.message} — credential revoked or invalid. Shutting down.`);
|
|
@@ -1449,6 +1504,18 @@ export async function runFleetDaemon() {
|
|
|
1449
1504
|
// in a directory that no longer exists — a human is shown the wrong thing
|
|
1450
1505
|
// and nothing errors anywhere.
|
|
1451
1506
|
retirePreviews(roster.activeWorkSessions);
|
|
1507
|
+
// THEN the process the tunnel pointed at, and only then the worktree. A
|
|
1508
|
+
// viewer must not see a 502 from a gate whose origin vanished, and
|
|
1509
|
+
// `retireWorkSessions`'s dirty check inspects only TRACKED files — so
|
|
1510
|
+
// `git worktree remove` would happily pull the directory out from under a
|
|
1511
|
+
// running node process, which then serves bytes from open file handles in
|
|
1512
|
+
// a directory that no longer exists, with no error anywhere.
|
|
1513
|
+
// ADOPT BEFORE RETIRING. A run left alive by a self-update or a takeover
|
|
1514
|
+
// must be re-attached before the retire pass can decide it is unowned;
|
|
1515
|
+
// doing it the other way round would kill exactly the processes this
|
|
1516
|
+
// feature exists to keep.
|
|
1517
|
+
adoptDevRuns(roster.activeWorkSessions);
|
|
1518
|
+
retireDevRuns(roster.activeWorkSessions);
|
|
1452
1519
|
// A session another daemon on this credential is serving is NOT a closed
|
|
1453
1520
|
// tab. Without this the daemon that lost the lease removes the worktree the
|
|
1454
1521
|
// winner is working in — absence would mean "somebody else won" instead of
|
|
@@ -1467,6 +1534,7 @@ export async function runFleetDaemon() {
|
|
|
1467
1534
|
// credential are both handed this array, and both opening a tunnel strands
|
|
1468
1535
|
// a public hostname nobody can settle.
|
|
1469
1536
|
processPreviewJobs(roster.previewJobs);
|
|
1537
|
+
processDevRunJobs(roster.devRunJobs);
|
|
1470
1538
|
// …and what the SURVIVING ones hold: branch, ahead-of-base, diffstat.
|
|
1471
1539
|
// Throttled inside, never awaited — a `git status` the human cannot run
|
|
1472
1540
|
// themselves from a browser, relayed. After retirement so a directory that
|
|
@@ -1476,6 +1544,13 @@ export async function runFleetDaemon() {
|
|
|
1476
1544
|
// the daemon's own worktrees are carved out (a session the daemon spawned
|
|
1477
1545
|
// is already a tab, not something to offer adopting).
|
|
1478
1546
|
void maybeReportLocalSessions({ repoRoot, excludeDirs: [baseDir] });
|
|
1547
|
+
// WHAT `/` CAN OFFER, on a machine no turn has taught yet. One-shot and
|
|
1548
|
+
// self-cancelling (it returns immediately if a turn has already reported),
|
|
1549
|
+
// never awaited, and it lands in the cache that the NEXT poll reads — so
|
|
1550
|
+
// nothing here waits on a child process. In the loop rather than at
|
|
1551
|
+
// startup on purpose: a daemon that has been up since before this release
|
|
1552
|
+
// gets measured too, without needing a restart to earn its own menu.
|
|
1553
|
+
probeSkillsOnce(repoRoot);
|
|
1479
1554
|
processCleanupJobs(roster.cleanupJobs);
|
|
1480
1555
|
const rosterIds = new Set(roster.agents.map((a) => a.agentId));
|
|
1481
1556
|
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A DISK REGISTRY OF DETACHED PROCESSES — one implementation, two users.
|
|
3
|
+
*
|
|
4
|
+
* The daemon spawns things that outlive it: cloudflared tunnels, and now dev
|
|
5
|
+
* servers. If the daemon dies without tearing them down, the successor has to
|
|
6
|
+
* find them — a public tunnel nobody is minding, or a dev server holding a port
|
|
7
|
+
* in a worktree about to be `git worktree remove`d, are both worse than the
|
|
8
|
+
* crash that caused them.
|
|
9
|
+
*
|
|
10
|
+
* Lifted verbatim out of `preview.mjs`, which grew all of this for the tunnel
|
|
11
|
+
* and now shares it rather than being copied. Two things were ADDED on the way
|
|
12
|
+
* out, because a dev server lives for hours where a tunnel lived for minutes
|
|
13
|
+
* and long-lived rows make both matter:
|
|
14
|
+
*
|
|
15
|
+
* - an entry CAP, so a registry cannot grow without bound;
|
|
16
|
+
* - a TTL sweep for entries whose pid is long dead, so a file nobody prunes
|
|
17
|
+
* does not become a file nobody can read.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
closeSync,
|
|
22
|
+
mkdirSync,
|
|
23
|
+
openSync,
|
|
24
|
+
readFileSync,
|
|
25
|
+
renameSync,
|
|
26
|
+
statSync,
|
|
27
|
+
unlinkSync,
|
|
28
|
+
writeFileSync,
|
|
29
|
+
} from 'node:fs';
|
|
30
|
+
|
|
31
|
+
const LOCK_STALE_MS = 15_000;
|
|
32
|
+
const MAX_ENTRIES = 32;
|
|
33
|
+
const ENTRY_TTL_MS = 7 * 24 * 60 * 60_000;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Best-effort exclusive lock. Returns a release function; on failure returns
|
|
37
|
+
* null and the caller proceeds UNLOCKED — losing an entry is bad, refusing to
|
|
38
|
+
* record one at all is worse.
|
|
39
|
+
*/
|
|
40
|
+
export function acquireLock(dir, lockPath) {
|
|
41
|
+
try {
|
|
42
|
+
mkdirSync(dir, { recursive: true });
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
for (let i = 0; i < 30; i++) {
|
|
47
|
+
try {
|
|
48
|
+
const fd = openSync(lockPath, 'wx');
|
|
49
|
+
closeSync(fd);
|
|
50
|
+
return () => {
|
|
51
|
+
try {
|
|
52
|
+
unlinkSync(lockPath);
|
|
53
|
+
} catch {
|
|
54
|
+
/* already released */
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
} catch {
|
|
58
|
+
// Held — unless it was left behind by something that died holding it.
|
|
59
|
+
try {
|
|
60
|
+
if (Date.now() - statSync(lockPath).mtimeMs > LOCK_STALE_MS) {
|
|
61
|
+
unlinkSync(lockPath);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
} catch {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const until = Date.now() + 20;
|
|
68
|
+
while (Date.now() < until) {
|
|
69
|
+
/* busy-wait: 20ms, 30 times, then give up entirely */
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function readRegistry(path) {
|
|
77
|
+
try {
|
|
78
|
+
const v = JSON.parse(readFileSync(path, 'utf8'));
|
|
79
|
+
return Array.isArray(v) ? v : [];
|
|
80
|
+
} catch {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Atomic: write a sibling temp file and rename over the target, so a reader
|
|
86
|
+
* never sees a half-written array. */
|
|
87
|
+
export function writeRegistry(dir, path, list) {
|
|
88
|
+
try {
|
|
89
|
+
mkdirSync(dir, { recursive: true });
|
|
90
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
91
|
+
writeFileSync(tmp, JSON.stringify(list));
|
|
92
|
+
renameSync(tmp, path);
|
|
93
|
+
} catch {
|
|
94
|
+
/* best-effort */
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Signal-0 liveness. EPERM means alive and not ours, which is still alive. */
|
|
99
|
+
export function processAlive(pid) {
|
|
100
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
101
|
+
try {
|
|
102
|
+
process.kill(pid, 0);
|
|
103
|
+
return true;
|
|
104
|
+
} catch (e) {
|
|
105
|
+
return e.code === 'EPERM';
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Read-modify-write under the lock, then prune.
|
|
111
|
+
*
|
|
112
|
+
* The prune is here rather than at the call sites so it cannot be forgotten by
|
|
113
|
+
* one of them: an entry whose pid has been dead for a week is not a process
|
|
114
|
+
* anybody is going to reap, and keeping it only makes the next reader slower
|
|
115
|
+
* and the next pid collision more likely.
|
|
116
|
+
*/
|
|
117
|
+
export function mutateRegistry(dir, path, lockPath, fn) {
|
|
118
|
+
const release = acquireLock(dir, lockPath);
|
|
119
|
+
try {
|
|
120
|
+
const next = fn(readRegistry(path));
|
|
121
|
+
const now = Date.now();
|
|
122
|
+
const pruned = next
|
|
123
|
+
.filter((e) => {
|
|
124
|
+
if (processAlive(e?.pid)) return true;
|
|
125
|
+
const started = Number(e?.startedAt ?? 0);
|
|
126
|
+
return started > 0 && now - started < ENTRY_TTL_MS;
|
|
127
|
+
})
|
|
128
|
+
.slice(-MAX_ENTRIES);
|
|
129
|
+
writeRegistry(dir, path, pruned);
|
|
130
|
+
return pruned;
|
|
131
|
+
} finally {
|
|
132
|
+
release?.();
|
|
133
|
+
}
|
|
134
|
+
}
|
package/bin/lib/runtimes.mjs
CHANGED
|
@@ -36,9 +36,9 @@
|
|
|
36
36
|
* machine HAVE" — activity, never capacity, and never a default we invented.
|
|
37
37
|
*/
|
|
38
38
|
|
|
39
|
-
import { execFileSync } from 'node:child_process';
|
|
40
|
-
import { mkdtempSync, writeFileSync } from 'node:fs';
|
|
41
|
-
import { tmpdir } from 'node:os';
|
|
39
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
40
|
+
import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
41
|
+
import { homedir, tmpdir } from 'node:os';
|
|
42
42
|
import { join } from 'node:path';
|
|
43
43
|
import { SAFE, MODEL, USER_AGENT } from './config.mjs';
|
|
44
44
|
|
|
@@ -976,3 +976,191 @@ export function recordSkills(names) {
|
|
|
976
976
|
export function knownSkills() {
|
|
977
977
|
return skillsCache;
|
|
978
978
|
}
|
|
979
|
+
|
|
980
|
+
/**
|
|
981
|
+
* LEARN WHAT `/` CAN OFFER, ON A MACHINE NO TURN HAS TAUGHT.
|
|
982
|
+
*
|
|
983
|
+
* WHY THIS EXISTS. `recordSkills` above is fed from the init event of a tab
|
|
984
|
+
* turn — authoritative and free, but with a hole nobody priced: THE FIRST THING
|
|
985
|
+
* ANYONE DOES IN A NEW TAB IS TYPE `/`, and that is by definition before that
|
|
986
|
+
* machine has run a turn. The menu was guaranteed empty exactly where it is
|
|
987
|
+
* first reached. Not a theoretical hole: checked against production on
|
|
988
|
+
* 2026-08-25, `agent_tokens.skills` was NULL for EVERY machine credential that
|
|
989
|
+
* has ever existed, because the only tab turns ever run predated the release
|
|
990
|
+
* that reports. The feature had never worked for anyone, once.
|
|
991
|
+
*
|
|
992
|
+
* IT COSTS ONE SMALL REQUEST, AND THAT IS THE HONEST NUMBER. This file used to
|
|
993
|
+
* forbid probing outright — "a `claude -p` run purely to populate a dropdown
|
|
994
|
+
* would spend the operator's quota on an affordance" — and that rule was
|
|
995
|
+
* written picturing a COMPLETED TURN. This is not one: Claude Code emits
|
|
996
|
+
* `system.init`, carrying its own fully resolved skill set, within ~0.5s and
|
|
997
|
+
* long before it finishes answering, so the child is killed the moment that
|
|
998
|
+
* event is read. But the request HAS gone out by then — measured on 2.1.245 by
|
|
999
|
+
* reading the transcript a killed probe left behind: 2 input tokens, 4 output,
|
|
1000
|
+
* ~6k cache-creation. Two zero-request routes were tried and both failed: an
|
|
1001
|
+
* empty prompt errors before init is emitted, and `--input-format stream-json`
|
|
1002
|
+
* emits nothing at all until a message arrives. So the cost is one cheap turn,
|
|
1003
|
+
* ONCE per daemon process, on the cheapest model, and only on a machine no turn
|
|
1004
|
+
* has taught. Do not let this grow into a per-tab or per-poll probe.
|
|
1005
|
+
*
|
|
1006
|
+
* `--model haiku` for that reason, and it is safe by this repo's own rule: a
|
|
1007
|
+
* name lives in AGENT_MODELS only once `claude --model <name>` is known to be
|
|
1008
|
+
* accepted on a real install. If it were ever refused the probe simply learns
|
|
1009
|
+
* nothing and the machine stays unmeasured — which is exactly today's state, so
|
|
1010
|
+
* the failure mode is the status quo rather than a regression.
|
|
1011
|
+
*
|
|
1012
|
+
* IT SCANS FOR THE INIT EVENT, never assuming it is line 1 — and that is not
|
|
1013
|
+
* defensive padding, it is measured. With `--model haiku` the CLI prints a
|
|
1014
|
+
* `system/status` line FIRST and init lands on line 2; with the default model
|
|
1015
|
+
* init is line 1. A first-line-only reader (the version this replaced) silently
|
|
1016
|
+
* learned nothing the moment a model flag was added.
|
|
1017
|
+
*
|
|
1018
|
+
* IT CLEANS UP AFTER ITSELF, and this is not optional. `claude -p` writes a
|
|
1019
|
+
* transcript to `~/.claude/projects/<munged-cwd>/<session-id>.jsonl` the moment
|
|
1020
|
+
* it starts, and `localSessions.mjs` reports the newest ENDED session per
|
|
1021
|
+
* directory to the Workbench as an ADOPTABLE row. Left behind, every daemon
|
|
1022
|
+
* start would put a phantom untitled session in the `+` menu offering to adopt
|
|
1023
|
+
* a conversation that never happened. The init event names its own session id,
|
|
1024
|
+
* so the file is ours by name and is removed by it.
|
|
1025
|
+
*
|
|
1026
|
+
* IT IS BEST-EFFORT IN EVERY DIRECTION. No claude, no PATH, a CLI that changed
|
|
1027
|
+
* its event shape, an unwritable home — all leave `skillsCache` exactly as it
|
|
1028
|
+
* was (null, "nobody looked"), which is the honest answer and the one the app
|
|
1029
|
+
* already renders correctly. Nothing here may throw, and nothing may block the
|
|
1030
|
+
* poll it is called from.
|
|
1031
|
+
*/
|
|
1032
|
+
|
|
1033
|
+
/** Long enough for a cold CLI start on a slow box, short enough that a hung
|
|
1034
|
+
* child is not left holding a session for the life of the daemon. */
|
|
1035
|
+
const SKILL_PROBE_TIMEOUT_MS = 30_000;
|
|
1036
|
+
|
|
1037
|
+
/** Init arrives within the first couple of events or not at all; this is the
|
|
1038
|
+
* guard against parsing a whole turn's output looking for it. */
|
|
1039
|
+
const SKILL_PROBE_MAX_LINES = 20;
|
|
1040
|
+
|
|
1041
|
+
let skillProbeStarted = false;
|
|
1042
|
+
|
|
1043
|
+
/** Where Claude Code keeps a transcript for `cwd`: `/` and `.` both become `-`. */
|
|
1044
|
+
function transcriptCandidates(cwd, sessionId) {
|
|
1045
|
+
const base = join(homedir(), '.claude', 'projects');
|
|
1046
|
+
const out = [join(base, cwd.replace(/[/.]/g, '-'), `${sessionId}.jsonl`)];
|
|
1047
|
+
// The munge is Claude Code's, not ours, so a version that changes it must not
|
|
1048
|
+
// leave the file behind: fall back to finding our own session id by name.
|
|
1049
|
+
try {
|
|
1050
|
+
for (const d of readdirSync(base)) out.push(join(base, d, `${sessionId}.jsonl`));
|
|
1051
|
+
} catch {
|
|
1052
|
+
/* no project store — nothing was written either */
|
|
1053
|
+
}
|
|
1054
|
+
return out;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
function removeProbeTranscript(cwd, sessionId) {
|
|
1058
|
+
if (!sessionId || !/^[A-Za-z0-9_-]{8,64}$/.test(sessionId)) return;
|
|
1059
|
+
for (const f of transcriptCandidates(cwd, sessionId)) {
|
|
1060
|
+
try {
|
|
1061
|
+
if (existsSync(f)) {
|
|
1062
|
+
rmSync(f, { force: true });
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
} catch {
|
|
1066
|
+
/* best-effort */
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* One line of the probe's stdout → the init event's payload, or null for "not
|
|
1073
|
+
* it, keep reading".
|
|
1074
|
+
*
|
|
1075
|
+
* SEPARATE FROM THE SCAN LOOP so the thing that actually broke can be tested
|
|
1076
|
+
* without spawning a CLI. The first version of this probe read line 1 and
|
|
1077
|
+
* stopped, which was measured-correct with the default model and silently
|
|
1078
|
+
* WRONG with `--model haiku`: that path prints a `system/status` line first and
|
|
1079
|
+
* puts init on line 2, so the probe learned nothing and reported nothing, which
|
|
1080
|
+
* is indistinguishable from the bug it was written to fix.
|
|
1081
|
+
*
|
|
1082
|
+
* A line that is not JSON is not a failure — a CLI warning on stdout is a line
|
|
1083
|
+
* to skip, not a reason to abandon the probe.
|
|
1084
|
+
*/
|
|
1085
|
+
export function parseInitLine(line) {
|
|
1086
|
+
let ev;
|
|
1087
|
+
try {
|
|
1088
|
+
ev = JSON.parse(line);
|
|
1089
|
+
} catch {
|
|
1090
|
+
return null;
|
|
1091
|
+
}
|
|
1092
|
+
if (!ev || ev.type !== 'system' || ev.subtype !== 'init') return null;
|
|
1093
|
+
return {
|
|
1094
|
+
// An init event WITHOUT skills is still the init event: stop reading, but
|
|
1095
|
+
// record nothing. Conflating the two would keep the probe scanning a whole
|
|
1096
|
+
// turn's output on a CLI that does not report them.
|
|
1097
|
+
skills: Array.isArray(ev.skills) ? ev.skills : null,
|
|
1098
|
+
sessionId: typeof ev.session_id === 'string' ? ev.session_id : null,
|
|
1099
|
+
};
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
/**
|
|
1103
|
+
* Kick off the one-shot probe. Returns immediately; the result lands in
|
|
1104
|
+
* `skillsCache` and rides the NEXT poll, so nothing waits on it.
|
|
1105
|
+
*
|
|
1106
|
+
* A no-op once a turn has taught us (`skillsCache !== null`) — a turn's init
|
|
1107
|
+
* event and this one say the same thing, and the turn is free.
|
|
1108
|
+
*/
|
|
1109
|
+
export function probeSkillsOnce(cwd) {
|
|
1110
|
+
if (skillProbeStarted || skillsCache !== null) return;
|
|
1111
|
+
skillProbeStarted = true;
|
|
1112
|
+
let child;
|
|
1113
|
+
try {
|
|
1114
|
+
// Claude Code specifically, not the `wiki` profile's runtime pick: `skills`
|
|
1115
|
+
// is Claude Code's own field and the `/` tray only renders for claude tabs.
|
|
1116
|
+
child = spawn(
|
|
1117
|
+
RUNTIMES.claude.bin,
|
|
1118
|
+
['-p', 'x', '--model', 'haiku', '--output-format', 'stream-json', '--verbose'],
|
|
1119
|
+
{ cwd, stdio: ['ignore', 'pipe', 'ignore'] }
|
|
1120
|
+
);
|
|
1121
|
+
} catch {
|
|
1122
|
+
return; // no claude on PATH — the machine simply stays unmeasured
|
|
1123
|
+
}
|
|
1124
|
+
let buf = '';
|
|
1125
|
+
let lines = 0;
|
|
1126
|
+
let settled = false;
|
|
1127
|
+
const finish = (sessionId) => {
|
|
1128
|
+
if (settled) return;
|
|
1129
|
+
settled = true;
|
|
1130
|
+
clearTimeout(timer);
|
|
1131
|
+
try {
|
|
1132
|
+
child.kill('SIGKILL');
|
|
1133
|
+
} catch {
|
|
1134
|
+
/* already gone */
|
|
1135
|
+
}
|
|
1136
|
+
// AFTER the kill, and on a delay: the transcript is the CHILD's file, so
|
|
1137
|
+
// deleting it while the child still lives races a recreate.
|
|
1138
|
+
if (sessionId) setTimeout(() => removeProbeTranscript(cwd, sessionId), 750).unref?.();
|
|
1139
|
+
};
|
|
1140
|
+
const timer = setTimeout(() => finish(null), SKILL_PROBE_TIMEOUT_MS);
|
|
1141
|
+
timer.unref?.();
|
|
1142
|
+
child.on('error', () => finish(null));
|
|
1143
|
+
child.on('exit', () => finish(null));
|
|
1144
|
+
child.stdout.on('data', (d) => {
|
|
1145
|
+
if (settled) return;
|
|
1146
|
+
buf += d.toString();
|
|
1147
|
+
let nl;
|
|
1148
|
+
while (!settled && (nl = buf.indexOf('\n')) >= 0) {
|
|
1149
|
+
const line = buf.slice(0, nl);
|
|
1150
|
+
buf = buf.slice(nl + 1);
|
|
1151
|
+
if (!line.trim()) continue;
|
|
1152
|
+
if (++lines > SKILL_PROBE_MAX_LINES) {
|
|
1153
|
+
finish(null);
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
const init = parseInitLine(line);
|
|
1157
|
+
if (!init) continue;
|
|
1158
|
+
if (init.skills) recordSkills(init.skills);
|
|
1159
|
+
finish(init.sessionId);
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
// A single line this long is not an init event; stop buffering a whole
|
|
1163
|
+
// turn's output into memory waiting for one.
|
|
1164
|
+
if (!settled && buf.length > 1_000_000) finish(null);
|
|
1165
|
+
});
|
|
1166
|
+
}
|
package/bin/lib/work.mjs
CHANGED
|
@@ -38,8 +38,9 @@ import {
|
|
|
38
38
|
DAEMON_INSTANCE,
|
|
39
39
|
} from './config.mjs';
|
|
40
40
|
import { git, gitRaw, splitNul, baseBranchName, isSafePathSegment } from './git.mjs';
|
|
41
|
-
import { listenersIn } from './listeners.mjs';
|
|
41
|
+
import { listenersIn, listenersSupported } from './listeners.mjs';
|
|
42
42
|
import { openTunnel } from './preview.mjs';
|
|
43
|
+
import { startDevServer, reapOrphanDevRuns, killDevRunEntry } from './devServer.mjs';
|
|
43
44
|
import { c, note, ok, warn } from './ui.mjs';
|
|
44
45
|
import { mcpFor, runTurn } from './claude.mjs';
|
|
45
46
|
import {
|
|
@@ -102,6 +103,8 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
102
103
|
const DIFF_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/diff-done');
|
|
103
104
|
const PREVIEW_CLAIM_URL = FLEET_URL.replace(/\/agents\/?$/, '/preview-claim');
|
|
104
105
|
const PREVIEW_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/preview-done');
|
|
106
|
+
const DEV_RUN_CLAIM_URL = FLEET_URL.replace(/\/agents\/?$/, '/dev-run-claim');
|
|
107
|
+
const DEV_RUN_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/dev-run-done');
|
|
105
108
|
const SESSION_COMMANDS_URL = FLEET_URL.replace(/\/agents\/?$/, '/session-commands');
|
|
106
109
|
const ATTACHMENT_URL = FLEET_URL.replace(/\/agents\/?$/, '/attachment');
|
|
107
110
|
const workAnswering = new Set(); // turn ids currently queued/running here
|
|
@@ -350,7 +353,17 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
350
353
|
// The browser NEVER names a directory and never names a port this did not
|
|
351
354
|
// report: ports are global to a box and a worktree is not, so this
|
|
352
355
|
// measurement is the security boundary for the whole preview feature.
|
|
353
|
-
|
|
356
|
+
// `listeningSupported` says whether this machine can measure AT ALL, which
|
|
357
|
+
// is a different fact from finding nothing. Windows reports nothing and a
|
|
358
|
+
// failed scan reports nothing, and both were indistinguishable from an idle
|
|
359
|
+
// worktree — harmless while the only consumer needed a NON-empty array, and
|
|
360
|
+
// a permanent `Starting…` the moment a Run dev offer hangs off an empty one.
|
|
361
|
+
return {
|
|
362
|
+
sessionId,
|
|
363
|
+
...d,
|
|
364
|
+
listening: listenersIn(wt),
|
|
365
|
+
listeningSupported: listenersSupported(),
|
|
366
|
+
};
|
|
354
367
|
};
|
|
355
368
|
/** One session, now — called after its turn settles. */
|
|
356
369
|
const reportSessionWorktree = async (sessionId) => {
|
|
@@ -634,6 +647,259 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
634
647
|
/** The sessionIds this machine is still serving — sent on the poll so the
|
|
635
648
|
* server can tell a live share from one whose machine went away. Silence
|
|
636
649
|
* must never read as "live". */
|
|
650
|
+
/**
|
|
651
|
+
* The daemon's own shape check on an argv the server parsed.
|
|
652
|
+
*
|
|
653
|
+
* Deliberately a SHAPE check and not a re-parse: the server owns the policy
|
|
654
|
+
* (which argv[0] are allowed, the install refusal, the length caps) and the
|
|
655
|
+
* machine owns the refusal to EXECUTE something malformed. It is duplicated
|
|
656
|
+
* rather than imported because this package ships standalone and cannot
|
|
657
|
+
* depend on the monorepo — the mirror is small, and `devCommand.ts` is where
|
|
658
|
+
* the real rules live.
|
|
659
|
+
*/
|
|
660
|
+
const isPlausibleDevArgv = (argv) =>
|
|
661
|
+
Array.isArray(argv) &&
|
|
662
|
+
argv.length > 0 &&
|
|
663
|
+
argv.length <= 8 &&
|
|
664
|
+
argv.every((a) => typeof a === 'string' && a.length > 0 && a.length <= 200) &&
|
|
665
|
+
!argv.some((a) => /[&|;<>`$(){}*?~\\]/.test(a));
|
|
666
|
+
|
|
667
|
+
// ── DEV RUNS ──────────────────────────────────────────────────────────
|
|
668
|
+
//
|
|
669
|
+
// The web asks for the PROJECT'S STORED command to run in a tab's worktree.
|
|
670
|
+
// The argv arrives on the job, already parsed from a string a human approved;
|
|
671
|
+
// nothing here reads a repo file to decide what executes. See devServer.mjs.
|
|
672
|
+
const liveDevRuns = new Map(); // sessionId -> { stop, pid }
|
|
673
|
+
const devRunClaiming = new Set();
|
|
674
|
+
|
|
675
|
+
const postDevRun = async (body) => {
|
|
676
|
+
try {
|
|
677
|
+
await fetch(DEV_RUN_DONE_URL, {
|
|
678
|
+
method: 'POST',
|
|
679
|
+
headers: {
|
|
680
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
681
|
+
'User-Agent': USER_AGENT,
|
|
682
|
+
'Content-Type': 'application/json',
|
|
683
|
+
},
|
|
684
|
+
signal: AbortSignal.timeout(30_000),
|
|
685
|
+
body: JSON.stringify({ ...body, instance: DAEMON_INSTANCE }),
|
|
686
|
+
});
|
|
687
|
+
} catch {
|
|
688
|
+
/* the row stops being confirmed and reads as stopped — which is true */
|
|
689
|
+
}
|
|
690
|
+
};
|
|
691
|
+
|
|
692
|
+
const claimDevRun = async (sessionId) => {
|
|
693
|
+
try {
|
|
694
|
+
const res = await fetch(DEV_RUN_CLAIM_URL, {
|
|
695
|
+
method: 'POST',
|
|
696
|
+
headers: {
|
|
697
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
698
|
+
'User-Agent': USER_AGENT,
|
|
699
|
+
'Content-Type': 'application/json',
|
|
700
|
+
},
|
|
701
|
+
signal: AbortSignal.timeout(30_000),
|
|
702
|
+
body: JSON.stringify({ sessionId, instance: DAEMON_INSTANCE }),
|
|
703
|
+
});
|
|
704
|
+
const j = await res.json().catch(() => ({}));
|
|
705
|
+
return Boolean(j?.data?.claimed);
|
|
706
|
+
} catch {
|
|
707
|
+
return false;
|
|
708
|
+
}
|
|
709
|
+
};
|
|
710
|
+
|
|
711
|
+
const stopDevRun = async (sessionId, reason) => {
|
|
712
|
+
const live = liveDevRuns.get(sessionId);
|
|
713
|
+
liveDevRuns.delete(sessionId);
|
|
714
|
+
try {
|
|
715
|
+
live?.stop?.();
|
|
716
|
+
} catch {
|
|
717
|
+
/* best-effort */
|
|
718
|
+
}
|
|
719
|
+
await postDevRun({ sessionId, ended: true, endedReason: reason });
|
|
720
|
+
};
|
|
721
|
+
|
|
722
|
+
const processDevRunJobs = (jobs) => {
|
|
723
|
+
if (!Array.isArray(jobs) || jobs.length === 0) return;
|
|
724
|
+
for (const job of jobs.slice(0, 5)) {
|
|
725
|
+
const sessionId = String(job?.sessionId || '');
|
|
726
|
+
if (!isSafePathSegment(sessionId)) continue;
|
|
727
|
+
|
|
728
|
+
if (job?.action === 'stop') {
|
|
729
|
+
if (devRunClaiming.has(sessionId)) continue;
|
|
730
|
+
devRunClaiming.add(sessionId);
|
|
731
|
+
void stopDevRun(sessionId, 'stopped').finally(() => devRunClaiming.delete(sessionId));
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// RE-VALIDATE THE SHAPE at this boundary. The server parsed the string
|
|
736
|
+
// and owns the policy; the machine owns the refusal to execute something
|
|
737
|
+
// malformed, because one place doing a check is one deploy away from
|
|
738
|
+
// being zero places.
|
|
739
|
+
const argv = Array.isArray(job?.argv) ? job.argv.map(String) : [];
|
|
740
|
+
if (!isPlausibleDevArgv(argv)) {
|
|
741
|
+
void postDevRun({
|
|
742
|
+
sessionId,
|
|
743
|
+
started: false,
|
|
744
|
+
endedReason: 'refused',
|
|
745
|
+
error: 'the machine did not recognise that command',
|
|
746
|
+
});
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
// Already serving this tab. Re-starting would kill a server somebody is
|
|
750
|
+
// looking at right now.
|
|
751
|
+
if (liveDevRuns.has(sessionId)) continue;
|
|
752
|
+
if (devRunClaiming.has(sessionId)) continue;
|
|
753
|
+
devRunClaiming.add(sessionId);
|
|
754
|
+
|
|
755
|
+
void (async () => {
|
|
756
|
+
try {
|
|
757
|
+
if (!(await claimDevRun(sessionId))) return; // somebody else has it
|
|
758
|
+
const wt = join(baseDir, 'sessions', sessionId);
|
|
759
|
+
const r = await startDevServer({
|
|
760
|
+
sessionId,
|
|
761
|
+
worktree: wt,
|
|
762
|
+
argv,
|
|
763
|
+
log: (m) => note(`dev ${sessionId.slice(0, 8)}: ${m}`),
|
|
764
|
+
onState: (st) => {
|
|
765
|
+
void postDevRun({ sessionId, ...st });
|
|
766
|
+
// Re-report the worktree at once so the chip flips within a
|
|
767
|
+
// second instead of waiting up to 60s for the sweep.
|
|
768
|
+
void reportSessionWorktree(sessionId).catch(() => undefined);
|
|
769
|
+
},
|
|
770
|
+
onExit: (ex) => {
|
|
771
|
+
liveDevRuns.delete(sessionId);
|
|
772
|
+
void postDevRun({ sessionId, ended: true, ...ex });
|
|
773
|
+
},
|
|
774
|
+
});
|
|
775
|
+
if (!r.ok) {
|
|
776
|
+
await postDevRun({
|
|
777
|
+
sessionId,
|
|
778
|
+
started: false,
|
|
779
|
+
endedReason: r.endedReason ?? 'spawn_failed',
|
|
780
|
+
error: r.error ?? 'the machine could not start it',
|
|
781
|
+
});
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
liveDevRuns.set(sessionId, { stop: r.stop, pid: r.pid });
|
|
785
|
+
// THE AUDIT ROW. Without it a daemon-spawned dev server would be the
|
|
786
|
+
// ONLY execution on this box with no entry in the very surface built
|
|
787
|
+
// so an admin can answer "what ran on this machine" — and it would be
|
|
788
|
+
// missing precisely the execution whose provenance is most worth
|
|
789
|
+
// checking. `runtime: null` because no CLI ran it: the daemon did.
|
|
790
|
+
void fetch(SESSION_COMMANDS_URL, {
|
|
791
|
+
method: 'POST',
|
|
792
|
+
headers: {
|
|
793
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
794
|
+
'User-Agent': USER_AGENT,
|
|
795
|
+
'Content-Type': 'application/json',
|
|
796
|
+
},
|
|
797
|
+
signal: AbortSignal.timeout(30_000),
|
|
798
|
+
body: JSON.stringify({
|
|
799
|
+
sessionId,
|
|
800
|
+
cwd: wt,
|
|
801
|
+
commands: [{ command: argv.join(' '), at: new Date().toISOString() }],
|
|
802
|
+
}),
|
|
803
|
+
}).catch(() => {
|
|
804
|
+
/* best-effort — the audit records what reached it */
|
|
805
|
+
});
|
|
806
|
+
} finally {
|
|
807
|
+
devRunClaiming.delete(sessionId);
|
|
808
|
+
}
|
|
809
|
+
})();
|
|
810
|
+
}
|
|
811
|
+
};
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* ADOPT what the previous process left behind.
|
|
815
|
+
*
|
|
816
|
+
* This is the half of "consistently running" that actually delivers it. A
|
|
817
|
+
* self-update re-execs, and a same-repo takeover replaces this process — both
|
|
818
|
+
* leave dev servers alive on purpose (see `shutdownDevRuns`), and without
|
|
819
|
+
* adoption the successor would neither supervise them nor be able to stop
|
|
820
|
+
* them, so the row would say running while nothing owned the process.
|
|
821
|
+
*
|
|
822
|
+
* Identity is the CWD plus liveness, never the command string: `npm run dev`
|
|
823
|
+
* is identical across two tabs, two worktrees, and the driver's own
|
|
824
|
+
* hand-started server, so a cmdline match would be indistinguishable from a
|
|
825
|
+
* coincidence. A could-not-measure is NOT a match and kills nothing.
|
|
826
|
+
*/
|
|
827
|
+
const adoptDevRuns = (activeIds) => {
|
|
828
|
+
let adopted = 0;
|
|
829
|
+
for (const entry of reapOrphanDevRuns(activeIds, note)) {
|
|
830
|
+
const wt = join(baseDir, 'sessions', entry.sessionId);
|
|
831
|
+
// The recorded cwd must still be this session's worktree. A recycled pid
|
|
832
|
+
// pointing anywhere else is somebody else's process.
|
|
833
|
+
if (entry.cwd !== wt) continue;
|
|
834
|
+
if (liveDevRuns.has(entry.sessionId)) continue;
|
|
835
|
+
liveDevRuns.set(entry.sessionId, {
|
|
836
|
+
pid: entry.pid,
|
|
837
|
+
stop: () => killDevRunEntry(entry),
|
|
838
|
+
});
|
|
839
|
+
adopted += 1;
|
|
840
|
+
// The output ring is EMPTY for an adopted run and the row says so rather
|
|
841
|
+
// than pretending to a tail it does not have.
|
|
842
|
+
void postDevRun({
|
|
843
|
+
sessionId: entry.sessionId,
|
|
844
|
+
started: true,
|
|
845
|
+
pid: entry.pid,
|
|
846
|
+
port: listenersIn(wt)[0]?.port ?? null,
|
|
847
|
+
logTail: '[reattached after the machine restarted — earlier output is not kept]',
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
if (adopted > 0) note(`re-attached ${adopted} dev server${adopted === 1 ? '' : 's'}`);
|
|
851
|
+
};
|
|
852
|
+
|
|
853
|
+
/** The sessionIds this machine is still running, sent on the poll so the
|
|
854
|
+
* server can tell a live run from one whose machine went away. */
|
|
855
|
+
const liveDevRunIds = () => [...liveDevRuns.keys()];
|
|
856
|
+
|
|
857
|
+
/** A tab closed. ORDER MATTERS and the caller keeps it: the tunnel is retired
|
|
858
|
+
* BEFORE the process it points at, so a viewer sees a dead link rather than
|
|
859
|
+
* a 502 from a gate whose origin vanished — and both happen before
|
|
860
|
+
* `retireWorkSessions` can `git worktree remove` the directory out from
|
|
861
|
+
* under a running node process, which it would do without complaint because
|
|
862
|
+
* its dirty check inspects only TRACKED files and node_modules is not one. */
|
|
863
|
+
const retireDevRuns = (activeIds) => {
|
|
864
|
+
if (!Array.isArray(activeIds)) return; // an older server, not a close
|
|
865
|
+
const live = new Set(activeIds);
|
|
866
|
+
for (const sessionId of [...liveDevRuns.keys()]) {
|
|
867
|
+
if (live.has(sessionId)) continue;
|
|
868
|
+
if (devRunClaiming.has(sessionId)) continue;
|
|
869
|
+
devRunClaiming.add(sessionId);
|
|
870
|
+
void stopDevRun(sessionId, 'tab_closed').finally(() => devRunClaiming.delete(sessionId));
|
|
871
|
+
}
|
|
872
|
+
};
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* Daemon shutdown, and the SPLIT is what delivers "consistently running".
|
|
876
|
+
*
|
|
877
|
+
* `kill: true` — a human hit Ctrl-C, the credential was revoked, or a stop was
|
|
878
|
+
* commanded. All three mean THIS MACHINE IS STANDING DOWN, and leaving dev
|
|
879
|
+
* servers behind would strand them.
|
|
880
|
+
*
|
|
881
|
+
* `kill: false` — a same-repo takeover, or a self-update re-exec. Both mean
|
|
882
|
+
* ANOTHER DAEMON IS ABOUT TO SERVE THIS REPO IN ONE SECOND, and killing would
|
|
883
|
+
* mean `flowviant` re-run in your own directory restarts the app you were
|
|
884
|
+
* watching, and a routine auto-update silently kills every dev server with
|
|
885
|
+
* nothing to bring them back — precisely the goal defeated. The registry rows
|
|
886
|
+
* are left for the successor to adopt.
|
|
887
|
+
*/
|
|
888
|
+
const shutdownDevRuns = (kill) => {
|
|
889
|
+
if (!kill) {
|
|
890
|
+
liveDevRuns.clear();
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
for (const [, live] of liveDevRuns) {
|
|
894
|
+
try {
|
|
895
|
+
live.stop?.();
|
|
896
|
+
} catch {
|
|
897
|
+
/* best-effort */
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
liveDevRuns.clear();
|
|
901
|
+
};
|
|
902
|
+
|
|
637
903
|
const livePreviewIds = () => [...livePreviews.keys()];
|
|
638
904
|
|
|
639
905
|
/**
|
|
@@ -2238,6 +2504,11 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
|
|
|
2238
2504
|
livePreviewIds,
|
|
2239
2505
|
retirePreviews,
|
|
2240
2506
|
shutdownPreviews,
|
|
2507
|
+
processDevRunJobs,
|
|
2508
|
+
liveDevRunIds,
|
|
2509
|
+
retireDevRuns,
|
|
2510
|
+
shutdownDevRuns,
|
|
2511
|
+
adoptDevRuns,
|
|
2241
2512
|
retireWorkSessions,
|
|
2242
2513
|
reportWorktrees,
|
|
2243
2514
|
shutdownWork,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.57.0",
|
|
4
4
|
"description": "Run your own coding CLIs as build agents for Flowviant \u2014 Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|