runwork 0.10.2 → 0.10.4
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/agents/__tests__/claude-code-stats.test.js +1 -0
- package/dist/agents/claude-code.js +1 -1
- package/dist/agents/cursor.js +1 -1
- package/dist/commands/clone.js +1 -1
- package/dist/commands/deploy.js +1 -1
- package/dist/commands/dev.d.ts +3 -0
- package/dist/commands/dev.js +628 -11
- package/dist/commands/info.d.ts +31 -0
- package/dist/commands/info.js +37 -0
- package/dist/commands/init.js +1 -1
- 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 +404 -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 +187 -0
- package/dist/dev/detach.js +292 -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/git/__tests__/credentials.test.js +1 -1
- package/dist/git/auto-commit.js +1 -1
- package/dist/git/credentials.js +1 -1
- package/dist/git/identity.js +1 -1
- package/dist/git/preflight.js +1 -1
- package/dist/git/sync.js +1 -1
- package/dist/health/checks.js +1 -1
- package/dist/template/manifest.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/dist/utils/agent-guidance.d.ts +13 -0
- package/dist/utils/agent-guidance.js +22 -7
- package/dist/utils/subprocess.d.ts +19 -0
- package/dist/utils/subprocess.js +27 -0
- package/dist/utils/which.js +1 -1
- package/package.json +1 -1
package/dist/commands/dev.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Command } from 'commander';
|
|
2
|
-
import { execFileSync } from '
|
|
1
|
+
import { Command, Option } from 'commander';
|
|
2
|
+
import { execFileSync } from '../utils/subprocess.js';
|
|
3
3
|
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
4
4
|
import { join } from 'path';
|
|
5
5
|
import { requireAuth } from '../auth/store.js';
|
|
@@ -9,6 +9,11 @@ import { syncWithRemote } from '../git/sync.js';
|
|
|
9
9
|
import { ensureGitIdentity } from '../git/identity.js';
|
|
10
10
|
import { requireGit } from '../git/preflight.js';
|
|
11
11
|
import { startLogTailer } from '../logs/tailer.js';
|
|
12
|
+
import { startPreviewUrlPoller } from '../dev/preview-url-poller.js';
|
|
13
|
+
import { buildSessionFile, getSessionState, readSessionFile, removeSessionFile, removeSessionFileIfOwned, writeSessionFile, } from '../dev/session.js';
|
|
14
|
+
import { INTERNAL_DETACHED_CHILD_FLAG, buildChildArgs, isInternalDetachedChild, runAsDetachedParent, } from '../dev/detach.js';
|
|
15
|
+
import { stopSession } from '../dev/stop.js';
|
|
16
|
+
import { formatStartedAgo, getAttachLogPaths, getCurrentPreviewUrl, renderLogLine, resolveAttachTarget, startLogTail, startSessionFileWatch, } from '../dev/attach.js';
|
|
12
17
|
import { populateTypes } from '../types-manager.js';
|
|
13
18
|
import { loadManifest, generateManifest, saveManifest, detectUserEdits } from '../template/manifest.js';
|
|
14
19
|
import { extractZip } from '../utils/zip.js';
|
|
@@ -16,9 +21,10 @@ import { removeNestedGitDirs } from '../utils/fs.js';
|
|
|
16
21
|
import { getDevBanner, getKeyboardHints, getInfoPanel } from '../ui/banner.js';
|
|
17
22
|
import { createStatusLine } from '../ui/status-line.js';
|
|
18
23
|
import { createKeyboardListener } from '../ui/keyboard.js';
|
|
19
|
-
import { bold, dim, green, yellow, cyan } from '../ui/colors.js';
|
|
24
|
+
import { bold, dim, green, yellow, cyan, red } from '../ui/colors.js';
|
|
20
25
|
import { shouldOutputJson, jsonLine } from '../utils/output.js';
|
|
21
26
|
import { buildDevSessionGuide } from '../utils/agent-guidance.js';
|
|
27
|
+
import { VERSION } from '../generated/version.js';
|
|
22
28
|
async function populateSkill(projectDir, client, appId) {
|
|
23
29
|
try {
|
|
24
30
|
const { skill } = await client.getAppSkill(appId);
|
|
@@ -99,29 +105,90 @@ function commitAndPushRestoredFiles(cwd, files) {
|
|
|
99
105
|
}
|
|
100
106
|
export async function execDev(options) {
|
|
101
107
|
const useJson = options?.json ?? false;
|
|
108
|
+
const mode = options?.mode ?? 'foreground';
|
|
102
109
|
requireGit('dev');
|
|
103
110
|
const config = readConfig();
|
|
104
111
|
const creds = requireAuth();
|
|
105
112
|
const client = new ApiClient(creds);
|
|
106
113
|
const cwd = process.cwd();
|
|
107
114
|
const ts = () => new Date().toISOString();
|
|
115
|
+
// --restart: stop any pre-existing session before starting a new one.
|
|
116
|
+
// We do this BEFORE the "already running" check so the user's intent
|
|
117
|
+
// is respected even when a healthy session is already in place.
|
|
118
|
+
if (options?.restart) {
|
|
119
|
+
const stopOutcome = await stopSession(cwd, config.appId);
|
|
120
|
+
if (!useJson && stopOutcome.result === 'stopped') {
|
|
121
|
+
console.log(dim(`Stopped previous dev session (PID ${stopOutcome.pid}).`));
|
|
122
|
+
}
|
|
123
|
+
if (useJson && (stopOutcome.result === 'stopped' || stopOutcome.result === 'stale-cleaned')) {
|
|
124
|
+
jsonLine({
|
|
125
|
+
event: 'session_stopped',
|
|
126
|
+
pid: 'pid' in stopOutcome ? stopOutcome.pid : undefined,
|
|
127
|
+
timestamp: ts(),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
// Idempotency check: read the session file before doing any work. If a
|
|
132
|
+
// live session is already running for this app on this machine, exit
|
|
133
|
+
// success with a structured event so the caller (human or agent) knows
|
|
134
|
+
// the URL and can move on. Stale files are cleaned up silently.
|
|
135
|
+
const existing = getSessionState(cwd, config.appId);
|
|
136
|
+
if (existing.state === 'alive') {
|
|
137
|
+
const f = existing.file;
|
|
138
|
+
if (useJson) {
|
|
139
|
+
jsonLine({
|
|
140
|
+
event: 'already_running',
|
|
141
|
+
previewUrl: f.previewUrl,
|
|
142
|
+
pid: f.pid,
|
|
143
|
+
sessionId: f.sessionId,
|
|
144
|
+
mode: f.mode,
|
|
145
|
+
timestamp: ts(),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
console.log(`Dev session already running (PID ${f.pid}, mode: ${f.mode}).`);
|
|
150
|
+
console.log(` Preview: ${green(f.previewUrl || '(starting up)')}`);
|
|
151
|
+
console.log(dim(` Follow logs: runwork dev attach`));
|
|
152
|
+
console.log(dim(` Stop: runwork dev stop`));
|
|
153
|
+
console.log(dim(` Start fresh: runwork dev --restart`));
|
|
154
|
+
}
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (existing.state === 'stale') {
|
|
158
|
+
removeSessionFile(cwd);
|
|
159
|
+
if (!useJson) {
|
|
160
|
+
console.log(dim(`Cleaned up dead session from a previous run (reason: ${existing.reason}).`));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
108
163
|
// Seed a local git identity so the very first commit cannot fail on a
|
|
109
164
|
// fresh machine where `git config --global user.email/.name` has never
|
|
110
165
|
// been set (extremely common on Windows after a clean install).
|
|
111
166
|
ensureGitIdentity(cwd, creds);
|
|
112
|
-
// Detect user edits made outside of `runwork dev
|
|
167
|
+
// Detect user edits made outside of `runwork dev`. When this fires
|
|
168
|
+
// for an AI agent, it almost always means the agent edited code
|
|
169
|
+
// before starting dev -- the wrong order. We surface this loudly so
|
|
170
|
+
// the agent's tool-result-handling can correct course on the next
|
|
171
|
+
// task. The recommendation is canonical: start `runwork dev --detach`
|
|
172
|
+
// first, then edit. See DEV_FIRST_RULE.
|
|
113
173
|
const oldManifest = await loadManifest(cwd);
|
|
114
174
|
if (oldManifest) {
|
|
115
175
|
const userEdits = await detectUserEdits(cwd, oldManifest);
|
|
116
176
|
if (userEdits.length > 0) {
|
|
117
177
|
if (useJson) {
|
|
118
|
-
jsonLine({
|
|
178
|
+
jsonLine({
|
|
179
|
+
event: 'startup',
|
|
180
|
+
phase: 'user_edits_detected',
|
|
181
|
+
files: userEdits,
|
|
182
|
+
timestamp: ts(),
|
|
183
|
+
warning: `Detected ${userEdits.length} file(s) edited before \`runwork dev\` was running. These will be batch-synced now. Next time, start \`runwork dev --detach\` BEFORE editing so changes flow incrementally and you can verify each edit against the preview.`,
|
|
184
|
+
});
|
|
119
185
|
}
|
|
120
186
|
else {
|
|
121
187
|
console.log(`Detected ${userEdits.length} file(s) edited outside dev session:`);
|
|
122
188
|
for (const file of userEdits) {
|
|
123
189
|
console.log(` ${file}`);
|
|
124
190
|
}
|
|
191
|
+
console.log(yellow(` Next time, start \`runwork dev --detach\` BEFORE editing -- changes will sync incrementally and the preview will validate each one.`));
|
|
125
192
|
}
|
|
126
193
|
try {
|
|
127
194
|
execFileSync('git', ['add', '--', ...userEdits], { stdio: 'pipe' });
|
|
@@ -257,12 +324,30 @@ export async function execDev(options) {
|
|
|
257
324
|
console.warn(yellow('Push failed. Continuing with current state...'));
|
|
258
325
|
}
|
|
259
326
|
}
|
|
327
|
+
// The session POST returns a snapshot of the preview URL at boot time.
|
|
328
|
+
// Sandboxes can rotate that URL mid-session (instance replacement,
|
|
329
|
+
// tunnel restart), so we treat this as the *initial* value and refresh
|
|
330
|
+
// it via `getDevStatus` -- the same source `runwork info` reads from.
|
|
331
|
+
// That keeps `dev` and `info` in agreement.
|
|
332
|
+
let currentPreviewUrl = session.previewUrl;
|
|
333
|
+
// Write the session file as soon as we have the URL. This is the
|
|
334
|
+
// rendezvous moment for `runwork dev --detach` -- the parent is polling
|
|
335
|
+
// `.runwork/dev-session.json` and exits 0 when this file appears with a
|
|
336
|
+
// non-empty previewUrl matching the child's PID.
|
|
337
|
+
writeSessionFile(cwd, buildSessionFile({
|
|
338
|
+
pid: process.pid,
|
|
339
|
+
sessionId: session.sessionId,
|
|
340
|
+
appId: config.appId,
|
|
341
|
+
previewUrl: currentPreviewUrl,
|
|
342
|
+
cliVersion: VERSION,
|
|
343
|
+
mode,
|
|
344
|
+
}));
|
|
260
345
|
// Emit session_started (JSON) or show banner (human)
|
|
261
346
|
if (useJson) {
|
|
262
|
-
jsonLine({ event: 'session_started', previewUrl:
|
|
347
|
+
jsonLine({ event: 'session_started', previewUrl: currentPreviewUrl, appName: config.appName, timestamp: ts(), guide: buildDevSessionGuide() });
|
|
263
348
|
}
|
|
264
349
|
else {
|
|
265
|
-
console.log(getDevBanner({ appName: config.appName, previewUrl:
|
|
350
|
+
console.log(getDevBanner({ appName: config.appName, previewUrl: currentPreviewUrl, workspaceName: config.workspaceName }));
|
|
266
351
|
console.log(getKeyboardHints());
|
|
267
352
|
console.log('');
|
|
268
353
|
console.log(dim(' ─────────────────────────────────────────────'));
|
|
@@ -277,16 +362,58 @@ export async function execDev(options) {
|
|
|
277
362
|
return;
|
|
278
363
|
const filterLabel = logFilter === 'all' ? 'All' : logFilter === 'events' ? 'Events' : 'Runtime';
|
|
279
364
|
const sep = '\x1b[90m\u2502\x1b[39m';
|
|
280
|
-
statusLine.update(`${bold(config.appName)} ${sep} ${green(
|
|
365
|
+
statusLine.update(`${bold(config.appName)} ${sep} ${green(currentPreviewUrl)} ${sep} ${cyan(`${syncedCount} synced`)} ${sep} ${filterLabel} ${sep} \x1b[90mo\x1b[39m:open \x1b[90mi\x1b[39m:info \x1b[90ma/e/r\x1b[39m:filter \x1b[90mq\x1b[39m:quit`);
|
|
281
366
|
}
|
|
282
367
|
if (statusLine)
|
|
283
368
|
updateStatus();
|
|
369
|
+
const previewUrlPoller = startPreviewUrlPoller({
|
|
370
|
+
client,
|
|
371
|
+
appId: config.appId,
|
|
372
|
+
initialUrl: currentPreviewUrl,
|
|
373
|
+
onChange: (next, prev) => {
|
|
374
|
+
currentPreviewUrl = next;
|
|
375
|
+
// Persist the rotated URL into the session file so `runwork info`,
|
|
376
|
+
// detached parent processes, and other observers see the same
|
|
377
|
+
// preview URL we're showing in the status line.
|
|
378
|
+
const current = readSessionFile(cwd);
|
|
379
|
+
if (current && current.pid === process.pid) {
|
|
380
|
+
writeSessionFile(cwd, { ...current, previewUrl: next });
|
|
381
|
+
}
|
|
382
|
+
if (useJson) {
|
|
383
|
+
jsonLine({
|
|
384
|
+
event: 'preview_url_changed',
|
|
385
|
+
previewUrl: next,
|
|
386
|
+
previousUrl: prev,
|
|
387
|
+
appName: config.appName,
|
|
388
|
+
timestamp: ts(),
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
else {
|
|
392
|
+
console.log(dim(`Preview URL changed: ${next}`));
|
|
393
|
+
updateStatus();
|
|
394
|
+
}
|
|
395
|
+
},
|
|
396
|
+
});
|
|
284
397
|
let logTailer;
|
|
285
398
|
const keyboard = useJson ? null : createKeyboardListener();
|
|
286
399
|
const cleanup = async () => {
|
|
400
|
+
// Remove the session file FIRST. If a Windows console-close-event
|
|
401
|
+
// truncates our cleanup window, or if we crash later in this handler,
|
|
402
|
+
// at least the file is gone -- the next `runwork dev` will see a
|
|
403
|
+
// clean slate. Stale-detection on next-startup is the primary recovery
|
|
404
|
+
// path; this is just polish for the graceful-exit case.
|
|
405
|
+
//
|
|
406
|
+
// Use the PID-guarded variant: if a startup race ended with another
|
|
407
|
+
// process owning the session file, we must NOT delete it -- that
|
|
408
|
+
// would leave the winner's session orphaned.
|
|
409
|
+
try {
|
|
410
|
+
removeSessionFileIfOwned(cwd, process.pid);
|
|
411
|
+
}
|
|
412
|
+
catch { /* best-effort */ }
|
|
287
413
|
if (!useJson)
|
|
288
414
|
console.log('\nStopping...');
|
|
289
415
|
logTailer?.stop();
|
|
416
|
+
previewUrlPoller.stop();
|
|
290
417
|
await stopAutoCommit();
|
|
291
418
|
statusLine?.destroy();
|
|
292
419
|
keyboard?.stop();
|
|
@@ -299,7 +426,7 @@ export async function execDev(options) {
|
|
|
299
426
|
switch (action) {
|
|
300
427
|
case 'o':
|
|
301
428
|
case 'p':
|
|
302
|
-
import('open').then(m => m.default(
|
|
429
|
+
import('open').then(m => m.default(currentPreviewUrl));
|
|
303
430
|
break;
|
|
304
431
|
case 'a':
|
|
305
432
|
logFilter = 'all';
|
|
@@ -319,7 +446,7 @@ export async function execDev(options) {
|
|
|
319
446
|
case 'i':
|
|
320
447
|
console.log(getInfoPanel({
|
|
321
448
|
appName: config.appName,
|
|
322
|
-
previewUrl:
|
|
449
|
+
previewUrl: currentPreviewUrl,
|
|
323
450
|
workspaceName: config.workspaceName,
|
|
324
451
|
directory: cwd,
|
|
325
452
|
}));
|
|
@@ -358,7 +485,497 @@ export const devCommand = new Command('dev')
|
|
|
358
485
|
.description('Start local development with live sync, preview sandbox, and file watching')
|
|
359
486
|
.option('--no-logs', 'Disable automatic log tailing')
|
|
360
487
|
.option('--logs-only-file', 'Write logs to file only, not terminal')
|
|
488
|
+
.option('--detach', 'Start the dev session in the background and exit. Logs go to .runwork/dev-{stdout,stderr}.log')
|
|
489
|
+
.option('--restart', 'Stop any existing dev session first, then start a fresh one')
|
|
490
|
+
// Hidden internal flag passed by `runwork dev --detach` when it spawns
|
|
491
|
+
// its detached child. Not for end users -- registered here only so
|
|
492
|
+
// commander does not throw "unknown option" when the child receives it.
|
|
493
|
+
.addOption(new Option(INTERNAL_DETACHED_CHILD_FLAG).hideHelp())
|
|
361
494
|
.action(async (options, command) => {
|
|
362
495
|
const globalJson = shouldOutputJson(command.optsWithGlobals().json);
|
|
363
|
-
|
|
496
|
+
// The internal child marker is parsed manually because we want it
|
|
497
|
+
// hidden from --help and from commander's option list. Its presence
|
|
498
|
+
// means: "this process is the detached child of a `--detach` parent;
|
|
499
|
+
// skip the parent-spawn branch and run the actual dev work."
|
|
500
|
+
const isChild = isInternalDetachedChild(process.argv);
|
|
501
|
+
if (options.detach && !isChild) {
|
|
502
|
+
await runDevDetachParent({
|
|
503
|
+
json: globalJson,
|
|
504
|
+
restart: options.restart,
|
|
505
|
+
});
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
await execDev({
|
|
509
|
+
...options,
|
|
510
|
+
json: globalJson,
|
|
511
|
+
mode: isChild ? 'detached' : 'foreground',
|
|
512
|
+
restart: options.restart,
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
/**
|
|
516
|
+
* Parent half of `runwork dev --detach`. Reads the app config to know
|
|
517
|
+
* which appId we're spawning a child for, dispatches to the detach
|
|
518
|
+
* orchestrator in `src/dev/detach.ts`, and renders the outcome.
|
|
519
|
+
*
|
|
520
|
+
* The parent does not do any of the dev work itself -- it is a thin
|
|
521
|
+
* observer that exists only long enough to confirm the child has a
|
|
522
|
+
* preview URL. See `docs/plans/2026-05-06-runwork-dev-lifecycle-design.md`
|
|
523
|
+
* for the full handshake contract.
|
|
524
|
+
*/
|
|
525
|
+
async function runDevDetachParent(opts) {
|
|
526
|
+
const config = readConfig();
|
|
527
|
+
const cwd = process.cwd();
|
|
528
|
+
const ts = () => new Date().toISOString();
|
|
529
|
+
// --restart: stop the existing session before spawning a child.
|
|
530
|
+
if (opts.restart) {
|
|
531
|
+
const stopOutcome = await stopSession(cwd, config.appId);
|
|
532
|
+
if (!opts.json && stopOutcome.result === 'stopped') {
|
|
533
|
+
console.log(dim(`Stopped previous dev session (PID ${stopOutcome.pid}).`));
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
// Idempotency: if a live session is already running on this machine,
|
|
537
|
+
// don't spawn a second child -- just report it.
|
|
538
|
+
const existing = getSessionState(cwd, config.appId);
|
|
539
|
+
if (existing.state === 'alive') {
|
|
540
|
+
const f = existing.file;
|
|
541
|
+
if (opts.json) {
|
|
542
|
+
jsonLine({
|
|
543
|
+
event: 'already_running',
|
|
544
|
+
previewUrl: f.previewUrl,
|
|
545
|
+
pid: f.pid,
|
|
546
|
+
sessionId: f.sessionId,
|
|
547
|
+
mode: f.mode,
|
|
548
|
+
timestamp: ts(),
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
else {
|
|
552
|
+
console.log(`Dev session already running (PID ${f.pid}, mode: ${f.mode}).`);
|
|
553
|
+
console.log(` Preview: ${green(f.previewUrl || '(starting up)')}`);
|
|
554
|
+
console.log(dim(` Follow logs: runwork dev attach`));
|
|
555
|
+
}
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
if (existing.state === 'stale') {
|
|
559
|
+
removeSessionFile(cwd);
|
|
560
|
+
}
|
|
561
|
+
if (opts.json) {
|
|
562
|
+
jsonLine({ event: 'starting', mode: 'detached', timestamp: ts() });
|
|
563
|
+
}
|
|
564
|
+
else {
|
|
565
|
+
console.log(dim('Starting dev session in background...'));
|
|
566
|
+
}
|
|
567
|
+
// `buildChildArgs` reproduces our invocation as args for the child
|
|
568
|
+
// (which is spawned with the same `process.execPath`), with two
|
|
569
|
+
// platform-aware tweaks: it skips Bun's auto-injected virtual-FS path
|
|
570
|
+
// on Windows (which the child runtime re-injects on its own), and it
|
|
571
|
+
// ensures exactly one copy of the internal-child marker.
|
|
572
|
+
const childArgs = buildChildArgs(process.argv);
|
|
573
|
+
const outcome = await runAsDetachedParent({
|
|
574
|
+
appDir: cwd,
|
|
575
|
+
expectedAppId: config.appId,
|
|
576
|
+
childArgs,
|
|
577
|
+
});
|
|
578
|
+
switch (outcome.result) {
|
|
579
|
+
case 'started': {
|
|
580
|
+
const f = outcome.file;
|
|
581
|
+
if (opts.json) {
|
|
582
|
+
jsonLine({
|
|
583
|
+
event: 'session_started',
|
|
584
|
+
previewUrl: f.previewUrl,
|
|
585
|
+
pid: f.pid,
|
|
586
|
+
sessionId: f.sessionId,
|
|
587
|
+
mode: f.mode,
|
|
588
|
+
timestamp: ts(),
|
|
589
|
+
guide: buildDevSessionGuide(),
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
else {
|
|
593
|
+
console.log('');
|
|
594
|
+
console.log(`Dev session running in background (PID ${f.pid}).`);
|
|
595
|
+
console.log(` Preview: ${green(f.previewUrl)}`);
|
|
596
|
+
console.log(dim(` Stop with: runwork dev stop`));
|
|
597
|
+
console.log(dim(` Logs: tail -f .runwork/dev-stdout.log`));
|
|
598
|
+
console.log('');
|
|
599
|
+
}
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
case 'wrong-pid': {
|
|
603
|
+
// Race: another process won the session-file write. Treat as
|
|
604
|
+
// "already running" -- the user's intent is satisfied. Our spawned
|
|
605
|
+
// child will detect the same file at its own startup probe and
|
|
606
|
+
// exit cleanly without doing duplicate work.
|
|
607
|
+
const f = outcome.file;
|
|
608
|
+
if (opts.json) {
|
|
609
|
+
jsonLine({
|
|
610
|
+
event: 'already_running',
|
|
611
|
+
previewUrl: f.previewUrl,
|
|
612
|
+
pid: f.pid,
|
|
613
|
+
sessionId: f.sessionId,
|
|
614
|
+
mode: f.mode,
|
|
615
|
+
timestamp: ts(),
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
else {
|
|
619
|
+
console.log(`Another dev session already running (PID ${f.pid}).`);
|
|
620
|
+
console.log(` Preview: ${green(f.previewUrl)}`);
|
|
621
|
+
}
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
case 'child-exited': {
|
|
625
|
+
const message = `Detached dev session exited before becoming ready.`;
|
|
626
|
+
if (opts.json) {
|
|
627
|
+
jsonLine({
|
|
628
|
+
event: 'error',
|
|
629
|
+
phase: 'detach-child-exited',
|
|
630
|
+
timestamp: ts(),
|
|
631
|
+
error: {
|
|
632
|
+
message,
|
|
633
|
+
diagnosis: 'The detached child process exited (typically due to missing auth, missing git, network failure, or a sync conflict) before it could publish a preview URL.',
|
|
634
|
+
suggestions: [
|
|
635
|
+
'Inspect the child stderr at .runwork/dev-stderr.log',
|
|
636
|
+
'Run `runwork doctor` to verify auth and connectivity',
|
|
637
|
+
'Try `runwork dev` (foreground) to see the failure inline',
|
|
638
|
+
],
|
|
639
|
+
childLogTail: outcome.childLogTail,
|
|
640
|
+
},
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
else {
|
|
644
|
+
console.error(yellow(message));
|
|
645
|
+
console.error(dim(' Inspect .runwork/dev-stderr.log or run `runwork dev` to see what failed.'));
|
|
646
|
+
if (outcome.childLogTail) {
|
|
647
|
+
console.error(dim(' Tail of child stderr:'));
|
|
648
|
+
for (const line of outcome.childLogTail.split('\n').slice(-10)) {
|
|
649
|
+
if (line)
|
|
650
|
+
console.error(dim(` ${line}`));
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
process.exit(1);
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
case 'timeout': {
|
|
658
|
+
const message = `Detached dev session did not become ready within 90s.`;
|
|
659
|
+
if (opts.json) {
|
|
660
|
+
jsonLine({
|
|
661
|
+
event: 'error',
|
|
662
|
+
phase: 'detach',
|
|
663
|
+
timestamp: ts(),
|
|
664
|
+
error: {
|
|
665
|
+
message,
|
|
666
|
+
diagnosis: 'The detached child process was spawned but never wrote a session file with a preview URL. The sandbox boot may have failed.',
|
|
667
|
+
suggestions: [
|
|
668
|
+
'Inspect the child stderr at .runwork/dev-stderr.log',
|
|
669
|
+
'Run `runwork doctor` to verify auth and connectivity',
|
|
670
|
+
'Try `runwork dev` (foreground) to see the failure inline',
|
|
671
|
+
],
|
|
672
|
+
childLogTail: outcome.childLogTail,
|
|
673
|
+
},
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
else {
|
|
677
|
+
console.error(yellow(message));
|
|
678
|
+
console.error(dim(' Inspect .runwork/dev-stderr.log or run `runwork dev` to see what failed.'));
|
|
679
|
+
if (outcome.childLogTail) {
|
|
680
|
+
console.error(dim(' Tail of child stderr:'));
|
|
681
|
+
for (const line of outcome.childLogTail.split('\n').slice(-10)) {
|
|
682
|
+
if (line)
|
|
683
|
+
console.error(dim(` ${line}`));
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
process.exit(1);
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
case 'spawn-failed': {
|
|
691
|
+
const message = outcome.error instanceof Error ? outcome.error.message : String(outcome.error);
|
|
692
|
+
if (opts.json) {
|
|
693
|
+
jsonLine({
|
|
694
|
+
event: 'error',
|
|
695
|
+
phase: 'detach-spawn',
|
|
696
|
+
timestamp: ts(),
|
|
697
|
+
error: {
|
|
698
|
+
message: `Failed to spawn detached child: ${message}`,
|
|
699
|
+
diagnosis: 'The CLI could not fork itself into a background process.',
|
|
700
|
+
suggestions: ['Run `runwork dev` (foreground) instead'],
|
|
701
|
+
},
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
else {
|
|
705
|
+
console.error(yellow(`Failed to start detached dev session: ${message}`));
|
|
706
|
+
console.error(dim(' Run `runwork dev` (foreground) instead.'));
|
|
707
|
+
}
|
|
708
|
+
process.exit(1);
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* `runwork dev stop` -- subcommand of `dev`. Tears down a running
|
|
715
|
+
* session by reading the local session file. Idempotent.
|
|
716
|
+
*/
|
|
717
|
+
const devStopCommand = new Command('stop')
|
|
718
|
+
.description('Stop the dev session running for this app')
|
|
719
|
+
.action(async (_opts, command) => {
|
|
720
|
+
const useJson = shouldOutputJson(command.optsWithGlobals().json);
|
|
721
|
+
const config = readConfig();
|
|
722
|
+
const cwd = process.cwd();
|
|
723
|
+
const ts = () => new Date().toISOString();
|
|
724
|
+
const outcome = await stopSession(cwd, config.appId);
|
|
725
|
+
switch (outcome.result) {
|
|
726
|
+
case 'no-session':
|
|
727
|
+
if (useJson) {
|
|
728
|
+
jsonLine({ event: 'session_stopped', result: 'no-session', timestamp: ts() });
|
|
729
|
+
}
|
|
730
|
+
else {
|
|
731
|
+
console.log('No dev session running here.');
|
|
732
|
+
}
|
|
733
|
+
return;
|
|
734
|
+
case 'stale-cleaned':
|
|
735
|
+
if (useJson) {
|
|
736
|
+
jsonLine({
|
|
737
|
+
event: 'session_stopped',
|
|
738
|
+
result: 'stale-cleaned',
|
|
739
|
+
reason: outcome.reason,
|
|
740
|
+
pid: outcome.pid,
|
|
741
|
+
timestamp: ts(),
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
else {
|
|
745
|
+
console.log(`Cleaned up stale session file (reason: ${outcome.reason}).`);
|
|
746
|
+
}
|
|
747
|
+
return;
|
|
748
|
+
case 'stopped':
|
|
749
|
+
if (useJson) {
|
|
750
|
+
jsonLine({
|
|
751
|
+
event: 'session_stopped',
|
|
752
|
+
result: 'stopped',
|
|
753
|
+
pid: outcome.pid,
|
|
754
|
+
gracefully: outcome.gracefully,
|
|
755
|
+
timestamp: ts(),
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
else {
|
|
759
|
+
const how = outcome.gracefully ? 'gracefully' : 'forcefully';
|
|
760
|
+
console.log(`Stopped dev session ${how} (PID ${outcome.pid}).`);
|
|
761
|
+
}
|
|
762
|
+
return;
|
|
763
|
+
case 'kill-failed': {
|
|
764
|
+
const msg = outcome.error instanceof Error ? outcome.error.message : String(outcome.error);
|
|
765
|
+
if (useJson) {
|
|
766
|
+
jsonLine({
|
|
767
|
+
event: 'error',
|
|
768
|
+
phase: 'dev-stop',
|
|
769
|
+
timestamp: ts(),
|
|
770
|
+
error: {
|
|
771
|
+
message: `Failed to stop PID ${outcome.pid}: ${msg}`,
|
|
772
|
+
diagnosis: 'The session file was removed but the process could not be killed by this CLI invocation.',
|
|
773
|
+
suggestions: [
|
|
774
|
+
`Manually kill the process: kill ${outcome.pid}`,
|
|
775
|
+
'Verify with: ps -p ' + outcome.pid,
|
|
776
|
+
],
|
|
777
|
+
},
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
else {
|
|
781
|
+
console.error(yellow(`Could not kill PID ${outcome.pid}: ${msg}`));
|
|
782
|
+
console.error(dim(' The session file has been removed; the process may need to be killed manually.'));
|
|
783
|
+
}
|
|
784
|
+
process.exit(1);
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
});
|
|
789
|
+
devCommand.addCommand(devStopCommand);
|
|
790
|
+
/**
|
|
791
|
+
* `runwork dev attach` -- read-only join on a running dev session.
|
|
792
|
+
*
|
|
793
|
+
* The keyboard glue here is intentionally a near-copy of the foreground
|
|
794
|
+
* dev path's switch (we considered factoring it out and decided the
|
|
795
|
+
* inverted cleanup semantics would force a config-heavy abstraction --
|
|
796
|
+
* see the conversation in `docs/plans/2026-05-06-runwork-dev-lifecycle-design.md`).
|
|
797
|
+
*
|
|
798
|
+
* The hard rule: nothing this command does, EXCEPT the explicit `s`
|
|
799
|
+
* keypress, ever stops the attached session. Ctrl+C and `q` exit the
|
|
800
|
+
* attach UI but leave the dev session running.
|
|
801
|
+
*/
|
|
802
|
+
const devAttachCommand = new Command('attach')
|
|
803
|
+
.description('Attach to a running dev session for this app: tail logs, see the preview URL, control via keys')
|
|
804
|
+
.option('--initial-lines <n>', 'How many trailing log lines to replay on connect (default 50)', (v) => parseInt(v, 10), 50)
|
|
805
|
+
.action(async (options, command) => {
|
|
806
|
+
const useJson = shouldOutputJson(command.optsWithGlobals().json);
|
|
807
|
+
const config = readConfig();
|
|
808
|
+
const cwd = process.cwd();
|
|
809
|
+
const ts = () => new Date().toISOString();
|
|
810
|
+
const target = resolveAttachTarget(cwd, config.appId);
|
|
811
|
+
if (target.result === 'no-session') {
|
|
812
|
+
if (useJson) {
|
|
813
|
+
jsonLine({ event: 'attach_no_session', timestamp: ts() });
|
|
814
|
+
}
|
|
815
|
+
else {
|
|
816
|
+
console.log('No dev session running here.');
|
|
817
|
+
console.log(dim(' Start one with `runwork dev` or `runwork dev --detach`.'));
|
|
818
|
+
}
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
if (target.result === 'stale-cleaned') {
|
|
822
|
+
if (useJson) {
|
|
823
|
+
jsonLine({ event: 'attach_stale_cleaned', reason: target.reason, timestamp: ts() });
|
|
824
|
+
}
|
|
825
|
+
else {
|
|
826
|
+
console.log(`Found a stale session file (reason: ${target.reason}); cleaned up.`);
|
|
827
|
+
console.log(dim(' Nothing to attach to. Start a new session with `runwork dev`.'));
|
|
828
|
+
}
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
const file = target.file;
|
|
832
|
+
if (useJson) {
|
|
833
|
+
jsonLine({
|
|
834
|
+
event: 'attach_started',
|
|
835
|
+
pid: file.pid,
|
|
836
|
+
sessionId: file.sessionId,
|
|
837
|
+
previewUrl: file.previewUrl,
|
|
838
|
+
mode: file.mode,
|
|
839
|
+
startedAt: file.startedAt,
|
|
840
|
+
timestamp: ts(),
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
else {
|
|
844
|
+
console.log('');
|
|
845
|
+
console.log(`Attached to dev session ${dim(`(PID ${file.pid}, mode: ${file.mode}, ${formatStartedAgo(file.startedAt)})`)}`);
|
|
846
|
+
console.log(` Preview: ${green(file.previewUrl || '(starting up)')}`);
|
|
847
|
+
console.log(dim(` Logs: .runwork/dev-stdout.log, dev-stderr.log`));
|
|
848
|
+
console.log('');
|
|
849
|
+
console.log(dim(' o:open i:info s:stop q:detach (Ctrl+C also detaches; session keeps running)'));
|
|
850
|
+
console.log('');
|
|
851
|
+
}
|
|
852
|
+
const colors = { dim, green, yellow, red, cyan };
|
|
853
|
+
const paths = getAttachLogPaths(cwd);
|
|
854
|
+
let stopping = false;
|
|
855
|
+
// Status line: only in human + TTY mode. The same caveats apply as
|
|
856
|
+
// foreground dev: createStatusLine returns a no-op when stdout
|
|
857
|
+
// is not a TTY.
|
|
858
|
+
const statusLine = useJson ? null : createStatusLine(process.stdout);
|
|
859
|
+
let displayedUrl = file.previewUrl;
|
|
860
|
+
function updateStatus() {
|
|
861
|
+
if (!statusLine)
|
|
862
|
+
return;
|
|
863
|
+
const sep = '\x1b[90m│\x1b[39m';
|
|
864
|
+
statusLine.update(`${bold(config.appName)} ${sep} ${green(displayedUrl || '(starting up)')} ${sep} ${cyan('attached')} ${sep} ${dim(`PID ${file.pid}`)} ${sep} \x1b[90mo\x1b[39m:open \x1b[90mi\x1b[39m:info \x1b[90ms\x1b[39m:stop \x1b[90mq\x1b[39m:detach`);
|
|
865
|
+
}
|
|
866
|
+
if (statusLine)
|
|
867
|
+
updateStatus();
|
|
868
|
+
const tail = startLogTail(paths.stdout, paths.stderr, {
|
|
869
|
+
initialLines: options.initialLines ?? 50,
|
|
870
|
+
onLine: (line, source) => {
|
|
871
|
+
if (useJson) {
|
|
872
|
+
// Pass-through: agents consuming attach in JSON mode see the
|
|
873
|
+
// raw NDJSON log lines plus a wrapper noting the source.
|
|
874
|
+
jsonLine({ event: 'log', source, line, timestamp: ts() });
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
const rendered = renderLogLine(line, source, colors);
|
|
878
|
+
console.log(rendered.text);
|
|
879
|
+
},
|
|
880
|
+
onTruncated: () => {
|
|
881
|
+
// The detached child reopens log files in 'w' mode at session
|
|
882
|
+
// start, but a restart of the SAME PID shouldn't happen --
|
|
883
|
+
// restart kills the old PID, spawns a new one. So a truncation
|
|
884
|
+
// we observe is most likely "another process restarted the
|
|
885
|
+
// session." We surface it and let the session-file watch decide
|
|
886
|
+
// whether to fully exit (which it will when it sees the PID
|
|
887
|
+
// change).
|
|
888
|
+
if (!useJson)
|
|
889
|
+
console.log(yellow(' (log file was truncated -- session may have restarted)'));
|
|
890
|
+
},
|
|
891
|
+
});
|
|
892
|
+
const sessionWatch = startSessionFileWatch(cwd, file, {
|
|
893
|
+
expectedAppId: config.appId,
|
|
894
|
+
onUrlChanged: (next, prev) => {
|
|
895
|
+
displayedUrl = next;
|
|
896
|
+
if (useJson) {
|
|
897
|
+
jsonLine({ event: 'preview_url_changed', previewUrl: next, previousUrl: prev, timestamp: ts() });
|
|
898
|
+
}
|
|
899
|
+
else {
|
|
900
|
+
console.log(dim(` Preview URL changed: ${next}`));
|
|
901
|
+
updateStatus();
|
|
902
|
+
}
|
|
903
|
+
},
|
|
904
|
+
onSessionGone: () => {
|
|
905
|
+
if (stopping)
|
|
906
|
+
return;
|
|
907
|
+
if (useJson) {
|
|
908
|
+
jsonLine({ event: 'attach_session_ended', pid: file.pid, timestamp: ts() });
|
|
909
|
+
}
|
|
910
|
+
else {
|
|
911
|
+
console.log('');
|
|
912
|
+
console.log(yellow('Session ended. Detaching.'));
|
|
913
|
+
}
|
|
914
|
+
cleanupAttach('session-ended');
|
|
915
|
+
},
|
|
916
|
+
});
|
|
917
|
+
const keyboard = useJson ? null : createKeyboardListener();
|
|
918
|
+
function cleanupAttach(reason) {
|
|
919
|
+
if (stopping)
|
|
920
|
+
return;
|
|
921
|
+
stopping = true;
|
|
922
|
+
tail.stop();
|
|
923
|
+
sessionWatch.stop();
|
|
924
|
+
statusLine?.destroy();
|
|
925
|
+
keyboard?.stop();
|
|
926
|
+
// We never call removeSessionFile here -- attach does not own the
|
|
927
|
+
// session. The only path that ends a session is `s` -> stopSession,
|
|
928
|
+
// which removes the file itself.
|
|
929
|
+
void reason;
|
|
930
|
+
process.exit(0);
|
|
931
|
+
}
|
|
932
|
+
process.on('SIGINT', () => cleanupAttach('user'));
|
|
933
|
+
process.on('SIGTERM', () => cleanupAttach('user'));
|
|
934
|
+
if (keyboard) {
|
|
935
|
+
keyboard.start(async (action) => {
|
|
936
|
+
switch (action) {
|
|
937
|
+
case 'o':
|
|
938
|
+
case 'p': {
|
|
939
|
+
const url = getCurrentPreviewUrl(cwd, displayedUrl);
|
|
940
|
+
if (url)
|
|
941
|
+
import('open').then((m) => m.default(url));
|
|
942
|
+
break;
|
|
943
|
+
}
|
|
944
|
+
case 'i':
|
|
945
|
+
console.log(getInfoPanel({
|
|
946
|
+
appName: config.appName,
|
|
947
|
+
previewUrl: getCurrentPreviewUrl(cwd, displayedUrl),
|
|
948
|
+
workspaceName: config.workspaceName,
|
|
949
|
+
directory: cwd,
|
|
950
|
+
}));
|
|
951
|
+
break;
|
|
952
|
+
case 's': {
|
|
953
|
+
console.log(dim('Stopping dev session...'));
|
|
954
|
+
const outcome = await stopSession(cwd, config.appId);
|
|
955
|
+
if (outcome.result === 'stopped') {
|
|
956
|
+
console.log(`Stopped dev session (PID ${outcome.pid}).`);
|
|
957
|
+
}
|
|
958
|
+
else if (outcome.result === 'kill-failed') {
|
|
959
|
+
console.error(yellow(`Could not kill PID ${outcome.pid}.`));
|
|
960
|
+
}
|
|
961
|
+
cleanupAttach('stop');
|
|
962
|
+
break;
|
|
963
|
+
}
|
|
964
|
+
case 'quit':
|
|
965
|
+
cleanupAttach('user');
|
|
966
|
+
break;
|
|
967
|
+
// 'a' / 'e' / 'r' are no-ops in attach -- log filtering is a
|
|
968
|
+
// foreground-dev-only feature. The keys are still parsed
|
|
969
|
+
// centrally so they don't surface as null actions.
|
|
970
|
+
case 'a':
|
|
971
|
+
case 'e':
|
|
972
|
+
case 'r':
|
|
973
|
+
case null:
|
|
974
|
+
break;
|
|
975
|
+
}
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
// Block until cleanup runs.
|
|
979
|
+
await new Promise(() => { });
|
|
364
980
|
});
|
|
981
|
+
devCommand.addCommand(devAttachCommand);
|