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,347 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as os from 'os';
|
|
4
|
+
import * as path from 'path';
|
|
5
|
+
import { SESSION_FILE_SCHEMA_VERSION, BOOT_TIME_TOLERANCE_MS, buildSessionFile, currentBootTime, getSessionPaths, getSessionState, isBootTimeStale, isPidAlive, readSessionFile, removeSessionFile, removeSessionFileIfOwned, writeSessionFile, } from '../session.js';
|
|
6
|
+
function createTmpAppDir() {
|
|
7
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'runwork-session-test-'));
|
|
8
|
+
}
|
|
9
|
+
function makeValidSessionFile(overrides = {}) {
|
|
10
|
+
return {
|
|
11
|
+
version: SESSION_FILE_SCHEMA_VERSION,
|
|
12
|
+
pid: process.pid,
|
|
13
|
+
sessionId: 'sess_test_abc',
|
|
14
|
+
appId: 'test-app',
|
|
15
|
+
previewUrl: 'https://test.preview.runwork.dev',
|
|
16
|
+
startedAt: 1_700_000_000_000,
|
|
17
|
+
bootTime: 1_600_000_000_000,
|
|
18
|
+
cliVersion: '0.10.2',
|
|
19
|
+
mode: 'foreground',
|
|
20
|
+
...overrides,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
describe('session lifecycle primitives', () => {
|
|
24
|
+
let appDir;
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
appDir = createTmpAppDir();
|
|
27
|
+
});
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
if (fs.existsSync(appDir)) {
|
|
30
|
+
fs.rmSync(appDir, { recursive: true, force: true });
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
describe('getSessionPaths()', () => {
|
|
34
|
+
it('returns the four expected paths under <appDir>/.runwork', () => {
|
|
35
|
+
const paths = getSessionPaths(appDir);
|
|
36
|
+
expect(paths.dir).toBe(path.join(appDir, '.runwork'));
|
|
37
|
+
expect(paths.file).toBe(path.join(appDir, '.runwork', 'dev-session.json'));
|
|
38
|
+
expect(paths.tmpFile).toBe(path.join(appDir, '.runwork', 'dev-session.json.tmp'));
|
|
39
|
+
expect(paths.stdoutLog).toBe(path.join(appDir, '.runwork', 'dev-stdout.log'));
|
|
40
|
+
expect(paths.stderrLog).toBe(path.join(appDir, '.runwork', 'dev-stderr.log'));
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
describe('currentBootTime() / isBootTimeStale()', () => {
|
|
44
|
+
it('currentBootTime is finite and within a few seconds of Date.now() - os.uptime()*1000', () => {
|
|
45
|
+
const expected = Date.now() - os.uptime() * 1000;
|
|
46
|
+
const got = currentBootTime();
|
|
47
|
+
expect(Number.isFinite(got)).toBe(true);
|
|
48
|
+
expect(Math.abs(got - expected)).toBeLessThan(2_000);
|
|
49
|
+
});
|
|
50
|
+
it('returns false (not stale) when stored matches current exactly', () => {
|
|
51
|
+
expect(isBootTimeStale(currentBootTime())).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
it('returns false (not stale) for drift just under the tolerance', () => {
|
|
54
|
+
const now = 1_000_000_000_000;
|
|
55
|
+
const stored = now - (BOOT_TIME_TOLERANCE_MS - 1);
|
|
56
|
+
expect(isBootTimeStale(stored, { bootTime: () => now })).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
it('returns true (stale) for drift just over the tolerance', () => {
|
|
59
|
+
const now = 1_000_000_000_000;
|
|
60
|
+
const stored = now - (BOOT_TIME_TOLERANCE_MS + 1);
|
|
61
|
+
expect(isBootTimeStale(stored, { bootTime: () => now })).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
it('detects a reboot (large boot-time delta)', () => {
|
|
64
|
+
const before = 1_000_000_000_000;
|
|
65
|
+
const afterReboot = before + 24 * 60 * 60 * 1000; // 24 hours later
|
|
66
|
+
expect(isBootTimeStale(before, { bootTime: () => afterReboot })).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
describe('isPidAlive()', () => {
|
|
70
|
+
it('returns true for the currently running process', () => {
|
|
71
|
+
expect(isPidAlive(process.pid)).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
it('returns false for PID 0', () => {
|
|
74
|
+
expect(isPidAlive(0)).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
it('returns false for negative PIDs', () => {
|
|
77
|
+
expect(isPidAlive(-1)).toBe(false);
|
|
78
|
+
expect(isPidAlive(-99999)).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
it('returns false for non-integer PIDs', () => {
|
|
81
|
+
expect(isPidAlive(1.5)).toBe(false);
|
|
82
|
+
expect(isPidAlive(NaN)).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
it('returns false for an almost-certainly-dead high PID', () => {
|
|
85
|
+
// 32-bit max-ish PID on platforms that support it. Vanishingly
|
|
86
|
+
// unlikely to be assigned during test runtime.
|
|
87
|
+
expect(isPidAlive(2_147_483_640)).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
describe('readSessionFile()', () => {
|
|
91
|
+
it('returns null when the file does not exist', () => {
|
|
92
|
+
expect(readSessionFile(appDir)).toBeNull();
|
|
93
|
+
});
|
|
94
|
+
it('returns null when the file is not valid JSON', () => {
|
|
95
|
+
const { dir, file } = getSessionPaths(appDir);
|
|
96
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
97
|
+
fs.writeFileSync(file, 'not-json{{{', 'utf-8');
|
|
98
|
+
expect(readSessionFile(appDir)).toBeNull();
|
|
99
|
+
});
|
|
100
|
+
it('returns null when required fields are missing', () => {
|
|
101
|
+
const { dir, file } = getSessionPaths(appDir);
|
|
102
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
103
|
+
fs.writeFileSync(file, JSON.stringify({ version: 1, pid: 123 }), 'utf-8');
|
|
104
|
+
expect(readSessionFile(appDir)).toBeNull();
|
|
105
|
+
});
|
|
106
|
+
it('returns null when a field has the wrong type', () => {
|
|
107
|
+
const { dir, file } = getSessionPaths(appDir);
|
|
108
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
109
|
+
const bad = { ...makeValidSessionFile(), pid: 'not-a-number' };
|
|
110
|
+
fs.writeFileSync(file, JSON.stringify(bad), 'utf-8');
|
|
111
|
+
expect(readSessionFile(appDir)).toBeNull();
|
|
112
|
+
});
|
|
113
|
+
it('returns null when mode is not one of the allowed strings', () => {
|
|
114
|
+
const { dir, file } = getSessionPaths(appDir);
|
|
115
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
116
|
+
const bad = { ...makeValidSessionFile(), mode: 'something-else' };
|
|
117
|
+
fs.writeFileSync(file, JSON.stringify(bad), 'utf-8');
|
|
118
|
+
expect(readSessionFile(appDir)).toBeNull();
|
|
119
|
+
});
|
|
120
|
+
it('returns the parsed file when valid', () => {
|
|
121
|
+
const valid = makeValidSessionFile();
|
|
122
|
+
writeSessionFile(appDir, valid);
|
|
123
|
+
expect(readSessionFile(appDir)).toEqual(valid);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
describe('writeSessionFile()', () => {
|
|
127
|
+
it('creates the .runwork directory if it does not exist', () => {
|
|
128
|
+
const { dir } = getSessionPaths(appDir);
|
|
129
|
+
expect(fs.existsSync(dir)).toBe(false);
|
|
130
|
+
writeSessionFile(appDir, makeValidSessionFile());
|
|
131
|
+
expect(fs.existsSync(dir)).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
it('produces a file readable by readSessionFile', () => {
|
|
134
|
+
const file = makeValidSessionFile({ previewUrl: 'https://abc.example' });
|
|
135
|
+
writeSessionFile(appDir, file);
|
|
136
|
+
expect(readSessionFile(appDir)).toEqual(file);
|
|
137
|
+
});
|
|
138
|
+
it('does not leave a .tmp file behind on a successful write', () => {
|
|
139
|
+
writeSessionFile(appDir, makeValidSessionFile());
|
|
140
|
+
const { tmpFile } = getSessionPaths(appDir);
|
|
141
|
+
expect(fs.existsSync(tmpFile)).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
it('overwrites the previous file on subsequent writes', () => {
|
|
144
|
+
writeSessionFile(appDir, makeValidSessionFile({ previewUrl: 'https://first' }));
|
|
145
|
+
writeSessionFile(appDir, makeValidSessionFile({ previewUrl: 'https://second' }));
|
|
146
|
+
expect(readSessionFile(appDir)?.previewUrl).toBe('https://second');
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
describe('removeSessionFile()', () => {
|
|
150
|
+
it('removes the session file', () => {
|
|
151
|
+
writeSessionFile(appDir, makeValidSessionFile());
|
|
152
|
+
const { file } = getSessionPaths(appDir);
|
|
153
|
+
expect(fs.existsSync(file)).toBe(true);
|
|
154
|
+
removeSessionFile(appDir);
|
|
155
|
+
expect(fs.existsSync(file)).toBe(false);
|
|
156
|
+
});
|
|
157
|
+
it('is idempotent (no error when the file is missing)', () => {
|
|
158
|
+
expect(() => removeSessionFile(appDir)).not.toThrow();
|
|
159
|
+
expect(() => removeSessionFile(appDir)).not.toThrow();
|
|
160
|
+
});
|
|
161
|
+
it('also removes a leftover .tmp file from a crashed mid-write', () => {
|
|
162
|
+
const { dir, tmpFile } = getSessionPaths(appDir);
|
|
163
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
164
|
+
fs.writeFileSync(tmpFile, '{}', 'utf-8');
|
|
165
|
+
removeSessionFile(appDir);
|
|
166
|
+
expect(fs.existsSync(tmpFile)).toBe(false);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
describe('removeSessionFileIfOwned()', () => {
|
|
170
|
+
it('removes the file when the pid matches', () => {
|
|
171
|
+
writeSessionFile(appDir, makeValidSessionFile({ pid: 12345 }));
|
|
172
|
+
expect(removeSessionFileIfOwned(appDir, 12345)).toBe(true);
|
|
173
|
+
expect(fs.existsSync(getSessionPaths(appDir).file)).toBe(false);
|
|
174
|
+
});
|
|
175
|
+
it('does NOT remove the file when the pid mismatches (another process took over)', () => {
|
|
176
|
+
writeSessionFile(appDir, makeValidSessionFile({ pid: 99999 }));
|
|
177
|
+
expect(removeSessionFileIfOwned(appDir, 12345)).toBe(false);
|
|
178
|
+
expect(fs.existsSync(getSessionPaths(appDir).file)).toBe(true);
|
|
179
|
+
});
|
|
180
|
+
it('returns false (no-op) when the file is missing', () => {
|
|
181
|
+
expect(removeSessionFileIfOwned(appDir, 12345)).toBe(false);
|
|
182
|
+
});
|
|
183
|
+
it('returns false when the file is malformed (treats as not-ours)', () => {
|
|
184
|
+
const { dir, file } = getSessionPaths(appDir);
|
|
185
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
186
|
+
fs.writeFileSync(file, '{ broken', 'utf-8');
|
|
187
|
+
expect(removeSessionFileIfOwned(appDir, 12345)).toBe(false);
|
|
188
|
+
// We don't touch malformed files -- the next `runwork dev` will
|
|
189
|
+
// detect-and-clean via getSessionState, which is the right place
|
|
190
|
+
// for that policy.
|
|
191
|
+
expect(fs.existsSync(file)).toBe(true);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
describe('getSessionState()', () => {
|
|
195
|
+
const aliveDeps = {
|
|
196
|
+
bootTime: () => 1_600_000_000_000,
|
|
197
|
+
pidAlive: (_pid) => true,
|
|
198
|
+
};
|
|
199
|
+
it('returns "none" when no file exists', () => {
|
|
200
|
+
expect(getSessionState(appDir, 'test-app', aliveDeps)).toEqual({ state: 'none' });
|
|
201
|
+
});
|
|
202
|
+
it('returns "stale: malformed" when the file is not valid JSON', () => {
|
|
203
|
+
const { dir, file } = getSessionPaths(appDir);
|
|
204
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
205
|
+
fs.writeFileSync(file, '{ broken', 'utf-8');
|
|
206
|
+
const result = getSessionState(appDir, 'test-app', aliveDeps);
|
|
207
|
+
expect(result.state).toBe('stale');
|
|
208
|
+
if (result.state === 'stale') {
|
|
209
|
+
expect(result.reason).toBe('malformed');
|
|
210
|
+
expect(result.file).toBeUndefined();
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
it('returns "stale: malformed" when required fields are missing', () => {
|
|
214
|
+
const { dir, file } = getSessionPaths(appDir);
|
|
215
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
216
|
+
fs.writeFileSync(file, JSON.stringify({ version: 1, pid: 123 }), 'utf-8');
|
|
217
|
+
const result = getSessionState(appDir, 'test-app', aliveDeps);
|
|
218
|
+
expect(result.state).toBe('stale');
|
|
219
|
+
if (result.state === 'stale') {
|
|
220
|
+
expect(result.reason).toBe('malformed');
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
it('returns "stale: version-mismatch" when the schema version is unknown', () => {
|
|
224
|
+
writeSessionFile(appDir, makeValidSessionFile({ version: 999 }));
|
|
225
|
+
const result = getSessionState(appDir, 'test-app', aliveDeps);
|
|
226
|
+
expect(result.state).toBe('stale');
|
|
227
|
+
if (result.state === 'stale') {
|
|
228
|
+
expect(result.reason).toBe('version-mismatch');
|
|
229
|
+
expect(result.file?.version).toBe(999);
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
it('returns "stale: app-id-mismatch" when appId does not match the expected app', () => {
|
|
233
|
+
writeSessionFile(appDir, makeValidSessionFile({ appId: 'a-different-app' }));
|
|
234
|
+
const result = getSessionState(appDir, 'test-app', aliveDeps);
|
|
235
|
+
expect(result.state).toBe('stale');
|
|
236
|
+
if (result.state === 'stale') {
|
|
237
|
+
expect(result.reason).toBe('app-id-mismatch');
|
|
238
|
+
expect(result.file?.appId).toBe('a-different-app');
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
it('returns "stale: boot-time-mismatch" when the system has rebooted since the session was written', () => {
|
|
242
|
+
const writtenBoot = 1_600_000_000_000;
|
|
243
|
+
writeSessionFile(appDir, makeValidSessionFile({ bootTime: writtenBoot }));
|
|
244
|
+
const result = getSessionState(appDir, 'test-app', {
|
|
245
|
+
bootTime: () => writtenBoot + 24 * 60 * 60 * 1000, // 24h later
|
|
246
|
+
pidAlive: () => true,
|
|
247
|
+
});
|
|
248
|
+
expect(result.state).toBe('stale');
|
|
249
|
+
if (result.state === 'stale') {
|
|
250
|
+
expect(result.reason).toBe('boot-time-mismatch');
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
it('returns "stale: pid-dead" when the PID is no longer running', () => {
|
|
254
|
+
writeSessionFile(appDir, makeValidSessionFile());
|
|
255
|
+
const result = getSessionState(appDir, 'test-app', {
|
|
256
|
+
bootTime: () => 1_600_000_000_000,
|
|
257
|
+
pidAlive: () => false,
|
|
258
|
+
});
|
|
259
|
+
expect(result.state).toBe('stale');
|
|
260
|
+
if (result.state === 'stale') {
|
|
261
|
+
expect(result.reason).toBe('pid-dead');
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
it('returns "alive" when version + appId + bootTime + pid all check out', () => {
|
|
265
|
+
const file = makeValidSessionFile();
|
|
266
|
+
writeSessionFile(appDir, file);
|
|
267
|
+
const result = getSessionState(appDir, 'test-app', aliveDeps);
|
|
268
|
+
expect(result.state).toBe('alive');
|
|
269
|
+
if (result.state === 'alive') {
|
|
270
|
+
expect(result.file).toEqual(file);
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
it('uses real defaults when no deps are passed (regression: undefined-spread)', () => {
|
|
274
|
+
// Build a session file claiming to be the current process at the
|
|
275
|
+
// current boot time, then call getSessionState with NO deps. This
|
|
276
|
+
// exercises the real `currentBootTime` + `isPidAlive` defaults --
|
|
277
|
+
// the path that a buggy `{...realDeps, ...{bootTime: undefined}}`
|
|
278
|
+
// spread would have crashed.
|
|
279
|
+
const file = makeValidSessionFile({
|
|
280
|
+
pid: process.pid,
|
|
281
|
+
bootTime: Date.now() - os.uptime() * 1000,
|
|
282
|
+
});
|
|
283
|
+
writeSessionFile(appDir, file);
|
|
284
|
+
const result = getSessionState(appDir, 'test-app');
|
|
285
|
+
expect(result.state).toBe('alive');
|
|
286
|
+
});
|
|
287
|
+
it('checks version BEFORE app-id, so a v999 with wrong appId reports version-mismatch', () => {
|
|
288
|
+
writeSessionFile(appDir, makeValidSessionFile({ version: 999, appId: 'wrong' }));
|
|
289
|
+
const result = getSessionState(appDir, 'test-app', aliveDeps);
|
|
290
|
+
if (result.state === 'stale') {
|
|
291
|
+
expect(result.reason).toBe('version-mismatch');
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
it('checks bootTime BEFORE pidAlive, so a stale-rebooted file does not depend on the PID syscall', () => {
|
|
295
|
+
writeSessionFile(appDir, makeValidSessionFile({ bootTime: 1_600_000_000_000 }));
|
|
296
|
+
const pidAlive = (_pid) => {
|
|
297
|
+
throw new Error('pidAlive should not be called when bootTime is stale');
|
|
298
|
+
};
|
|
299
|
+
const result = getSessionState(appDir, 'test-app', {
|
|
300
|
+
bootTime: () => 1_600_000_000_000 + 24 * 60 * 60 * 1000,
|
|
301
|
+
pidAlive,
|
|
302
|
+
});
|
|
303
|
+
expect(result.state).toBe('stale');
|
|
304
|
+
if (result.state === 'stale') {
|
|
305
|
+
expect(result.reason).toBe('boot-time-mismatch');
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
describe('buildSessionFile()', () => {
|
|
310
|
+
it('fills version + bootTime + startedAt and passes through the rest', () => {
|
|
311
|
+
const before = Date.now();
|
|
312
|
+
const file = buildSessionFile({
|
|
313
|
+
pid: 12345,
|
|
314
|
+
sessionId: 'sess_x',
|
|
315
|
+
appId: 'app',
|
|
316
|
+
previewUrl: 'https://x',
|
|
317
|
+
cliVersion: '1.2.3',
|
|
318
|
+
mode: 'foreground',
|
|
319
|
+
deps: { bootTime: () => 42 },
|
|
320
|
+
});
|
|
321
|
+
const after = Date.now();
|
|
322
|
+
expect(file.version).toBe(SESSION_FILE_SCHEMA_VERSION);
|
|
323
|
+
expect(file.pid).toBe(12345);
|
|
324
|
+
expect(file.sessionId).toBe('sess_x');
|
|
325
|
+
expect(file.appId).toBe('app');
|
|
326
|
+
expect(file.previewUrl).toBe('https://x');
|
|
327
|
+
expect(file.cliVersion).toBe('1.2.3');
|
|
328
|
+
expect(file.mode).toBe('foreground');
|
|
329
|
+
expect(file.bootTime).toBe(42);
|
|
330
|
+
expect(file.startedAt).toBeGreaterThanOrEqual(before);
|
|
331
|
+
expect(file.startedAt).toBeLessThanOrEqual(after);
|
|
332
|
+
});
|
|
333
|
+
it('honors an explicit startedAt override', () => {
|
|
334
|
+
const file = buildSessionFile({
|
|
335
|
+
pid: 1,
|
|
336
|
+
sessionId: 's',
|
|
337
|
+
appId: 'a',
|
|
338
|
+
previewUrl: '',
|
|
339
|
+
cliVersion: '0.0.0',
|
|
340
|
+
mode: 'detached',
|
|
341
|
+
startedAt: 12345,
|
|
342
|
+
deps: { bootTime: () => 0 },
|
|
343
|
+
});
|
|
344
|
+
expect(file.startedAt).toBe(12345);
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as os from 'os';
|
|
4
|
+
import * as path from 'path';
|
|
5
|
+
import { stopSession } from '../stop.js';
|
|
6
|
+
import { buildSessionFile, getSessionPaths, writeSessionFile, } from '../session.js';
|
|
7
|
+
function createTmpAppDir() {
|
|
8
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'runwork-stop-test-'));
|
|
9
|
+
}
|
|
10
|
+
function makeAliveFile(overrides = {}) {
|
|
11
|
+
return buildSessionFile({
|
|
12
|
+
pid: 99999,
|
|
13
|
+
sessionId: 'sess_test',
|
|
14
|
+
appId: 'test-app',
|
|
15
|
+
previewUrl: 'https://x',
|
|
16
|
+
cliVersion: '0.10.2',
|
|
17
|
+
mode: 'foreground',
|
|
18
|
+
deps: { bootTime: () => 1_000_000_000_000 },
|
|
19
|
+
...overrides,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
describe('stopSession()', () => {
|
|
23
|
+
let appDir;
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
appDir = createTmpAppDir();
|
|
26
|
+
});
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
if (fs.existsSync(appDir)) {
|
|
29
|
+
fs.rmSync(appDir, { recursive: true, force: true });
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
it('returns "no-session" when there is no file to act on', async () => {
|
|
33
|
+
const out = await stopSession(appDir, 'test-app');
|
|
34
|
+
expect(out).toEqual({ result: 'no-session' });
|
|
35
|
+
});
|
|
36
|
+
it('cleans up a stale file (dead PID) without trying to kill anything', async () => {
|
|
37
|
+
const file = makeAliveFile();
|
|
38
|
+
writeSessionFile(appDir, file);
|
|
39
|
+
const killProcess = vi.fn();
|
|
40
|
+
const out = await stopSession(appDir, 'test-app', {
|
|
41
|
+
bootTime: () => 1_000_000_000_000,
|
|
42
|
+
pidAlive: () => false,
|
|
43
|
+
killProcess,
|
|
44
|
+
});
|
|
45
|
+
expect(out.result).toBe('stale-cleaned');
|
|
46
|
+
if (out.result === 'stale-cleaned') {
|
|
47
|
+
expect(out.reason).toBe('pid-dead');
|
|
48
|
+
expect(out.pid).toBe(file.pid);
|
|
49
|
+
}
|
|
50
|
+
expect(killProcess).not.toHaveBeenCalled();
|
|
51
|
+
expect(fs.existsSync(getSessionPaths(appDir).file)).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
it('cleans up a stale file (app-id mismatch) without killing the other app\'s process', async () => {
|
|
54
|
+
writeSessionFile(appDir, makeAliveFile({ appId: 'a-different-app' }));
|
|
55
|
+
const killProcess = vi.fn();
|
|
56
|
+
const out = await stopSession(appDir, 'test-app', {
|
|
57
|
+
bootTime: () => 1_000_000_000_000,
|
|
58
|
+
pidAlive: () => true,
|
|
59
|
+
killProcess,
|
|
60
|
+
});
|
|
61
|
+
expect(out.result).toBe('stale-cleaned');
|
|
62
|
+
if (out.result === 'stale-cleaned') {
|
|
63
|
+
expect(out.reason).toBe('app-id-mismatch');
|
|
64
|
+
}
|
|
65
|
+
expect(killProcess).not.toHaveBeenCalled();
|
|
66
|
+
expect(fs.existsSync(getSessionPaths(appDir).file)).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
it('on Unix: sends SIGTERM, waits for the PID to die, returns "stopped" gracefully', async () => {
|
|
69
|
+
writeSessionFile(appDir, makeAliveFile({ pid: 12345 }));
|
|
70
|
+
let alive = true;
|
|
71
|
+
const killCalls = [];
|
|
72
|
+
const out = await stopSession(appDir, 'test-app', {
|
|
73
|
+
platform: 'darwin',
|
|
74
|
+
bootTime: () => 1_000_000_000_000,
|
|
75
|
+
pidAlive: () => alive,
|
|
76
|
+
killProcess: (pid, signal) => {
|
|
77
|
+
killCalls.push({ pid, signal });
|
|
78
|
+
if (signal === 'SIGTERM') {
|
|
79
|
+
// Simulate the child cleaning up after SIGTERM with a tiny delay.
|
|
80
|
+
setTimeout(() => { alive = false; }, 50);
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
84
|
+
graceTimeoutMs: 2000,
|
|
85
|
+
pollIntervalMs: 25,
|
|
86
|
+
});
|
|
87
|
+
expect(out.result).toBe('stopped');
|
|
88
|
+
if (out.result === 'stopped') {
|
|
89
|
+
expect(out.pid).toBe(12345);
|
|
90
|
+
expect(out.gracefully).toBe(true);
|
|
91
|
+
}
|
|
92
|
+
expect(killCalls).toHaveLength(1);
|
|
93
|
+
expect(killCalls[0]).toEqual({ pid: 12345, signal: 'SIGTERM' });
|
|
94
|
+
expect(fs.existsSync(getSessionPaths(appDir).file)).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
it('on Unix: falls back to SIGKILL when SIGTERM does not stop the process within graceTimeoutMs', async () => {
|
|
97
|
+
writeSessionFile(appDir, makeAliveFile({ pid: 12345 }));
|
|
98
|
+
const killCalls = [];
|
|
99
|
+
const out = await stopSession(appDir, 'test-app', {
|
|
100
|
+
platform: 'linux',
|
|
101
|
+
bootTime: () => 1_000_000_000_000,
|
|
102
|
+
pidAlive: () => true, // never dies on its own
|
|
103
|
+
killProcess: (pid, signal) => {
|
|
104
|
+
killCalls.push({ pid, signal });
|
|
105
|
+
},
|
|
106
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
107
|
+
graceTimeoutMs: 200,
|
|
108
|
+
pollIntervalMs: 25,
|
|
109
|
+
});
|
|
110
|
+
expect(out.result).toBe('stopped');
|
|
111
|
+
if (out.result === 'stopped') {
|
|
112
|
+
expect(out.gracefully).toBe(false);
|
|
113
|
+
}
|
|
114
|
+
expect(killCalls.map((c) => c.signal)).toEqual(['SIGTERM', 'SIGKILL']);
|
|
115
|
+
expect(fs.existsSync(getSessionPaths(appDir).file)).toBe(false);
|
|
116
|
+
});
|
|
117
|
+
it('on Windows: kills with TerminateProcess (no signal arg), no SIGKILL fallback path', async () => {
|
|
118
|
+
writeSessionFile(appDir, makeAliveFile({ pid: 12345 }));
|
|
119
|
+
const killCalls = [];
|
|
120
|
+
const out = await stopSession(appDir, 'test-app', {
|
|
121
|
+
platform: 'win32',
|
|
122
|
+
bootTime: () => 1_000_000_000_000,
|
|
123
|
+
pidAlive: () => true,
|
|
124
|
+
killProcess: (pid, signal) => {
|
|
125
|
+
killCalls.push({ pid, signal });
|
|
126
|
+
},
|
|
127
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
128
|
+
});
|
|
129
|
+
expect(out.result).toBe('stopped');
|
|
130
|
+
expect(killCalls).toHaveLength(1);
|
|
131
|
+
expect(killCalls[0].signal).toBeUndefined();
|
|
132
|
+
expect(fs.existsSync(getSessionPaths(appDir).file)).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
it('returns "kill-failed" but still removes the session file when the kill throws', async () => {
|
|
135
|
+
writeSessionFile(appDir, makeAliveFile({ pid: 12345 }));
|
|
136
|
+
const out = await stopSession(appDir, 'test-app', {
|
|
137
|
+
platform: 'linux',
|
|
138
|
+
bootTime: () => 1_000_000_000_000,
|
|
139
|
+
pidAlive: () => true,
|
|
140
|
+
killProcess: () => {
|
|
141
|
+
const err = new Error('EPERM');
|
|
142
|
+
err.code = 'EPERM';
|
|
143
|
+
throw err;
|
|
144
|
+
},
|
|
145
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
146
|
+
});
|
|
147
|
+
expect(out.result).toBe('kill-failed');
|
|
148
|
+
if (out.result === 'kill-failed') {
|
|
149
|
+
expect(out.pid).toBe(12345);
|
|
150
|
+
}
|
|
151
|
+
expect(fs.existsSync(getSessionPaths(appDir).file)).toBe(false);
|
|
152
|
+
});
|
|
153
|
+
it('treats the session file disappearing during the SIGTERM wait as graceful exit', async () => {
|
|
154
|
+
writeSessionFile(appDir, makeAliveFile({ pid: 12345 }));
|
|
155
|
+
const out = await stopSession(appDir, 'test-app', {
|
|
156
|
+
platform: 'darwin',
|
|
157
|
+
bootTime: () => 1_000_000_000_000,
|
|
158
|
+
pidAlive: () => true, // PID stays alive in the mock...
|
|
159
|
+
killProcess: () => {
|
|
160
|
+
// ...but the "child" removes its file mid-grace-period.
|
|
161
|
+
setTimeout(() => { fs.rmSync(getSessionPaths(appDir).file); }, 30);
|
|
162
|
+
},
|
|
163
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
164
|
+
graceTimeoutMs: 500,
|
|
165
|
+
pollIntervalMs: 10,
|
|
166
|
+
});
|
|
167
|
+
expect(out.result).toBe('stopped');
|
|
168
|
+
if (out.result === 'stopped') {
|
|
169
|
+
expect(out.gracefully).toBe(true);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
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 { type SessionFile } from './session.js';
|
|
16
|
+
export interface RenderedEvent {
|
|
17
|
+
/** Pre-styled human-readable line ready to print to a TTY. */
|
|
18
|
+
text: string;
|
|
19
|
+
/** Severity hint, useful for filtering or color. */
|
|
20
|
+
level: 'info' | 'warn' | 'error';
|
|
21
|
+
}
|
|
22
|
+
interface ColorFns {
|
|
23
|
+
dim: (s: string) => string;
|
|
24
|
+
green: (s: string) => string;
|
|
25
|
+
yellow: (s: string) => string;
|
|
26
|
+
red: (s: string) => string;
|
|
27
|
+
cyan: (s: string) => string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Turn one raw log-file line into a rendered `RenderedEvent`. The line
|
|
31
|
+
* may be NDJSON (the format the detached child writes) or plain text.
|
|
32
|
+
*
|
|
33
|
+
* - Known NDJSON `event` types get a friendly one-line summary.
|
|
34
|
+
* - Unknown JSON shapes pass through as compact JSON with dim styling.
|
|
35
|
+
* - Non-JSON text passes through verbatim, with stderr lines reddened.
|
|
36
|
+
*
|
|
37
|
+
* Pure function: no I/O, no globals, no color env. Tests pass identity
|
|
38
|
+
* color fns to assert exact output without ANSI noise.
|
|
39
|
+
*/
|
|
40
|
+
export declare function renderLogLine(line: string, source: 'stdout' | 'stderr', colors?: ColorFns): RenderedEvent;
|
|
41
|
+
export interface LogTailOptions {
|
|
42
|
+
/** Number of trailing lines to replay on attach. Default 50. */
|
|
43
|
+
initialLines?: number;
|
|
44
|
+
/** Poll interval. Default 500ms. */
|
|
45
|
+
intervalMs?: number;
|
|
46
|
+
/** Called for each new line. */
|
|
47
|
+
onLine: (line: string, source: 'stdout' | 'stderr') => void;
|
|
48
|
+
/**
|
|
49
|
+
* Called when we detect file truncation (size shrunk) on either file.
|
|
50
|
+
* The orchestrator decides whether this is a "session restarted -- exit
|
|
51
|
+
* attach" or "spurious -- reset and continue."
|
|
52
|
+
*/
|
|
53
|
+
onTruncated?: () => void;
|
|
54
|
+
onError?: (err: unknown) => void;
|
|
55
|
+
}
|
|
56
|
+
export interface LogTail {
|
|
57
|
+
stop(): void;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Poll-based tail of stdout/stderr log files. Cross-platform by design
|
|
61
|
+
* (no `fs.watch`, no native deps, identical behavior on macOS/Linux/
|
|
62
|
+
* Windows). Reads the last `initialLines` lines on start to give the
|
|
63
|
+
* attaching user immediate context, then watches for appends.
|
|
64
|
+
*/
|
|
65
|
+
export declare function startLogTail(stdoutPath: string, stderrPath: string, opts: LogTailOptions): LogTail;
|
|
66
|
+
/**
|
|
67
|
+
* Format a "started X ago" string for human display. Pure function, no
|
|
68
|
+
* locale handling -- this is a developer tool, not a UI.
|
|
69
|
+
*/
|
|
70
|
+
export declare function formatStartedAgo(startedAt: number, now?: number): string;
|
|
71
|
+
/**
|
|
72
|
+
* Watch the session file for `previewUrl` rotations or for the file
|
|
73
|
+
* disappearing (the session ended). Polls instead of `fs.watch` for
|
|
74
|
+
* cross-platform consistency.
|
|
75
|
+
*/
|
|
76
|
+
export interface SessionWatchOptions {
|
|
77
|
+
intervalMs?: number;
|
|
78
|
+
expectedAppId: string;
|
|
79
|
+
onUrlChanged?: (next: string, prev: string) => void;
|
|
80
|
+
onSessionGone?: () => void;
|
|
81
|
+
onError?: (err: unknown) => void;
|
|
82
|
+
}
|
|
83
|
+
export interface SessionWatch {
|
|
84
|
+
stop(): void;
|
|
85
|
+
}
|
|
86
|
+
export declare function startSessionFileWatch(appDir: string, initial: SessionFile, opts: SessionWatchOptions): SessionWatch;
|
|
87
|
+
/**
|
|
88
|
+
* Result returned to `dev.ts` so it can pick a top-level message + exit
|
|
89
|
+
* code. The orchestrator function never calls `process.exit` itself --
|
|
90
|
+
* tests can drive it without spawning a child.
|
|
91
|
+
*/
|
|
92
|
+
export type AttachResult = {
|
|
93
|
+
result: 'no-session';
|
|
94
|
+
} | {
|
|
95
|
+
result: 'stale-cleaned';
|
|
96
|
+
reason: string;
|
|
97
|
+
} | {
|
|
98
|
+
result: 'attached';
|
|
99
|
+
file: SessionFile;
|
|
100
|
+
};
|
|
101
|
+
/**
|
|
102
|
+
* Resolve what attach can/should do based on the session file alone.
|
|
103
|
+
* Pure logic; the caller decides what to render and whether to keep
|
|
104
|
+
* running. Split out so the resolution can be unit-tested separately
|
|
105
|
+
* from the long-running tail loop.
|
|
106
|
+
*/
|
|
107
|
+
export declare function resolveAttachTarget(appDir: string, expectedAppId: string): AttachResult;
|
|
108
|
+
/**
|
|
109
|
+
* Helper exposed for the orchestrator: returns the current preview URL
|
|
110
|
+
* the user should "open" if they press `o`. We re-read the session file
|
|
111
|
+
* each time so URL rotations are honored without plumbing watchers
|
|
112
|
+
* through the keyboard handler.
|
|
113
|
+
*/
|
|
114
|
+
export declare function getCurrentPreviewUrl(appDir: string, fallback: string): string;
|
|
115
|
+
/** Convenience: the log file paths for the given app dir. */
|
|
116
|
+
export declare function getAttachLogPaths(appDir: string): {
|
|
117
|
+
stdout: string;
|
|
118
|
+
stderr: string;
|
|
119
|
+
};
|
|
120
|
+
export {};
|