runwork 0.10.2 → 0.10.3
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/dist/commands/dev.d.ts +3 -0
- package/dist/commands/dev.js +621 -8
- package/dist/commands/info.d.ts +31 -0
- package/dist/commands/info.js +37 -0
- package/dist/dev/__tests__/attach.test.d.ts +1 -0
- package/dist/dev/__tests__/attach.test.js +296 -0
- package/dist/dev/__tests__/detach.test.d.ts +1 -0
- package/dist/dev/__tests__/detach.test.js +328 -0
- package/dist/dev/__tests__/preview-url-poller.test.d.ts +1 -0
- package/dist/dev/__tests__/preview-url-poller.test.js +149 -0
- package/dist/dev/__tests__/session.test.d.ts +1 -0
- package/dist/dev/__tests__/session.test.js +347 -0
- package/dist/dev/__tests__/stop.test.d.ts +1 -0
- package/dist/dev/__tests__/stop.test.js +172 -0
- package/dist/dev/attach.d.ts +120 -0
- package/dist/dev/attach.js +269 -0
- package/dist/dev/detach.d.ts +164 -0
- package/dist/dev/detach.js +247 -0
- package/dist/dev/preview-url-poller.d.ts +35 -0
- package/dist/dev/preview-url-poller.js +50 -0
- package/dist/dev/session.d.ts +158 -0
- package/dist/dev/session.js +252 -0
- package/dist/dev/stop.d.ts +52 -0
- package/dist/dev/stop.js +101 -0
- package/dist/generated/version.d.ts +1 -1
- package/dist/generated/version.js +1 -1
- package/dist/ui/__tests__/keyboard.test.js +4 -0
- package/dist/ui/keyboard.d.ts +1 -1
- package/dist/ui/keyboard.js +4 -0
- package/package.json +1 -1
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `runwork dev attach` -- read-only join on a running dev session.
|
|
3
|
+
*
|
|
4
|
+
* Attach is the handover affordance: an agent starts `runwork dev --detach`,
|
|
5
|
+
* a human (or another agent) later runs `runwork dev attach` to see the
|
|
6
|
+
* URL and the live log feed without disturbing the running session.
|
|
7
|
+
*
|
|
8
|
+
* Hard rule: attach NEVER kills the session except in response to an
|
|
9
|
+
* explicit `s` keypress (or `runwork dev stop` in another terminal).
|
|
10
|
+
* Ctrl+C and `q` exit attach but leave the session running. This is the
|
|
11
|
+
* inverted cleanup contract from foreground `runwork dev`, so we keep
|
|
12
|
+
* the keyboard switch local to this module rather than reusing dev.ts's
|
|
13
|
+
* dispatcher.
|
|
14
|
+
*/
|
|
15
|
+
import * as fs from 'fs';
|
|
16
|
+
import { getSessionPaths, getSessionState, readSessionFile, removeSessionFile, } from './session.js';
|
|
17
|
+
const identityColors = {
|
|
18
|
+
dim: (s) => s,
|
|
19
|
+
green: (s) => s,
|
|
20
|
+
yellow: (s) => s,
|
|
21
|
+
red: (s) => s,
|
|
22
|
+
cyan: (s) => s,
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Turn one raw log-file line into a rendered `RenderedEvent`. The line
|
|
26
|
+
* may be NDJSON (the format the detached child writes) or plain text.
|
|
27
|
+
*
|
|
28
|
+
* - Known NDJSON `event` types get a friendly one-line summary.
|
|
29
|
+
* - Unknown JSON shapes pass through as compact JSON with dim styling.
|
|
30
|
+
* - Non-JSON text passes through verbatim, with stderr lines reddened.
|
|
31
|
+
*
|
|
32
|
+
* Pure function: no I/O, no globals, no color env. Tests pass identity
|
|
33
|
+
* color fns to assert exact output without ANSI noise.
|
|
34
|
+
*/
|
|
35
|
+
export function renderLogLine(line, source, colors = identityColors) {
|
|
36
|
+
const trimmed = line.trim();
|
|
37
|
+
if (!trimmed)
|
|
38
|
+
return { text: line, level: 'info' };
|
|
39
|
+
let parsed;
|
|
40
|
+
try {
|
|
41
|
+
parsed = JSON.parse(trimmed);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return {
|
|
45
|
+
text: source === 'stderr' ? colors.red(line) : line,
|
|
46
|
+
level: source === 'stderr' ? 'error' : 'info',
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
50
|
+
return { text: colors.dim(JSON.stringify(parsed)), level: 'info' };
|
|
51
|
+
}
|
|
52
|
+
const ev = parsed.event;
|
|
53
|
+
if (typeof ev !== 'string') {
|
|
54
|
+
return { text: colors.dim(JSON.stringify(parsed)), level: 'info' };
|
|
55
|
+
}
|
|
56
|
+
const obj = parsed;
|
|
57
|
+
switch (ev) {
|
|
58
|
+
case 'session_started': {
|
|
59
|
+
const url = typeof obj.previewUrl === 'string' ? obj.previewUrl : '';
|
|
60
|
+
return {
|
|
61
|
+
text: colors.green('Dev session started') + (url ? ' ' + colors.dim(`(preview ${url})`) : ''),
|
|
62
|
+
level: 'info',
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
case 'preview_url_changed': {
|
|
66
|
+
const next = typeof obj.previewUrl === 'string' ? obj.previewUrl : '?';
|
|
67
|
+
const prev = typeof obj.previousUrl === 'string' ? obj.previousUrl : '?';
|
|
68
|
+
return {
|
|
69
|
+
text: colors.yellow('Preview URL changed') + ' ' + colors.dim(`${prev} -> ${next}`),
|
|
70
|
+
level: 'warn',
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
case 'files_synced': {
|
|
74
|
+
const count = typeof obj.count === 'number' ? obj.count : 0;
|
|
75
|
+
const target = typeof obj.target === 'string' ? obj.target : 'preview';
|
|
76
|
+
return { text: colors.cyan(`Synced ${count} file(s) -> ${target}`), level: 'info' };
|
|
77
|
+
}
|
|
78
|
+
case 'files_pushed': {
|
|
79
|
+
const count = typeof obj.count === 'number' ? obj.count : 0;
|
|
80
|
+
return { text: colors.cyan(`Pushed ${count} file(s) -> git`), level: 'info' };
|
|
81
|
+
}
|
|
82
|
+
case 'startup': {
|
|
83
|
+
const phase = typeof obj.phase === 'string' ? obj.phase : '';
|
|
84
|
+
return { text: colors.dim(`[startup] ${phase}`), level: 'info' };
|
|
85
|
+
}
|
|
86
|
+
case 'sync_restored_critical_files': {
|
|
87
|
+
const files = Array.isArray(obj.files) ? obj.files.filter((f) => typeof f === 'string').join(', ') : '';
|
|
88
|
+
return { text: colors.yellow(`Restored critical files: ${files}`), level: 'warn' };
|
|
89
|
+
}
|
|
90
|
+
case 'error': {
|
|
91
|
+
const errObj = obj.error;
|
|
92
|
+
const msg = typeof errObj?.message === 'string' ? errObj.message : 'unknown error';
|
|
93
|
+
const phase = typeof obj.phase === 'string' ? obj.phase : 'unknown';
|
|
94
|
+
const diag = typeof errObj?.diagnosis === 'string' ? `\n ${colors.dim(errObj.diagnosis)}` : '';
|
|
95
|
+
return { text: colors.red(`ERROR (${phase}): ${msg}${diag}`), level: 'error' };
|
|
96
|
+
}
|
|
97
|
+
default:
|
|
98
|
+
return { text: colors.dim(JSON.stringify(parsed)), level: 'info' };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Poll-based tail of stdout/stderr log files. Cross-platform by design
|
|
103
|
+
* (no `fs.watch`, no native deps, identical behavior on macOS/Linux/
|
|
104
|
+
* Windows). Reads the last `initialLines` lines on start to give the
|
|
105
|
+
* attaching user immediate context, then watches for appends.
|
|
106
|
+
*/
|
|
107
|
+
export function startLogTail(stdoutPath, stderrPath, opts) {
|
|
108
|
+
const intervalMs = opts.intervalMs ?? 500;
|
|
109
|
+
const initialLines = opts.initialLines ?? 50;
|
|
110
|
+
let stopped = false;
|
|
111
|
+
const offsets = { stdout: 0, stderr: 0 };
|
|
112
|
+
const paths = { stdout: stdoutPath, stderr: stderrPath };
|
|
113
|
+
// Initial replay: last N lines from each file (most recent at the bottom).
|
|
114
|
+
for (const source of ['stdout', 'stderr']) {
|
|
115
|
+
const p = paths[source];
|
|
116
|
+
if (!fs.existsSync(p))
|
|
117
|
+
continue;
|
|
118
|
+
try {
|
|
119
|
+
const stat = fs.statSync(p);
|
|
120
|
+
const buf = fs.readFileSync(p, 'utf-8');
|
|
121
|
+
const lines = buf.split('\n');
|
|
122
|
+
// Strip the trailing empty entry from a final `\n`.
|
|
123
|
+
const cleaned = lines[lines.length - 1] === '' ? lines.slice(0, -1) : lines;
|
|
124
|
+
const tail = cleaned.slice(-initialLines);
|
|
125
|
+
for (const line of tail) {
|
|
126
|
+
opts.onLine(line, source);
|
|
127
|
+
}
|
|
128
|
+
offsets[source] = stat.size;
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
opts.onError?.(err);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const tickOne = (source) => {
|
|
135
|
+
const p = paths[source];
|
|
136
|
+
if (!fs.existsSync(p))
|
|
137
|
+
return;
|
|
138
|
+
let stat;
|
|
139
|
+
try {
|
|
140
|
+
stat = fs.statSync(p);
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
opts.onError?.(err);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (stat.size < offsets[source]) {
|
|
147
|
+
// Truncation: a new dev session started and re-opened the log file
|
|
148
|
+
// in 'w' mode. Tell the caller; they decide whether to exit or
|
|
149
|
+
// reset offsets and continue.
|
|
150
|
+
opts.onTruncated?.();
|
|
151
|
+
offsets[source] = 0;
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (stat.size === offsets[source])
|
|
155
|
+
return;
|
|
156
|
+
try {
|
|
157
|
+
const fd = fs.openSync(p, 'r');
|
|
158
|
+
try {
|
|
159
|
+
const length = stat.size - offsets[source];
|
|
160
|
+
const buf = Buffer.alloc(length);
|
|
161
|
+
fs.readSync(fd, buf, 0, length, offsets[source]);
|
|
162
|
+
offsets[source] = stat.size;
|
|
163
|
+
const text = buf.toString('utf-8');
|
|
164
|
+
const lines = text.split('\n');
|
|
165
|
+
const cleaned = lines[lines.length - 1] === '' ? lines.slice(0, -1) : lines;
|
|
166
|
+
for (const line of cleaned) {
|
|
167
|
+
opts.onLine(line, source);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
finally {
|
|
171
|
+
fs.closeSync(fd);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
opts.onError?.(err);
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
const handle = setInterval(() => {
|
|
179
|
+
if (stopped)
|
|
180
|
+
return;
|
|
181
|
+
tickOne('stdout');
|
|
182
|
+
tickOne('stderr');
|
|
183
|
+
}, intervalMs);
|
|
184
|
+
return {
|
|
185
|
+
stop: () => {
|
|
186
|
+
stopped = true;
|
|
187
|
+
clearInterval(handle);
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Format a "started X ago" string for human display. Pure function, no
|
|
193
|
+
* locale handling -- this is a developer tool, not a UI.
|
|
194
|
+
*/
|
|
195
|
+
export function formatStartedAgo(startedAt, now = Date.now()) {
|
|
196
|
+
const sec = Math.max(0, Math.round((now - startedAt) / 1000));
|
|
197
|
+
if (sec < 60)
|
|
198
|
+
return `${sec}s ago`;
|
|
199
|
+
if (sec < 3600)
|
|
200
|
+
return `${Math.floor(sec / 60)}m ago`;
|
|
201
|
+
return `${Math.floor(sec / 3600)}h ago`;
|
|
202
|
+
}
|
|
203
|
+
export function startSessionFileWatch(appDir, initial, opts) {
|
|
204
|
+
const intervalMs = opts.intervalMs ?? 2_000;
|
|
205
|
+
let stopped = false;
|
|
206
|
+
let lastUrl = initial.previewUrl;
|
|
207
|
+
const lastPid = initial.pid;
|
|
208
|
+
const handle = setInterval(() => {
|
|
209
|
+
if (stopped)
|
|
210
|
+
return;
|
|
211
|
+
const file = readSessionFile(appDir);
|
|
212
|
+
if (!file) {
|
|
213
|
+
// The file disappeared. Either the session ended cleanly (its
|
|
214
|
+
// cleanup handler removed it) or someone called `dev stop`. Either
|
|
215
|
+
// way, the session this attach was tracking is gone.
|
|
216
|
+
opts.onSessionGone?.();
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (file.appId !== opts.expectedAppId || file.pid !== lastPid) {
|
|
220
|
+
// A different session took over (e.g., user did `dev --restart`
|
|
221
|
+
// somewhere). Treat the original session as gone -- attach was
|
|
222
|
+
// following a specific PID, not the whole app.
|
|
223
|
+
opts.onSessionGone?.();
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (file.previewUrl && file.previewUrl !== lastUrl) {
|
|
227
|
+
const prev = lastUrl;
|
|
228
|
+
lastUrl = file.previewUrl;
|
|
229
|
+
opts.onUrlChanged?.(file.previewUrl, prev);
|
|
230
|
+
}
|
|
231
|
+
}, intervalMs);
|
|
232
|
+
return {
|
|
233
|
+
stop: () => {
|
|
234
|
+
stopped = true;
|
|
235
|
+
clearInterval(handle);
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Resolve what attach can/should do based on the session file alone.
|
|
241
|
+
* Pure logic; the caller decides what to render and whether to keep
|
|
242
|
+
* running. Split out so the resolution can be unit-tested separately
|
|
243
|
+
* from the long-running tail loop.
|
|
244
|
+
*/
|
|
245
|
+
export function resolveAttachTarget(appDir, expectedAppId) {
|
|
246
|
+
const state = getSessionState(appDir, expectedAppId);
|
|
247
|
+
if (state.state === 'none')
|
|
248
|
+
return { result: 'no-session' };
|
|
249
|
+
if (state.state === 'stale') {
|
|
250
|
+
removeSessionFile(appDir);
|
|
251
|
+
return { result: 'stale-cleaned', reason: state.reason };
|
|
252
|
+
}
|
|
253
|
+
return { result: 'attached', file: state.file };
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Helper exposed for the orchestrator: returns the current preview URL
|
|
257
|
+
* the user should "open" if they press `o`. We re-read the session file
|
|
258
|
+
* each time so URL rotations are honored without plumbing watchers
|
|
259
|
+
* through the keyboard handler.
|
|
260
|
+
*/
|
|
261
|
+
export function getCurrentPreviewUrl(appDir, fallback) {
|
|
262
|
+
const file = readSessionFile(appDir);
|
|
263
|
+
return file?.previewUrl || fallback;
|
|
264
|
+
}
|
|
265
|
+
/** Convenience: the log file paths for the given app dir. */
|
|
266
|
+
export function getAttachLogPaths(appDir) {
|
|
267
|
+
const paths = getSessionPaths(appDir);
|
|
268
|
+
return { stdout: paths.stdoutLog, stderr: paths.stderrLog };
|
|
269
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `runwork dev --detach` orchestration.
|
|
3
|
+
*
|
|
4
|
+
* The detach contract:
|
|
5
|
+
*
|
|
6
|
+
* 1. Parent spawns the child via `process.execPath` with the same args
|
|
7
|
+
* plus `--internal-detached-child`. Child stdout/stderr are routed to
|
|
8
|
+
* log files in `.runwork/`. Parent calls `child.unref()` and exits as
|
|
9
|
+
* soon as the rendezvous signal arrives.
|
|
10
|
+
*
|
|
11
|
+
* 2. Child runs the same `runwork dev` code path as foreground, but in
|
|
12
|
+
* "detached" mode: no TUI, no keypress listener, JSON event stream
|
|
13
|
+
* to stdout (which is the log file).
|
|
14
|
+
*
|
|
15
|
+
* 3. Rendezvous channel is the session file, NOT a pipe. The child
|
|
16
|
+
* writes `.runwork/dev-session.json` once it has a `previewUrl`. The
|
|
17
|
+
* parent polls. When the parent observes a file whose `pid` matches
|
|
18
|
+
* the spawned child AND `previewUrl` is non-empty, parent prints +
|
|
19
|
+
* emits the URL and exits 0.
|
|
20
|
+
*
|
|
21
|
+
* 4. On timeout, parent attempts a single kill of the child, removes
|
|
22
|
+
* any partial session file, and exits 1 with a structured error.
|
|
23
|
+
*
|
|
24
|
+
* The file-as-IPC choice is intentional: pipes don't survive parent exit,
|
|
25
|
+
* and Node IPC has historical Bun-on-Windows quirks. The session file is
|
|
26
|
+
* already our durable lifecycle truth; reusing it for the handshake is
|
|
27
|
+
* free.
|
|
28
|
+
*/
|
|
29
|
+
import { type SessionDeps, type SessionFile } from './session.js';
|
|
30
|
+
/**
|
|
31
|
+
* Internal flag passed to the child. Hidden from `--help`. The child uses
|
|
32
|
+
* its presence to skip the parent-spawn branch and run the actual dev
|
|
33
|
+
* work in detached mode.
|
|
34
|
+
*/
|
|
35
|
+
export declare const INTERNAL_DETACHED_CHILD_FLAG = "--internal-detached-child";
|
|
36
|
+
export type PollOutcome = {
|
|
37
|
+
result: 'ready';
|
|
38
|
+
file: SessionFile;
|
|
39
|
+
} | {
|
|
40
|
+
result: 'timeout';
|
|
41
|
+
} | {
|
|
42
|
+
result: 'wrong-pid';
|
|
43
|
+
file: SessionFile;
|
|
44
|
+
} | {
|
|
45
|
+
result: 'child-exited';
|
|
46
|
+
};
|
|
47
|
+
export interface PollDeps extends SessionDeps {
|
|
48
|
+
now?: () => number;
|
|
49
|
+
sleep?: (ms: number) => Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Poll the session file until either:
|
|
53
|
+
* - a file appears whose pid matches `expectedPid` AND has a non-empty
|
|
54
|
+
* `previewUrl` -> `ready`
|
|
55
|
+
* - the timeout elapses -> `timeout`
|
|
56
|
+
*
|
|
57
|
+
* If a file appears with a different pid (some other process won a write
|
|
58
|
+
* race), we treat it as the user's intent being satisfied and return
|
|
59
|
+
* `wrong-pid` so the caller can decide how to handle it -- usually that's
|
|
60
|
+
* "another `runwork dev` invocation already had a session running, fine."
|
|
61
|
+
*
|
|
62
|
+
* The function does not validate `bootTime` or `appId` -- those are the
|
|
63
|
+
* caller's job once they know which file matters. We only care about the
|
|
64
|
+
* shape of the file and the pid match here.
|
|
65
|
+
*/
|
|
66
|
+
export declare function pollForSession(appDir: string, expectedPid: number, expectedAppId: string, opts?: {
|
|
67
|
+
intervalMs?: number;
|
|
68
|
+
timeoutMs?: number;
|
|
69
|
+
deps?: PollDeps;
|
|
70
|
+
/**
|
|
71
|
+
* Optional: probe whether the spawned child is still running. If
|
|
72
|
+
* provided and it ever returns false, polling aborts immediately
|
|
73
|
+
* with `child-exited`. Lets the caller distinguish "child died
|
|
74
|
+
* before writing the file" (fast fail, ~one tick) from "child is
|
|
75
|
+
* just slow" (wait for timeout). Without this hook the parent
|
|
76
|
+
* would hang for the full timeout on a fast-failing child.
|
|
77
|
+
*/
|
|
78
|
+
isChildAlive?: () => boolean;
|
|
79
|
+
}): Promise<PollOutcome>;
|
|
80
|
+
/**
|
|
81
|
+
* Open the stdout/stderr log files in truncating mode and return their
|
|
82
|
+
* descriptors. We truncate (not append) so each new dev session starts
|
|
83
|
+
* fresh -- log rotation across sessions is out of scope.
|
|
84
|
+
*
|
|
85
|
+
* The descriptors must be closed by the caller after `spawn` so the
|
|
86
|
+
* parent process doesn't keep them open (which would prevent the child
|
|
87
|
+
* from being the sole owner).
|
|
88
|
+
*/
|
|
89
|
+
export declare function openLogFds(appDir: string): {
|
|
90
|
+
stdoutFd: number;
|
|
91
|
+
stderrFd: number;
|
|
92
|
+
};
|
|
93
|
+
export interface SpawnedChildHandle {
|
|
94
|
+
pid: number;
|
|
95
|
+
kill: (signal?: NodeJS.Signals) => boolean;
|
|
96
|
+
/**
|
|
97
|
+
* Returns false once the child has exited. Lets `pollForSession`
|
|
98
|
+
* abort early instead of waiting the full timeout when the child
|
|
99
|
+
* fails fast (missing auth, sync conflict, sandbox-boot error).
|
|
100
|
+
*/
|
|
101
|
+
isAlive: () => boolean;
|
|
102
|
+
}
|
|
103
|
+
export interface DetachParentOptions {
|
|
104
|
+
appDir: string;
|
|
105
|
+
expectedAppId: string;
|
|
106
|
+
childArgs: string[];
|
|
107
|
+
intervalMs?: number;
|
|
108
|
+
timeoutMs?: number;
|
|
109
|
+
/**
|
|
110
|
+
* Optional spawn override for tests. Real callers pass nothing and get
|
|
111
|
+
* the cross-platform spawn defined below.
|
|
112
|
+
*/
|
|
113
|
+
spawn?: (args: string[]) => SpawnedChildHandle;
|
|
114
|
+
pollDeps?: PollDeps;
|
|
115
|
+
}
|
|
116
|
+
export type DetachParentOutcome = {
|
|
117
|
+
result: 'started';
|
|
118
|
+
file: SessionFile;
|
|
119
|
+
} | {
|
|
120
|
+
result: 'wrong-pid';
|
|
121
|
+
file: SessionFile;
|
|
122
|
+
ourPid: number;
|
|
123
|
+
} | {
|
|
124
|
+
result: 'timeout';
|
|
125
|
+
ourPid: number;
|
|
126
|
+
childLogTail?: string;
|
|
127
|
+
} | {
|
|
128
|
+
result: 'child-exited';
|
|
129
|
+
ourPid: number;
|
|
130
|
+
childLogTail?: string;
|
|
131
|
+
} | {
|
|
132
|
+
result: 'spawn-failed';
|
|
133
|
+
error: unknown;
|
|
134
|
+
};
|
|
135
|
+
/**
|
|
136
|
+
* The parent half of `runwork dev --detach`. Returns a structured outcome
|
|
137
|
+
* the caller can translate into stdout text and an exit code.
|
|
138
|
+
*
|
|
139
|
+
* IMPORTANT: this function does NOT print anything. It only orchestrates.
|
|
140
|
+
* The caller (in `dev.ts`) decides how to render the outcome -- human
|
|
141
|
+
* banner vs. JSON event vs. error stream.
|
|
142
|
+
*/
|
|
143
|
+
export declare function runAsDetachedParent(opts: DetachParentOptions): Promise<DetachParentOutcome>;
|
|
144
|
+
/**
|
|
145
|
+
* Cross-platform detached self-spawn. Uses `process.execPath` so we never
|
|
146
|
+
* depend on PATH lookup -- a known failure mode for Bun standalone on
|
|
147
|
+
* Windows. The child gets the user-supplied args plus the internal child
|
|
148
|
+
* marker.
|
|
149
|
+
*/
|
|
150
|
+
export declare function defaultSpawnDetachedChild(childArgs: string[]): SpawnedChildHandle;
|
|
151
|
+
/**
|
|
152
|
+
* Detect whether the current `runwork dev` invocation is the detached
|
|
153
|
+
* child. Used by `dev.ts` to pick between the parent-spawn branch and
|
|
154
|
+
* the actual dev work.
|
|
155
|
+
*/
|
|
156
|
+
export declare function isInternalDetachedChild(argv?: readonly string[]): boolean;
|
|
157
|
+
/**
|
|
158
|
+
* Strip the internal marker from a list of args. Used when constructing
|
|
159
|
+
* the child's args from the parent's own args -- the parent already has
|
|
160
|
+
* the marker, the child needs it, but we want to avoid duplicates if for
|
|
161
|
+
* any reason the parent was itself launched with the marker (e.g., a
|
|
162
|
+
* misconfigured wrapper).
|
|
163
|
+
*/
|
|
164
|
+
export declare function stripInternalChildFlag(args: readonly string[]): string[];
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `runwork dev --detach` orchestration.
|
|
3
|
+
*
|
|
4
|
+
* The detach contract:
|
|
5
|
+
*
|
|
6
|
+
* 1. Parent spawns the child via `process.execPath` with the same args
|
|
7
|
+
* plus `--internal-detached-child`. Child stdout/stderr are routed to
|
|
8
|
+
* log files in `.runwork/`. Parent calls `child.unref()` and exits as
|
|
9
|
+
* soon as the rendezvous signal arrives.
|
|
10
|
+
*
|
|
11
|
+
* 2. Child runs the same `runwork dev` code path as foreground, but in
|
|
12
|
+
* "detached" mode: no TUI, no keypress listener, JSON event stream
|
|
13
|
+
* to stdout (which is the log file).
|
|
14
|
+
*
|
|
15
|
+
* 3. Rendezvous channel is the session file, NOT a pipe. The child
|
|
16
|
+
* writes `.runwork/dev-session.json` once it has a `previewUrl`. The
|
|
17
|
+
* parent polls. When the parent observes a file whose `pid` matches
|
|
18
|
+
* the spawned child AND `previewUrl` is non-empty, parent prints +
|
|
19
|
+
* emits the URL and exits 0.
|
|
20
|
+
*
|
|
21
|
+
* 4. On timeout, parent attempts a single kill of the child, removes
|
|
22
|
+
* any partial session file, and exits 1 with a structured error.
|
|
23
|
+
*
|
|
24
|
+
* The file-as-IPC choice is intentional: pipes don't survive parent exit,
|
|
25
|
+
* and Node IPC has historical Bun-on-Windows quirks. The session file is
|
|
26
|
+
* already our durable lifecycle truth; reusing it for the handshake is
|
|
27
|
+
* free.
|
|
28
|
+
*/
|
|
29
|
+
import * as fs from 'fs';
|
|
30
|
+
import * as child_process from 'child_process';
|
|
31
|
+
import { getSessionPaths, readSessionFile, removeSessionFileIfOwned, } from './session.js';
|
|
32
|
+
/**
|
|
33
|
+
* Internal flag passed to the child. Hidden from `--help`. The child uses
|
|
34
|
+
* its presence to skip the parent-spawn branch and run the actual dev
|
|
35
|
+
* work in detached mode.
|
|
36
|
+
*/
|
|
37
|
+
export const INTERNAL_DETACHED_CHILD_FLAG = '--internal-detached-child';
|
|
38
|
+
const realPollDeps = {
|
|
39
|
+
now: () => Date.now(),
|
|
40
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
41
|
+
};
|
|
42
|
+
function resolvePollDeps(deps) {
|
|
43
|
+
if (!deps)
|
|
44
|
+
return realPollDeps;
|
|
45
|
+
return {
|
|
46
|
+
now: deps.now ?? realPollDeps.now,
|
|
47
|
+
sleep: deps.sleep ?? realPollDeps.sleep,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Poll the session file until either:
|
|
52
|
+
* - a file appears whose pid matches `expectedPid` AND has a non-empty
|
|
53
|
+
* `previewUrl` -> `ready`
|
|
54
|
+
* - the timeout elapses -> `timeout`
|
|
55
|
+
*
|
|
56
|
+
* If a file appears with a different pid (some other process won a write
|
|
57
|
+
* race), we treat it as the user's intent being satisfied and return
|
|
58
|
+
* `wrong-pid` so the caller can decide how to handle it -- usually that's
|
|
59
|
+
* "another `runwork dev` invocation already had a session running, fine."
|
|
60
|
+
*
|
|
61
|
+
* The function does not validate `bootTime` or `appId` -- those are the
|
|
62
|
+
* caller's job once they know which file matters. We only care about the
|
|
63
|
+
* shape of the file and the pid match here.
|
|
64
|
+
*/
|
|
65
|
+
export async function pollForSession(appDir, expectedPid, expectedAppId, opts = {}) {
|
|
66
|
+
const intervalMs = opts.intervalMs ?? 250;
|
|
67
|
+
const timeoutMs = opts.timeoutMs ?? 90_000;
|
|
68
|
+
const d = resolvePollDeps(opts.deps);
|
|
69
|
+
const deadline = d.now() + timeoutMs;
|
|
70
|
+
while (d.now() < deadline) {
|
|
71
|
+
if (opts.isChildAlive && !opts.isChildAlive()) {
|
|
72
|
+
return { result: 'child-exited' };
|
|
73
|
+
}
|
|
74
|
+
const file = readSessionFile(appDir);
|
|
75
|
+
if (file && file.previewUrl && file.appId === expectedAppId) {
|
|
76
|
+
if (file.pid === expectedPid) {
|
|
77
|
+
return { result: 'ready', file };
|
|
78
|
+
}
|
|
79
|
+
// Another process wrote a session file before our child did. The
|
|
80
|
+
// user's intent ("have a dev session running") is satisfied. Caller
|
|
81
|
+
// decides whether to log this and exit success or kill our child.
|
|
82
|
+
return { result: 'wrong-pid', file };
|
|
83
|
+
}
|
|
84
|
+
await d.sleep(intervalMs);
|
|
85
|
+
}
|
|
86
|
+
// One last child-alive probe before declaring timeout: a child that
|
|
87
|
+
// exited just before the deadline would otherwise be reported as
|
|
88
|
+
// "timeout" instead of "child-exited", losing diagnostic information.
|
|
89
|
+
if (opts.isChildAlive && !opts.isChildAlive()) {
|
|
90
|
+
return { result: 'child-exited' };
|
|
91
|
+
}
|
|
92
|
+
return { result: 'timeout' };
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Open the stdout/stderr log files in truncating mode and return their
|
|
96
|
+
* descriptors. We truncate (not append) so each new dev session starts
|
|
97
|
+
* fresh -- log rotation across sessions is out of scope.
|
|
98
|
+
*
|
|
99
|
+
* The descriptors must be closed by the caller after `spawn` so the
|
|
100
|
+
* parent process doesn't keep them open (which would prevent the child
|
|
101
|
+
* from being the sole owner).
|
|
102
|
+
*/
|
|
103
|
+
export function openLogFds(appDir) {
|
|
104
|
+
const paths = getSessionPaths(appDir);
|
|
105
|
+
fs.mkdirSync(paths.dir, { recursive: true });
|
|
106
|
+
const stdoutFd = fs.openSync(paths.stdoutLog, 'w');
|
|
107
|
+
const stderrFd = fs.openSync(paths.stderrLog, 'w');
|
|
108
|
+
return { stdoutFd, stderrFd };
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* The parent half of `runwork dev --detach`. Returns a structured outcome
|
|
112
|
+
* the caller can translate into stdout text and an exit code.
|
|
113
|
+
*
|
|
114
|
+
* IMPORTANT: this function does NOT print anything. It only orchestrates.
|
|
115
|
+
* The caller (in `dev.ts`) decides how to render the outcome -- human
|
|
116
|
+
* banner vs. JSON event vs. error stream.
|
|
117
|
+
*/
|
|
118
|
+
export async function runAsDetachedParent(opts) {
|
|
119
|
+
let child;
|
|
120
|
+
try {
|
|
121
|
+
child = (opts.spawn ?? defaultSpawnDetachedChild)(opts.childArgs);
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
return { result: 'spawn-failed', error: err };
|
|
125
|
+
}
|
|
126
|
+
if (!child.pid) {
|
|
127
|
+
return { result: 'spawn-failed', error: new Error('Spawned child has no PID') };
|
|
128
|
+
}
|
|
129
|
+
const outcome = await pollForSession(opts.appDir, child.pid, opts.expectedAppId, {
|
|
130
|
+
intervalMs: opts.intervalMs,
|
|
131
|
+
timeoutMs: opts.timeoutMs,
|
|
132
|
+
deps: opts.pollDeps,
|
|
133
|
+
isChildAlive: child.isAlive,
|
|
134
|
+
});
|
|
135
|
+
if (outcome.result === 'ready') {
|
|
136
|
+
return { result: 'started', file: outcome.file };
|
|
137
|
+
}
|
|
138
|
+
if (outcome.result === 'wrong-pid') {
|
|
139
|
+
// Another process wrote a session file with a different PID before
|
|
140
|
+
// our child did. Our child is still happily running its own dev
|
|
141
|
+
// setup -- left alone, it will eventually overwrite the winner's
|
|
142
|
+
// session file with its own PID, defeating the duplicate-session
|
|
143
|
+
// guard. SIGTERM our child so the winner remains the unique owner.
|
|
144
|
+
// Best-effort: if the kill fails (race, EPERM), we still surface
|
|
145
|
+
// wrong-pid -- the user/agent will see two sessions briefly and
|
|
146
|
+
// can resolve via `runwork dev stop`.
|
|
147
|
+
try {
|
|
148
|
+
child.kill('SIGTERM');
|
|
149
|
+
}
|
|
150
|
+
catch { /* best-effort */ }
|
|
151
|
+
return { result: 'wrong-pid', file: outcome.file, ourPid: child.pid };
|
|
152
|
+
}
|
|
153
|
+
// For both timeout and child-exited, kill the child (no-op if already
|
|
154
|
+
// gone), clean up any partial session file we own, and surface the
|
|
155
|
+
// stderr tail for diagnostics.
|
|
156
|
+
try {
|
|
157
|
+
child.kill('SIGTERM');
|
|
158
|
+
}
|
|
159
|
+
catch { /* best-effort */ }
|
|
160
|
+
// Only remove the session file if it's ours -- a wrong-pid race could
|
|
161
|
+
// have replaced it with the winner's file between our last probe and
|
|
162
|
+
// here. removeSessionFileIfOwned guards against that.
|
|
163
|
+
try {
|
|
164
|
+
removeSessionFileIfOwned(opts.appDir, child.pid);
|
|
165
|
+
}
|
|
166
|
+
catch { /* best-effort */ }
|
|
167
|
+
const childLogTail = readChildStderrTail(opts.appDir);
|
|
168
|
+
if (outcome.result === 'child-exited') {
|
|
169
|
+
return { result: 'child-exited', ourPid: child.pid, childLogTail };
|
|
170
|
+
}
|
|
171
|
+
return { result: 'timeout', ourPid: child.pid, childLogTail };
|
|
172
|
+
}
|
|
173
|
+
function readChildStderrTail(appDir) {
|
|
174
|
+
try {
|
|
175
|
+
const paths = getSessionPaths(appDir);
|
|
176
|
+
if (!fs.existsSync(paths.stderrLog))
|
|
177
|
+
return undefined;
|
|
178
|
+
const raw = fs.readFileSync(paths.stderrLog, 'utf-8');
|
|
179
|
+
return raw.slice(-2000); // last ~2KB of stderr
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Cross-platform detached self-spawn. Uses `process.execPath` so we never
|
|
187
|
+
* depend on PATH lookup -- a known failure mode for Bun standalone on
|
|
188
|
+
* Windows. The child gets the user-supplied args plus the internal child
|
|
189
|
+
* marker.
|
|
190
|
+
*/
|
|
191
|
+
export function defaultSpawnDetachedChild(childArgs) {
|
|
192
|
+
const cwd = process.cwd();
|
|
193
|
+
const { stdoutFd, stderrFd } = openLogFds(cwd);
|
|
194
|
+
try {
|
|
195
|
+
const proc = child_process.spawn(process.execPath, childArgs, {
|
|
196
|
+
cwd,
|
|
197
|
+
env: process.env,
|
|
198
|
+
detached: true,
|
|
199
|
+
stdio: ['ignore', stdoutFd, stderrFd],
|
|
200
|
+
windowsHide: true,
|
|
201
|
+
});
|
|
202
|
+
if (proc.pid !== undefined) {
|
|
203
|
+
proc.unref();
|
|
204
|
+
}
|
|
205
|
+
// Track exit via the 'exit' event. We can't depend on `proc.exitCode`
|
|
206
|
+
// alone because that's null until the process actually exits.
|
|
207
|
+
let exited = proc.exitCode !== null || proc.signalCode !== null;
|
|
208
|
+
proc.on('exit', () => { exited = true; });
|
|
209
|
+
proc.on('error', () => { exited = true; });
|
|
210
|
+
return {
|
|
211
|
+
pid: proc.pid ?? 0,
|
|
212
|
+
kill: (signal) => proc.kill(signal),
|
|
213
|
+
isAlive: () => !exited,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
finally {
|
|
217
|
+
// The child has dup'd these descriptors; the parent should close its
|
|
218
|
+
// copies so it isn't holding onto the log files. Failure here is
|
|
219
|
+
// non-fatal -- the FDs will be reaped on parent exit anyway.
|
|
220
|
+
try {
|
|
221
|
+
fs.closeSync(stdoutFd);
|
|
222
|
+
}
|
|
223
|
+
catch { /* ignore */ }
|
|
224
|
+
try {
|
|
225
|
+
fs.closeSync(stderrFd);
|
|
226
|
+
}
|
|
227
|
+
catch { /* ignore */ }
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Detect whether the current `runwork dev` invocation is the detached
|
|
232
|
+
* child. Used by `dev.ts` to pick between the parent-spawn branch and
|
|
233
|
+
* the actual dev work.
|
|
234
|
+
*/
|
|
235
|
+
export function isInternalDetachedChild(argv = process.argv) {
|
|
236
|
+
return argv.includes(INTERNAL_DETACHED_CHILD_FLAG);
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Strip the internal marker from a list of args. Used when constructing
|
|
240
|
+
* the child's args from the parent's own args -- the parent already has
|
|
241
|
+
* the marker, the child needs it, but we want to avoid duplicates if for
|
|
242
|
+
* any reason the parent was itself launched with the marker (e.g., a
|
|
243
|
+
* misconfigured wrapper).
|
|
244
|
+
*/
|
|
245
|
+
export function stripInternalChildFlag(args) {
|
|
246
|
+
return args.filter((a) => a !== INTERNAL_DETACHED_CHILD_FLAG);
|
|
247
|
+
}
|