Skip to content

Refactor site watchers into unified _watch CLI command - #2380

Merged
bcotrim merged 17 commits into
dev/studio-cli-i2from
stu-1193-refactor-site-watchers
Jan 15, 2026
Merged

Refactor site watchers into unified _watch CLI command#2380
bcotrim merged 17 commits into
dev/studio-cli-i2from
stu-1193-refactor-site-watchers

Conversation

@bcotrim

@bcotrim bcotrim commented Jan 12, 2026

Copy link
Copy Markdown
Contributor

Related issues

Proposed Changes

This PR refactors site watching into a unified architecture using a hidden _events CLI command that streams site events to Studio:

  • New hidden CLI command (studio _events): Streams site lifecycle events (CREATED, UPDATED, DELETED) to Studio via IPC messages
  • New events relay process (cli/events-relay.ts): PM2-managed process that watches appdata file changes and emits events
  • New shared event types (common/lib/site-events.ts): Shared schema for site events between CLI and app
  • New CLI events subscriber (cli-events-subscriber.ts): Subscribes to _events command output and updates SiteServer instances

Architecture:

┌─────────────────────────────────────────────────────┐
│  _events command                                    │
│  • Subscribes to PM2 process events                 │
│  • Subscribes to events-relay for appdata changes   │
└───────────────┬─────────────────────────────────────┘
                │ IPC messages (site-event)
┌───────────────▼─────────────────────────────────────┐
│  App (cli-events-subscriber.ts)                     │
│  • Handles CREATED: register new sites              │
│  • Handles UPDATED: update existing sites only      │
│  • Handles DELETED: unregister sites                │
│  • Sends IPC event to renderer                      │
└───────────────┬─────────────────────────────────────┘
                │ IPC (site-event)
┌───────────────▼─────────────────────────────────────┐
│  Renderer (use-site-details.tsx)                    │
│  • CREATED: add site to state                       │
│  • UPDATED: update existing site in state           │
│  • DELETED: remove site from state                  │
└─────────────────────────────────────────────────────┘

Key improvements:

  • Single event stream replaces dual watchers (user-data-watcher + site-watch-command)
  • Debouncing prevents duplicate events when multiple sources fire
  • Event-specific handling prevents race conditions during site creation
  • Clean signal handling (SIGINT/SIGTERM) for proper E2E test cleanup

Testing Instructions

Site Status Changes via CLI

  1. Start Studio app
  2. In a separate terminal, run studio site start <site-name> - verify UI updates
  3. Run studio site stop <site-name> - verify UI updates

Site Creation via CLI

  1. With app open, run studio site create --name "CLI Test Site" --path ~/Sites/cli-test
  2. Verify the new site appears in the app UI

Site Editing via CLI

  1. Run studio site set --name "CLI Test Site" --php-version 8.2
  2. Verify the change reflects in the app UI

Site Deletion via CLI

  1. Run studio site delete --name "CLI Test Site"
  2. Verify the site is removed from the app UI

App Operations

  1. Start/stop sites via the app UI - verify works as before
  2. Create/edit/delete sites via the app UI - verify works as before

Edge Cases

  1. Rapid successive changes are handled correctly with debouncing
  2. Creating a site in Studio and CLI at the same time does not cause duplicates
  3. Quitting Studio exits cleanly without stuck processes

Pre-merge Checklist

  • Have you checked for TypeScript, React or other console errors?
@bcotrim bcotrim self-assigned this Jan 12, 2026
@bcotrim
bcotrim force-pushed the stu-1193-refactor-site-watchers branch from 38b8247 to 34c722c Compare January 13, 2026 22:37
@bcotrim
bcotrim force-pushed the stu-1193-refactor-site-watchers branch from 36fab92 to 2f32c7b Compare January 14, 2026 19:40
@bcotrim
bcotrim marked this pull request as ready for review January 14, 2026 19:41

@fredrikekelund fredrikekelund left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Submitting my review in pieces… First batch comes here.

I spent some time experimenting with a different pattern than the events-relay pm2 process. The current pattern works, but I agree with you that it's not ideal. Using a socket as an event emitter between processes would be much nicer, but I couldn't get that to work using pm2's built-in socket…

Comment thread common/lib/site-events.ts Outdated
Comment on lines +29 to +36
export const SITE_EVENTS = {
CREATED: 'site-created',
UPDATED: 'site-updated',
DELETED: 'site-deleted',
} as const;

export const siteEventSchema = z.object( {
event: z.string(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
export const SITE_EVENTS = {
CREATED: 'site-created',
UPDATED: 'site-updated',
DELETED: 'site-deleted',
} as const;
export const siteEventSchema = z.object( {
event: z.string(),
export enum SITE_EVENTS {
CREATED = 'site-created',
UPDATED = 'site-updated',
DELETED = 'site-deleted',
}
export const siteEventSchema = z.object( {
event: z.nativeEnum( SITE_EVENTS ),

We'd benefit from stricter types here, and this is a pretty straightforward way to achieve it.

Comment thread cli/lib/pm2-manager.ts Outdated
* @param data - The event data (must include siteId)
*/
export async function emitSiteEvent(
event: string,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
event: string,
event: SITE_EVENTS,

Let's make the type more strict.

Comment thread cli/commands/_events.ts Outdated
if ( processName !== relayProcessName ) {
return;
}
if ( LIFECYCLE_EVENTS.includes( topic ) ) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if ( LIFECYCLE_EVENTS.includes( topic ) ) {
if (
topic === SITE_EVENTS.UPDATED ||
topic === SITE_EVENTS.DELETED ||
topic === SITE_EVENTS.CREATED
) {

An example of how we might handle stricter types after converting SITE_EVENTS to an enum

Comment thread cli/lib/pm2-manager.ts
Comment on lines +368 to +391
export async function emitSiteEvent(
event: string,
data: { siteId: string; url?: string }
): Promise< void > {
const relayProcess = await getProcessByName( EVENTS_RELAY_PROCESS_NAME );
if ( ! relayProcess?.pm_id ) {
// No relay running - that's fine, Studio might not be open
return;
}

return new Promise( ( resolve ) => {
pm2.sendDataToProcessId(
relayProcess.pm_id as number,
{
type: 'process:msg',
topic: event,
data,
},
() => {
resolve();
}
);
} );
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand the reason for the current strategy of using a pm2-managed events-relay process to be that we need a pm2 process ID to be able to use the pm2.sendDataToProcessId API.

I spent some time trying a different strategy: interacting directly with the pm2 "bus". The bus is an instance of sub-socket that comes from the pm2-axon module. Unfortunately, that module is old and seemingly unmaintained… Despite multiple attempts, I couldn't manage to get a simple pub/sub pattern (listening on a Unix socket) to work.

If it had worked, I would argue it's a better pattern, but this relay-based solution is an OK option within our current constraints, IMO.

Comment thread cli/lib/pm2-manager.ts Outdated
Comment on lines +372 to +380
const relayProcess = await getProcessByName( EVENTS_RELAY_PROCESS_NAME );
if ( ! relayProcess?.pm_id ) {
// No relay running - that's fine, Studio might not be open
return;
}

return new Promise( ( resolve ) => {
pm2.sendDataToProcessId(
relayProcess.pm_id as number,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const relayProcess = await getProcessByName( EVENTS_RELAY_PROCESS_NAME );
if ( ! relayProcess?.pm_id ) {
// No relay running - that's fine, Studio might not be open
return;
}
return new Promise( ( resolve ) => {
pm2.sendDataToProcessId(
relayProcess.pm_id as number,
const relayProcess = await isProcessRunning( EVENTS_RELAY_PROCESS_NAME );
if ( ! relayProcess?.pmId ) {
// No relay running - that's fine, Studio might not be open
return;
}
return new Promise( ( resolve ) => {
pm2.sendDataToProcessId(
relayProcess.pmId,

isProcessRunning is really a misnomer… It already does the same thing as getProcessByName

Comment thread cli/lib/pm2-manager.ts Outdated
Comment on lines +348 to +360
async function getProcessByName( name: string ): Promise< { pm_id: number | undefined } | null > {
return new Promise( ( resolve, reject ) => {
pm2.list( ( error, processes ) => {
if ( error ) {
reject( error );
return;
}
const proc = ( processes || [] ).find( ( p ) => p.name === name );
resolve( proc ? { pm_id: proc.pm_id } : null );
} );
} );
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
async function getProcessByName( name: string ): Promise< { pm_id: number | undefined } | null > {
return new Promise( ( resolve, reject ) => {
pm2.list( ( error, processes ) => {
if ( error ) {
reject( error );
return;
}
const proc = ( processes || [] ).find( ( p ) => p.name === name );
resolve( proc ? { pm_id: proc.pm_id } : null );
} );
} );
}

isProcessRunning already does the same thing

Comment thread cli/lib/pm2-manager.ts
Comment on lines +397 to +401
export async function startEventsRelayProcess( relayScriptPath: string ): Promise< void > {
const existingProcess = await getProcessByName( EVENTS_RELAY_PROCESS_NAME );
if ( existingProcess?.pm_id !== undefined ) {
return;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
export async function startEventsRelayProcess( relayScriptPath: string ): Promise< void > {
const existingProcess = await getProcessByName( EVENTS_RELAY_PROCESS_NAME );
if ( existingProcess?.pm_id !== undefined ) {
return;
}
export async function startEventsRelayProcess( relayScriptPath: string ): Promise< void > {
const existingProcess = await isProcessRunning( EVENTS_RELAY_PROCESS_NAME );
if ( existingProcess?.pmId !== undefined ) {
return;
}

@fredrikekelund fredrikekelund left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm nearly done with the code review

Comment thread cli/commands/_events.ts Outdated
Comment on lines +149 to +166
export const registerCommand = ( yargs: StudioArgv ) => {
return yargs.command( {
command: '_events',
describe: false, // Hidden command
handler: async () => {
try {
await runCommand();
} catch ( error ) {
if ( error instanceof LoggerError ) {
logger.reportError( error );
} else {
const loggerError = new LoggerError( __( 'Events watcher failed' ), error );
logger.reportError( loggerError );
}
}
},
} );
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
export const registerCommand = ( yargs: StudioArgv ) => {
return yargs.command( {
command: '_events',
describe: false, // Hidden command
handler: async () => {
try {
await runCommand();
} catch ( error ) {
if ( error instanceof LoggerError ) {
logger.reportError( error );
} else {
const loggerError = new LoggerError( __( 'Events watcher failed' ), error );
logger.reportError( loggerError );
}
}
},
} );
};
export async function commandHandler() {
try {
await runCommand();
} catch ( error ) {
if ( error instanceof LoggerError ) {
logger.reportError( error );
} else {
const loggerError = new LoggerError( __( 'Events watcher failed' ), error );
logger.reportError( loggerError );
}
}
}

This is just a recommendation, but cli/commands/wp.ts has already established this pattern for running top-level commands.

It mostly just comes down to cli/index.ts being easier to ready with this approach, since it allows us to keep chaining .command() calls, and all top-level command names are visible in that file.

On the other hand, the registerCommand pattern is also well established, so I could be swayed in that direction, too…

Comment thread cli/index.ts
Comment on lines 110 to 115
.demandCommand( 1, __( 'You must provide a valid command' ) )
.strict();

registerEventsCommand( studioArgv );

await studioArgv.argv;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
.demandCommand( 1, __( 'You must provide a valid command' ) )
.strict();
registerEventsCommand( studioArgv );
await studioArgv.argv;
.command( {
command: '_events',
describe: false, // Hidden command
handler: _eventsCommandHandler,
} )
.demandCommand( 1, __( 'You must provide a valid command' ) )
.strict();
await studioArgv.argv;

Per my recommendation of exporting a commandHandler function from cli/commands/_events instead of a registerCommand callback.

Comment thread cli/lib/wordpress-server-manager.ts Outdated
@@ -450,7 +451,8 @@ export async function subscribeSiteEvents(
let debounceTimeout: NodeJS.Timeout | null = null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like we aren't using the debounceMs option anywhere. Can we remove the debounce logic and simplify invokeHandler?

Comment thread src/tests/index.test.ts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like we can undo this change. The test still passes with the old code when I try it locally.

Comment on lines +30 to +35
async function handleSiteEvent( event: SiteEvent ): Promise< void > {
const { event: eventType, siteId, site, running } = event;
const previous = pendingUpdates.get( siteId ) ?? Promise.resolve();
const current = previous
.catch( () => {} )
.then( () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
async function handleSiteEvent( event: SiteEvent ): Promise< void > {
const { event: eventType, siteId, site, running } = event;
const previous = pendingUpdates.get( siteId ) ?? Promise.resolve();
const current = previous
.catch( () => {} )
.then( () => {
const handleSiteEvent = sequential( async ( event: SiteEvent ): Promise< void > => {
const { event: eventType, siteId, site, running } = event;

The common/lib/sequential.ts helper could be used here. I know the current logic sets up separate queues for each site, but since the event handler isn't actually executing any async logic, we still achieve the same effect with the non-partitioned sequential queue.

Comment on lines +117 to +119
if ( childProcess.connected ) {
childProcess.disconnect();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if ( childProcess.connected ) {
childProcess.disconnect();
}

We don't need this.

Comment thread src/index.ts Outdated
globalShortcut.unregisterAll();
stopUserDataWatcher();
stopSiteWatcher();
void stopCliEventsSubscriber();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
void stopCliEventsSubscriber();
stopCliEventsSubscriber();

It's not an async function

Comment thread cli/commands/_events.ts Outdated
Comment on lines +58 to +84
function emitSiteEvent( event: string, siteId: string ): void {
const existing = pendingEmits.get( siteId );

if ( event === SITE_EVENTS.DELETED ) {
if ( existing ) {
clearTimeout( existing.timeout );
pendingEmits.delete( siteId );
}
void doEmitSiteEvent( event, siteId );
return;
}

let effectiveEvent = event;
if ( existing ) {
clearTimeout( existing.timeout );
if ( existing.event === SITE_EVENTS.CREATED ) {
effectiveEvent = SITE_EVENTS.CREATED;
}
}

const timeout = setTimeout( () => {
pendingEmits.delete( siteId );
void doEmitSiteEvent( effectiveEvent, siteId );
}, DEBOUNCE_MS );

pendingEmits.set( siteId, { event: effectiveEvent, timeout } );
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really need to be this clever..? Can't we just use the common/lib/sequential.ts helper to ensure that events are sent in the order they're received?

@fredrikekelund fredrikekelund left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work on this 👍 The emitSiteEvent API is easily understandable, and it'll be easy to extend in the future if we add more events.

I still want to land #2398 so that the communication doesn't break is users run site stop --all outside Studio, but we agreed to land this PR first, to unblock #2281

@bcotrim
bcotrim merged commit be3b368 into dev/studio-cli-i2 Jan 15, 2026
4 checks passed
@bcotrim
bcotrim deleted the stu-1193-refactor-site-watchers branch January 15, 2026 16:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

2 participants