flowviant 0.63.1 → 0.65.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/claude.mjs +19 -0
- package/bin/lib/fleet.mjs +2 -39
- package/bin/lib/placeLock.mjs +106 -0
- package/bin/lib/processes.mjs +202 -0
- package/bin/lib/work.mjs +102 -458
- package/package.json +1 -1
- package/bin/lib/devResolve.mjs +0 -167
- package/bin/lib/devServer.mjs +0 -306
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.65.0",
|
|
4
4
|
"description": "Run your own coding CLIs as build agents for Flowviant — 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": {
|
package/bin/lib/devResolve.mjs
DELETED
|
@@ -1,167 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* WHAT STARTS THIS PROJECT — asked of a Claude, on the machine, in the
|
|
3
|
-
* background.
|
|
4
|
-
*
|
|
5
|
-
* WHY THIS EXISTS. Starting a dev server used to require a command a human had
|
|
6
|
-
* typed into a sheet, prefilled `npm run dev`. That prefill is a guess about
|
|
7
|
-
* somebody else's stack, and it was called out as one: "the option to run or
|
|
8
|
-
* start the dev server shouldnt be npm run dev or show it as npm run dev
|
|
9
|
-
* because thats not agnostic to everyones set up. no need to show that."
|
|
10
|
-
*
|
|
11
|
-
* The mechanism is the driver's own: "we are literally asking a claude session
|
|
12
|
-
* to start it for us" — and, decisively, "i still want claude to start the
|
|
13
|
-
* server for me but i dont want it to literally open a chat. have it do it in
|
|
14
|
-
* the background." So this is a headless turn. It reaches no transcript, spends
|
|
15
|
-
* no tab, and leaves no message anybody has to read.
|
|
16
|
-
*
|
|
17
|
-
* IT ANSWERS WITH A STRING AND STARTS NOTHING. The server parses what comes
|
|
18
|
-
* back, through the same `parseDevCommand` a human's answer goes through, and
|
|
19
|
-
* hands the machine an ordinary start job on the next poll. That split is the
|
|
20
|
-
* whole safety story: the policy for what may execute has exactly one
|
|
21
|
-
* implementation, and it lives in the component this repo can actually upgrade.
|
|
22
|
-
* A daemon that decided for itself what counted as a legal command would be a
|
|
23
|
-
* second copy of that policy, free to drift, published, and unrecallable.
|
|
24
|
-
*
|
|
25
|
-
* IT MAY INSTALL. That is not a loophole in the install refusal — it is the
|
|
26
|
-
* refusal's own stated remedy. `parseDevCommand` refuses `npm install` because
|
|
27
|
-
* a SPAWNED command runs lifecycle scripts from the repo and every transitive
|
|
28
|
-
* dependency with no agent in the loop; the file says the remedy is "a turn in
|
|
29
|
-
* the tab: a human asking, an agent doing it". This is exactly that turn, with
|
|
30
|
-
* the human asking by pressing the button. And it is the case that matters: a
|
|
31
|
-
* fresh worktree has no `node_modules` (they are gitignored, so they never come
|
|
32
|
-
* across with the branch), which is precisely the dead end that produced "it
|
|
33
|
-
* was stuck on 'working', does it really take that long to run dev?"
|
|
34
|
-
*
|
|
35
|
-
* IT CLEANS UP AFTER ITSELF. A `-p` turn writes a transcript, and
|
|
36
|
-
* `localSessions.mjs` offers the newest ended session per directory as
|
|
37
|
-
* adoptable — left behind, every resolve would drop a phantom untitled session
|
|
38
|
-
* into the `+` menu.
|
|
39
|
-
*/
|
|
40
|
-
|
|
41
|
-
import { runTurn } from './claude.mjs';
|
|
42
|
-
import { removeProbeTranscript } from './runtimes.mjs';
|
|
43
|
-
|
|
44
|
-
/** Long, because an install can sit in front of the answer. The SERVER holds
|
|
45
|
-
* the real ceiling (`RESOLVE_TTL_MS`); this is the machine giving up first so
|
|
46
|
-
* a wedged child does not hold a slot until then. */
|
|
47
|
-
export const DEV_RESOLVE_TIMEOUT_MS = 10 * 60_000;
|
|
48
|
-
|
|
49
|
-
/** The sentinel for "I could not tell", so an honest failure is distinguishable
|
|
50
|
-
* from a model padding an answer it does not have. */
|
|
51
|
-
export const NO_COMMAND = 'NONE';
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* The argv0s the server will accept. Named in the prompt NOT as a security
|
|
55
|
-
* control — the server enforces it either way, and would refuse anything else
|
|
56
|
-
* with the parser's own words — but because a model that knows the shape of an
|
|
57
|
-
* acceptable answer gives one, and a refused proposal costs the asker a whole
|
|
58
|
-
* round trip to learn nothing.
|
|
59
|
-
*/
|
|
60
|
-
const ALLOWED = [
|
|
61
|
-
'npm', 'pnpm', 'yarn', 'bun', 'node', 'deno', 'go', 'python', 'python3',
|
|
62
|
-
'make', 'cargo', 'rails', 'php', 'dotnet',
|
|
63
|
-
];
|
|
64
|
-
|
|
65
|
-
export function resolvePrompt() {
|
|
66
|
-
return [
|
|
67
|
-
'Work out the ONE command that starts this project’s development server, and reply with only that command.',
|
|
68
|
-
'',
|
|
69
|
-
'How to work it out: read the repo. Check package.json scripts, Makefile, Procfile, docker-compose, pyproject.toml, Cargo.toml, README — whatever this project actually uses. Prefer the script the project itself documents for local development.',
|
|
70
|
-
'',
|
|
71
|
-
'You MAY install dependencies first if they are missing (for example a worktree with no node_modules). Do that before answering.',
|
|
72
|
-
'',
|
|
73
|
-
'Rules for the answer:',
|
|
74
|
-
`- It must begin with one of: ${ALLOWED.join(', ')} — or a ./path to a script in this repo.`,
|
|
75
|
-
'- One line. No shell operators (&&, |, ;, >, $, backticks). If the project needs several steps, name a script in the repo that does them.',
|
|
76
|
-
'- Not an install command. Install as part of your work above if needed; the answer is the command that RUNS the server.',
|
|
77
|
-
'- It must not daemonize or background itself. It should stay in the foreground; something else supervises it.',
|
|
78
|
-
`- If you genuinely cannot tell, reply exactly ${NO_COMMAND}.`,
|
|
79
|
-
'',
|
|
80
|
-
'Reply with the command alone — no explanation, no backticks, no prose.',
|
|
81
|
-
].join('\n');
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Last plausible command line out of whatever the model said.
|
|
86
|
-
*
|
|
87
|
-
* DELIBERATELY FORGIVING, because the cost of being wrong is low and asymmetric:
|
|
88
|
-
* the server parses this and refuses anything outside the policy, naming what
|
|
89
|
-
* was proposed. Being strict here would turn a model that wrapped its answer in
|
|
90
|
-
* backticks into a failure the asker cannot act on, having already paid for the
|
|
91
|
-
* turn.
|
|
92
|
-
*/
|
|
93
|
-
export function pickCommand(text) {
|
|
94
|
-
const lines = String(text ?? '')
|
|
95
|
-
.split('\n')
|
|
96
|
-
.map((l) => l.trim())
|
|
97
|
-
// Fence markers and bullet/quote decoration, which are formatting rather
|
|
98
|
-
// than part of anybody's command.
|
|
99
|
-
.filter((l) => l && !/^```/.test(l))
|
|
100
|
-
.map((l) => l.replace(/^[-*>\s]+/, '').replace(/^`+|`+$/g, '').trim())
|
|
101
|
-
.filter(Boolean);
|
|
102
|
-
if (lines.length === 0) return null;
|
|
103
|
-
// The LAST such line: a model that explains before complying puts the answer
|
|
104
|
-
// at the end, and one that complies exactly has only one line anyway.
|
|
105
|
-
const last = lines[lines.length - 1];
|
|
106
|
-
if (!last || last === NO_COMMAND) return null;
|
|
107
|
-
// A sentence is not a command. Cheap shape check so obvious prose becomes
|
|
108
|
-
// "could not work it out" rather than a refusal quoting a paragraph back.
|
|
109
|
-
if (last.split(/\s+/).length > 8 || /[.!?]$/.test(last)) return null;
|
|
110
|
-
return last;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Run the turn. Resolves `{ command }` or `{ error }` — never throws, because
|
|
115
|
-
* the caller's only job with a failure is to relay it, and an exception at this
|
|
116
|
-
* boundary would strand the row.
|
|
117
|
-
*/
|
|
118
|
-
export async function resolveDevCommandOnMachine({ cwd, model, log, onActivity }) {
|
|
119
|
-
let sessionId = null;
|
|
120
|
-
let timer;
|
|
121
|
-
try {
|
|
122
|
-
const out = await Promise.race([
|
|
123
|
-
runTurn({
|
|
124
|
-
prompt: resolvePrompt(),
|
|
125
|
-
cwd,
|
|
126
|
-
streamJson: true,
|
|
127
|
-
answerFromResult: true,
|
|
128
|
-
model,
|
|
129
|
-
label: 'dev',
|
|
130
|
-
/**
|
|
131
|
-
* THE TURN'S OWN HUMANIZED TAIL, forwarded so a browser can watch it.
|
|
132
|
-
*
|
|
133
|
-
* This is the only feature in the product where a Claude does work
|
|
134
|
-
* nobody can see — no transcript, by design — and the driver's answer
|
|
135
|
-
* to that is the right one: "we could have it stream the output for
|
|
136
|
-
* transparency on the menu." So the same `read …` / `+ npm install …`
|
|
137
|
-
* lines a tab relays are forwarded here. It is the CLI's own stdout
|
|
138
|
-
* humanized, never an inference about it, which is the standing rule
|
|
139
|
-
* for every activity readout in this product.
|
|
140
|
-
*/
|
|
141
|
-
onActivity,
|
|
142
|
-
onInit: (i) => {
|
|
143
|
-
if (i?.sessionId) sessionId = i.sessionId;
|
|
144
|
-
},
|
|
145
|
-
}),
|
|
146
|
-
new Promise((r) => {
|
|
147
|
-
timer = setTimeout(() => r(null), DEV_RESOLVE_TIMEOUT_MS);
|
|
148
|
-
timer.unref?.();
|
|
149
|
-
}),
|
|
150
|
-
]);
|
|
151
|
-
if (out === null) {
|
|
152
|
-
return { error: 'your Claude did not finish working out how to start this project in time.' };
|
|
153
|
-
}
|
|
154
|
-
const command = pickCommand(out);
|
|
155
|
-
if (!command) {
|
|
156
|
-
return { error: 'your Claude could not work out how to start this project.' };
|
|
157
|
-
}
|
|
158
|
-
log?.(`dev: resolved start command — ${command}`);
|
|
159
|
-
return { command };
|
|
160
|
-
} catch (e) {
|
|
161
|
-
return { error: `your Claude could not be run here: ${e?.message ?? 'unknown error'}` };
|
|
162
|
-
} finally {
|
|
163
|
-
clearTimeout(timer);
|
|
164
|
-
// After the turn, so the delete does not race a child still writing.
|
|
165
|
-
if (sessionId) setTimeout(() => removeProbeTranscript(cwd, sessionId), 750).unref?.();
|
|
166
|
-
}
|
|
167
|
-
}
|
package/bin/lib/devServer.mjs
DELETED
|
@@ -1,306 +0,0 @@
|
|
|
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
|
-
}
|