From 10c2b7e5b02b8a5df7111ffbeff600e9e0c2725b Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 28 Oct 2026 09:54:40 -0400 Subject: [PATCH 1/4] Studio: Finish CLI commands when they report done, not when telemetry lets the process exit --- apps/cli/index.ts | 6 +++ packages/common/lib/cli-process.ts | 38 +++++++++++++------ packages/common/lib/tests/cli-process.test.ts | 15 ++++++++ packages/common/sites/lifecycle.test.ts | 10 +++++ packages/common/sites/lifecycle.ts | 7 +++- packages/common/sites/snapshots.ts | 9 ++++- 6 files changed, 72 insertions(+), 13 deletions(-) diff --git a/apps/cli/index.ts b/apps/cli/index.ts index ad93028579..2d7a49351f 100644 --- a/apps/cli/index.ts +++ b/apps/cli/index.ts @@ -331,6 +331,12 @@ async function main() { .strict(); await studioArgv.argv; + + // Tell the host that started this command it has finished, so it needn't wait for the + // process to exit after best-effort telemetry requests complete. + if ( process.connected && ! process.exitCode ) { + process.send?.( { action: 'done' } ); + } } void main(); diff --git a/packages/common/lib/cli-process.ts b/packages/common/lib/cli-process.ts index 267fdfd5b0..b1c9069ccb 100644 --- a/packages/common/lib/cli-process.ts +++ b/packages/common/lib/cli-process.ts @@ -84,6 +84,16 @@ const cliErrorMessageSchema = z.object( { message: z.string(), } ); +/** + * Sent by the CLI once a command has finished successfully. Its process can stay alive a while + * longer to finish sending best-effort telemetry, which its host doesn't need to wait for. + */ +const cliDoneMessageSchema = z.object( { action: z.literal( 'done' ) } ); + +export function isCliCommandDone( message: unknown ): boolean { + return cliDoneMessageSchema.safeParse( message ).success; +} + type CliCommandEventEmitter< CapturesOutput extends boolean > = TypedEventEmitter< CliCommandEventMap< CapturesOutput > >; @@ -327,7 +337,8 @@ const cliProgressMessageSchema = z.object( { /** * Runs a CLI command and resolves with the result it reports (`logger.reportResult`), parsed by - * `schema`. Rejects with the command's error, or when it succeeds without reporting a result. + * `schema`, once the command has finished. Rejects with the command's error, or when it succeeds + * without reporting a result. */ export function runCliCommand< T >( execute: ExecuteCliCommand, @@ -338,27 +349,32 @@ export function runCliCommand< T >( return new Promise( ( resolve, reject ) => { let result: z.ZodSafeParseResult< T > | undefined; const [ emitter ] = execute( args, { output: 'capture', logPrefix: options.logPrefix } ); + const settle = () => { + if ( result?.success ) { + resolve( result.data ); + } else { + reject( + result?.error ?? + new Error( `\`studio ${ args.slice( 0, 2 ).join( ' ' ) }\` reported no result.` ) + ); + } + }; emitter.on( 'data', ( { data } ) => { const message = cliResultMessageSchema.safeParse( data ); if ( message.success ) { result = schema.safeParse( message.data.value ); return; } + if ( isCliCommandDone( data ) ) { + settle(); + return; + } const progress = cliProgressMessageSchema.safeParse( data ); if ( progress.success ) { options.onProgress?.( progress.data.message ); } } ); - emitter.on( 'success', () => { - if ( result?.success ) { - resolve( result.data ); - } else { - reject( - result?.error ?? - new Error( `\`studio ${ args.slice( 0, 2 ).join( ' ' ) }\` reported no result.` ) - ); - } - } ); + emitter.on( 'success', settle ); emitter.on( 'failure', ( { error } ) => reject( error ) ); emitter.on( 'error', ( { error } ) => reject( error ) ); } ); diff --git a/packages/common/lib/tests/cli-process.test.ts b/packages/common/lib/tests/cli-process.test.ts index 7b81e7724a..fd0e044521 100644 --- a/packages/common/lib/tests/cli-process.test.ts +++ b/packages/common/lib/tests/cli-process.test.ts @@ -115,6 +115,21 @@ describe( 'runCliCommand', () => { ); } ); + it( 'resolves once the command says it finished, while its process is still running', async () => { + const emitter = new EventEmitter(); + const execute = vi.fn( () => { + queueMicrotask( () => { + emitter.emit( 'data', { data: { action: 'result', value: { url: 'https://a.example' } } } ); + emitter.emit( 'data', { data: { action: 'done' } } ); + } ); + return [ emitter, {} ]; + } ) as unknown as ExecuteCliCommand; + + await expect( runCliCommand( execute, [ 'site', 'create' ], schema ) ).resolves.toEqual( { + url: 'https://a.example', + } ); + } ); + it( 'rejects with the command error when it fails', async () => { const emitter = new EventEmitter(); const execute = vi.fn( () => { diff --git a/packages/common/sites/lifecycle.test.ts b/packages/common/sites/lifecycle.test.ts index 2021330dc7..7c6863cd79 100644 --- a/packages/common/sites/lifecycle.test.ts +++ b/packages/common/sites/lifecycle.test.ts @@ -57,6 +57,16 @@ describe( 'startSite', () => { rmSync( sitePath, { recursive: true, force: true } ); } ); + it( 'resolves once the command says it finished, while its process is still running', async () => { + const emitter = new EventEmitter(); + const execute = ( () => { + setImmediate( () => emitter.emit( 'data', { data: { action: 'done' } } ) ); + return [ emitter, {} ]; + } ) as unknown as ExecuteCliCommand; + + await expect( startSite( execute, '/site' ) ).resolves.toBeUndefined(); + } ); + it( "reports the command's own error otherwise", async () => { await expect( startSite( failingWith( 'Port in use' ), '/site' ) ).rejects.toThrow( /^Port in use$/ diff --git a/packages/common/sites/lifecycle.ts b/packages/common/sites/lifecycle.ts index ab6ba42f84..6727ad8af4 100644 --- a/packages/common/sites/lifecycle.ts +++ b/packages/common/sites/lifecycle.ts @@ -1,7 +1,7 @@ import { __ } from '@wordpress/i18n'; +import { isCliCommandDone, type ExecuteCliCommand } from '@studio/common/lib/cli-process'; import { getProxyPortConflictMessage } from '@studio/common/lib/domains'; import { checkMaintenanceFile } from '@studio/common/lib/maintenance-file'; -import type { ExecuteCliCommand } from '@studio/common/lib/cli-process'; // Start / stop / delete a local site via the Studio CLI's one-shot `site` // commands, for the desktop main process and the `studio ui` server alike. @@ -13,6 +13,11 @@ function runSiteCommand( ): Promise< void > { return new Promise( ( resolve, reject ) => { const [ emitter ] = execute( args, { output: 'capture' } ); + emitter.on( 'data', ( { data } ) => { + if ( isCliCommandDone( data ) ) { + resolve(); + } + } ); emitter.on( 'success', () => resolve() ); emitter.on( 'failure', ( { error } ) => { error.baseMessage = baseMessage; diff --git a/packages/common/sites/snapshots.ts b/packages/common/sites/snapshots.ts index 8f75904393..295fd50637 100644 --- a/packages/common/sites/snapshots.ts +++ b/packages/common/sites/snapshots.ts @@ -1,6 +1,10 @@ import crypto from 'node:crypto'; import { z } from 'zod'; -import { runCliCommand, type ExecuteCliCommand } from '@studio/common/lib/cli-process'; +import { + isCliCommandDone, + runCliCommand, + type ExecuteCliCommand, +} from '@studio/common/lib/cli-process'; import { PreviewCommandLoggerAction } from '@studio/common/logger-actions'; import { snapshotSchema, type Snapshot } from '@studio/common/types/snapshot'; @@ -60,6 +64,9 @@ export function createSnapshotManager( ctx: SnapshotCommandContext ): SnapshotMa const [ emitter ] = ctx.executeCliCommand( args, { output: 'capture', logPrefix: 'preview' } ); emitter.on( 'data', ( { data } ) => { + if ( isCliCommandDone( data ) ) { + return; + } const parsed = snapshotEventSchema.safeParse( data ); if ( ! parsed.success ) { console.error( 'Invalid snapshot event:', parsed.error ); From e5466d2f27294f9dd6b2c950ad1c1b4826d91027 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 28 Oct 2026 10:15:33 -0400 Subject: [PATCH 2/4] Studio: Keep site start, stop and delete finishing on exit --- packages/common/sites/lifecycle.test.ts | 10 ---------- packages/common/sites/lifecycle.ts | 7 +------ 2 files changed, 1 insertion(+), 16 deletions(-) diff --git a/packages/common/sites/lifecycle.test.ts b/packages/common/sites/lifecycle.test.ts index 7c6863cd79..2021330dc7 100644 --- a/packages/common/sites/lifecycle.test.ts +++ b/packages/common/sites/lifecycle.test.ts @@ -57,16 +57,6 @@ describe( 'startSite', () => { rmSync( sitePath, { recursive: true, force: true } ); } ); - it( 'resolves once the command says it finished, while its process is still running', async () => { - const emitter = new EventEmitter(); - const execute = ( () => { - setImmediate( () => emitter.emit( 'data', { data: { action: 'done' } } ) ); - return [ emitter, {} ]; - } ) as unknown as ExecuteCliCommand; - - await expect( startSite( execute, '/site' ) ).resolves.toBeUndefined(); - } ); - it( "reports the command's own error otherwise", async () => { await expect( startSite( failingWith( 'Port in use' ), '/site' ) ).rejects.toThrow( /^Port in use$/ diff --git a/packages/common/sites/lifecycle.ts b/packages/common/sites/lifecycle.ts index 6727ad8af4..ab6ba42f84 100644 --- a/packages/common/sites/lifecycle.ts +++ b/packages/common/sites/lifecycle.ts @@ -1,7 +1,7 @@ import { __ } from '@wordpress/i18n'; -import { isCliCommandDone, type ExecuteCliCommand } from '@studio/common/lib/cli-process'; import { getProxyPortConflictMessage } from '@studio/common/lib/domains'; import { checkMaintenanceFile } from '@studio/common/lib/maintenance-file'; +import type { ExecuteCliCommand } from '@studio/common/lib/cli-process'; // Start / stop / delete a local site via the Studio CLI's one-shot `site` // commands, for the desktop main process and the `studio ui` server alike. @@ -13,11 +13,6 @@ function runSiteCommand( ): Promise< void > { return new Promise( ( resolve, reject ) => { const [ emitter ] = execute( args, { output: 'capture' } ); - emitter.on( 'data', ( { data } ) => { - if ( isCliCommandDone( data ) ) { - resolve(); - } - } ); emitter.on( 'success', () => resolve() ); emitter.on( 'failure', ( { error } ) => { error.baseMessage = baseMessage; From 094785610c7735b1f93479dfb804e8652bf48291 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 28 Oct 2026 10:51:24 -0400 Subject: [PATCH 3/4] Studio: Show the site a start or stop reports, instead of waiting for its process to exit --- apps/cli/commands/site/start.ts | 6 +++ apps/cli/commands/site/stop.ts | 6 +++ apps/local/src/index.ts | 6 +-- apps/studio/src/ipc-handlers.ts | 21 ++++++---- apps/studio/src/lib/php-error-recovery.ts | 2 +- apps/ui/src/data/core/connectors/ipc/index.ts | 4 +- .../src/data/core/connectors/local/index.ts | 8 ++-- apps/ui/src/data/core/types.ts | 8 ++-- apps/ui/src/data/queries/use-sites.test.tsx | 18 +++------ apps/ui/src/data/queries/use-sites.ts | 31 ++++++++------- packages/common/sites/lifecycle.ts | 39 ++++++++++++++++--- 11 files changed, 97 insertions(+), 52 deletions(-) diff --git a/apps/cli/commands/site/start.ts b/apps/cli/commands/site/start.ts index 7844dd95fb..c4e282d5b7 100644 --- a/apps/cli/commands/site/start.ts +++ b/apps/cli/commands/site/start.ts @@ -5,6 +5,7 @@ import { __, sprintf } from '@wordpress/i18n'; import { getSiteByFolder, updateSiteLatestCliPid } from 'cli/lib/cli-config/sites'; import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client'; import { getAiInstructionsPath } from 'cli/lib/dependency-management/paths'; +import { getSiteDetails } from 'cli/lib/site-details'; import { withSiteOperation } from 'cli/lib/site-operations'; import { logSiteDetails, openSiteInBrowser, setupCustomDomain } from 'cli/lib/site-utils'; import { keepSqliteIntegrationUpdated } from 'cli/lib/sqlite-integration'; @@ -126,6 +127,11 @@ async function startSite( export async function commandHandler( argv: CommandArgs< typeof builder > ) { try { await runCommand( argv.path, argv.skipBrowser, argv.skipLogDetails ); + // Read after the operation is released, so Studio can show the site as it now is. + defaultLogger.reportResult( { + ...getSiteDetails( await getSiteByFolder( argv.path ) ), + running: true, + } ); } catch ( error ) { if ( error instanceof LoggerError ) { defaultLogger.reportError( error ); diff --git a/apps/cli/commands/site/stop.ts b/apps/cli/commands/site/stop.ts index 5b6d31b674..e6570d344b 100644 --- a/apps/cli/commands/site/stop.ts +++ b/apps/cli/commands/site/stop.ts @@ -13,6 +13,7 @@ import { disconnectFromDaemon, killDaemonAndChildren, } from 'cli/lib/daemon-client'; +import { getSiteDetails } from 'cli/lib/site-details'; import { withSiteOperation } from 'cli/lib/site-operations'; import { stopProxyIfNoSitesNeedIt } from 'cli/lib/site-utils'; import { getTracksOrigin, recordTracksEvent, TRACKS_EVENTS } from 'cli/lib/tracks'; @@ -158,6 +159,11 @@ export async function commandHandler( argv: CommandArgs< typeof builder > ) { await runCommand( Mode.STOP_ALL_SITES, undefined ); } else { await runCommand( Mode.STOP_SINGLE_SITE, argv.path ); + // Read after the operation is released, so Studio can show the site as it now is. + defaultLogger.reportResult( { + ...getSiteDetails( await getSiteByFolder( argv.path ) ), + running: false, + } ); } } catch ( error ) { if ( error instanceof LoggerError ) { diff --git a/apps/local/src/index.ts b/apps/local/src/index.ts index 671bef487d..032c940cf2 100644 --- a/apps/local/src/index.ts +++ b/apps/local/src/index.ts @@ -830,8 +830,7 @@ export async function startLocalServer( options: LocalServerOptions ): Promise< res.status( 404 ).json( { error: `Site ${ req.params.id } not found` } ); return; } - await startSite( execute, site.path ); - res.sendStatus( 204 ); + res.json( await startSite( execute, site.path ) ); } ) ); @@ -844,8 +843,7 @@ export async function startLocalServer( options: LocalServerOptions ): Promise< res.status( 404 ).json( { error: `Site ${ req.params.id } not found` } ); return; } - await stopSite( execute, site.path ); - res.sendStatus( 204 ); + res.json( await stopSite( execute, site.path ) ); } ) ); diff --git a/apps/studio/src/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts index 43ec934fa8..b547e52cec 100644 --- a/apps/studio/src/ipc-handlers.ts +++ b/apps/studio/src/ipc-handlers.ts @@ -51,7 +51,7 @@ import { downloadAndExtractBlueprintBundle, removeBlueprintTempDir, } from '@studio/common/lib/blueprint-bundle'; -import { SITE_EVENTS } from '@studio/common/lib/cli-events'; +import { SITE_EVENTS, type SiteListItem } from '@studio/common/lib/cli-events'; import { CliCommandError } from '@studio/common/lib/cli-process'; import { stripIpcErrorPrefix } from '@studio/common/lib/error-formatting'; import { @@ -615,7 +615,11 @@ export async function updateSite( } } -export async function startServer( event: IpcMainInvokeEvent, id: string ): Promise< void > { +// Resolves with the started site, or nothing when an error page is served instead. +export async function startServer( + event: IpcMainInvokeEvent, + id: string +): Promise< SiteListItem | undefined > { // Starting only needs the path, which `cli.json` has; the CLI's full details wait for a failure. const site = await readSite( id ); if ( ! site ) { @@ -629,7 +633,9 @@ export async function startServer( event: IpcMainInvokeEvent, id: string ): Prom const start = () => startSite( executeCliCommand, site.path ); try { console.log( `Starting server for '${ site.name }'` ); - await start(); + const started = await start(); + console.log( `Server started for '${ site.name }'` ); + return started; } catch ( error ) { // Too many running sites or a WordPress update in progress: expected, not a bug — skip Sentry if ( error instanceof SiteStartRefusedError ) { @@ -701,11 +707,12 @@ export async function startServer( event: IpcMainInvokeEvent, id: string ): Prom throw error; } - - console.log( `Server started for '${ site.name }'` ); } -export async function stopServer( event: IpcMainInvokeEvent, id: string ): Promise< void > { +export async function stopServer( + event: IpcMainInvokeEvent, + id: string +): Promise< SiteListItem | undefined > { const sitePath = await readSitePath( id ); if ( ! sitePath ) { return; @@ -714,7 +721,7 @@ export async function stopServer( event: IpcMainInvokeEvent, id: string ): Promi await stopErrorRecovery( id ); console.log( 'Stopping server with ID', id ); try { - await stopSite( executeCliCommand, sitePath ); + return await stopSite( executeCliCommand, sitePath ); } catch ( error ) { console.error( error ); } diff --git a/apps/studio/src/lib/php-error-recovery.ts b/apps/studio/src/lib/php-error-recovery.ts index c92dd48dee..51542added 100644 --- a/apps/studio/src/lib/php-error-recovery.ts +++ b/apps/studio/src/lib/php-error-recovery.ts @@ -90,7 +90,7 @@ export async function startErrorRecovery( site: { id: string; port: number; path: string }, errorMessage: string, readPm2Logs: ( siteId: string ) => { stdout?: string[]; stderr?: string[] }, - startSite: () => Promise< void > + startSite: () => Promise< unknown > ): Promise< void > { const { id, port, path } = site; diff --git a/apps/ui/src/data/core/connectors/ipc/index.ts b/apps/ui/src/data/core/connectors/ipc/index.ts index 5a17d9b05e..34abb41783 100644 --- a/apps/ui/src/data/core/connectors/ipc/index.ts +++ b/apps/ui/src/data/core/connectors/ipc/index.ts @@ -308,11 +308,11 @@ export function createIpcConnector(): Connector { }, async startSite( id ) { - await unwrapIpcError( ipcApi.startServer( id ) ); + return unwrapIpcError( ipcApi.startServer( id ) ); }, async stopSite( id ) { - await ipcApi.stopServer( id ); + return ipcApi.stopServer( id ); }, async updateSite( site, wpVersion ) { diff --git a/apps/ui/src/data/core/connectors/local/index.ts b/apps/ui/src/data/core/connectors/local/index.ts index 91748c079b..a4efe7b8a9 100644 --- a/apps/ui/src/data/core/connectors/local/index.ts +++ b/apps/ui/src/data/core/connectors/local/index.ts @@ -33,7 +33,7 @@ import type { } from '../../types'; import type { AgentRunEvent } from '@studio/agent/protocol/agent-events'; import type { AiSettings } from '@studio/agent/protocol/providers'; -import type { SiteEvent, SnapshotEvent } from '@studio/common/lib/cli-events'; +import type { SiteEvent, SiteListItem, SnapshotEvent } from '@studio/common/lib/cli-events'; import type { SyncEvent } from '@studio/common/lib/sync/activity'; import type { RawDirectoryEntry } from '@studio/common/types/sync-tree'; @@ -334,10 +334,12 @@ export function createLocalConnector( { apiBaseUrl }: LocalConnectorOptions ): C return lastSites; }, async startSite( id ) { - await api( `/sites/${ encodeURIComponent( id ) }/start`, { method: 'POST' } ); + return api< SiteListItem >( `/sites/${ encodeURIComponent( id ) }/start`, { + method: 'POST', + } ); }, async stopSite( id ) { - await api( `/sites/${ encodeURIComponent( id ) }/stop`, { method: 'POST' } ); + return api< SiteListItem >( `/sites/${ encodeURIComponent( id ) }/stop`, { method: 'POST' } ); }, async getSiteThumbnail( siteId ) { const { thumbnail } = await api< { thumbnail: string | null } >( diff --git a/apps/ui/src/data/core/types.ts b/apps/ui/src/data/core/types.ts index 31d6589164..74169b7282 100644 --- a/apps/ui/src/data/core/types.ts +++ b/apps/ui/src/data/core/types.ts @@ -10,7 +10,7 @@ import type { } from '@studio/agent/protocol/sessions/types'; import type { StudioVisualAnnotationSummary } from '@studio/agent/protocol/visual-annotations'; import type { SiteAgentFile, SiteAgentFileKind } from '@studio/common/lib/agent-skills'; -import type { SiteEvent, SnapshotEvent } from '@studio/common/lib/cli-events'; +import type { SiteEvent, SiteListItem, SnapshotEvent } from '@studio/common/lib/cli-events'; import type { SupportedLocale } from '@studio/common/lib/locale'; import type { TracksAuthSource, @@ -183,8 +183,10 @@ export interface Connector { // registers it as a new site). The connector picks a numbered name and a new // site id internally so callers stay simple. copySite( sourceSiteId: string ): Promise< SiteDetails >; - startSite( id: string ): Promise< void >; - stopSite( id: string ): Promise< void >; + // Resolve with the site as it is once the command finishes, or nothing when + // the caller has to read the list for it. + startSite( id: string ): Promise< SiteListItem | void >; + stopSite( id: string ): Promise< SiteListItem | void >; // Persists edits from the site-settings screen via the CLI-backed main // process handler. `wpVersion` is only forwarded when the user explicitly // picked a pinned version — undefined means "keep auto-updating". diff --git a/apps/ui/src/data/queries/use-sites.test.tsx b/apps/ui/src/data/queries/use-sites.test.tsx index d2309846a9..ad2659c617 100644 --- a/apps/ui/src/data/queries/use-sites.test.tsx +++ b/apps/ui/src/data/queries/use-sites.test.tsx @@ -200,28 +200,22 @@ describe( 'useSyncSitesWithEvents', () => { [ 'start', true ], [ 'stop', false ], ] as const )( - 'shows a %s from its events without refetching the list', + 'shows the site a %s reports, before its release event, without refetching the list', async ( action, running ) => { // What the CLI reports, and the sort order only Studio keeps. const details = { ...makeSite(), url: 'http://localhost:9010' }; const site = { ...details, running: ! running, sortOrder: 1000 }; const connector = createFakeConnector( { sites: [ site ] } ); connector[ action === 'start' ? 'startSite' : 'stopSite' ].mockImplementation( async () => { - const siteId = site.id; - const held = { ...details, operation: { pid: 1, kind: action } }; + // The event while the operation still holds the site. Its release + // event can land after the command finishes. connector.emit( 'onSiteEvent', { event: SITE_EVENTS.UPDATED, - siteId, - site: held, + siteId: site.id, + site: { ...details, operation: { pid: 1, kind: action } }, running, } ); - // An operation's release doesn't know whether the site runs. - connector.emit( 'onSiteEvent', { - event: SITE_EVENTS.OPERATIONS_CHANGED, - siteId, - site: details, - running: ! running, - } ); + return { ...details, running }; } ); const { result } = renderHook( () => { diff --git a/apps/ui/src/data/queries/use-sites.ts b/apps/ui/src/data/queries/use-sites.ts index 3a08eede7b..e576566b09 100644 --- a/apps/ui/src/data/queries/use-sites.ts +++ b/apps/ui/src/data/queries/use-sites.ts @@ -11,7 +11,7 @@ import { SESSIONS_QUERY_KEY } from '@/data/queries/use-sessions'; import { WP_VERSION_QUERY_KEY } from '@/data/queries/use-wordpress-versions'; import { useOpenSite } from '@/data/use-open-site'; import type { Connector, CreateSiteParams, SiteDetails } from '@/data/core'; -import type { SiteEvent } from '@studio/common/lib/cli-events'; +import type { SiteEvent, SiteListItem } from '@studio/common/lib/cli-events'; import type { SiteOperationKind } from '@studio/common/lib/site-operation'; import type { SiteMetadata } from '@studio/common/sites/metadata'; import type { QueryClient } from '@tanstack/react-query'; @@ -157,10 +157,7 @@ export function useStartSite() { if ( isSiteMutating( queryClient, STOP_SITE_MUTATION_KEY, id ) ) { return false; } - await connector.startSite( id ); - if ( ! listShowsSite( queryClient, id, true ) ) { - await queryClient.invalidateQueries( { queryKey: SITES_QUERY_KEY } ); - } + await showSite( queryClient, await connector.startSite( id ) ); return true; }, onError: ( error, id ) => { @@ -181,10 +178,7 @@ export function useStopSite() { return useMutation( { mutationKey: STOP_SITE_MUTATION_KEY, mutationFn: async ( id: string ) => { - await connector.stopSite( id ); - if ( ! listShowsSite( queryClient, id, false ) ) { - await queryClient.invalidateQueries( { queryKey: SITES_QUERY_KEY } ); - } + await showSite( queryClient, await connector.stopSite( id ) ); }, onError: ( _error, id ) => { const { title, description } = getBusyNotice( queryClient, id, __( 'Failed to stop site' ) ); @@ -395,12 +389,19 @@ function applySiteEvent( queryClient: QueryClient, event: SiteEvent ): boolean { return true; } -/** Whether the cached list already shows the site in this state, with no operation holding it. */ -function listShowsSite( queryClient: QueryClient, id: string, running: boolean ): boolean { - const site = queryClient - .getQueryData< SiteDetails[] >( SITES_QUERY_KEY ) - ?.find( ( item ) => item.id === id ); - return site?.running === running && ! site.operation; +/** Shows the site a start or stop reported, or reads the list when there's none to show. */ +async function showSite( queryClient: QueryClient, site: SiteListItem | void ): Promise< void > { + const applied = + site && + applySiteEvent( queryClient, { + event: SITE_EVENTS.UPDATED, + siteId: site.id, + running: site.running, + site, + } ); + if ( ! applied ) { + await queryClient.invalidateQueries( { queryKey: SITES_QUERY_KEY } ); + } } /** diff --git a/packages/common/sites/lifecycle.ts b/packages/common/sites/lifecycle.ts index ab6ba42f84..3992c6f73e 100644 --- a/packages/common/sites/lifecycle.ts +++ b/packages/common/sites/lifecycle.ts @@ -1,7 +1,12 @@ import { __ } from '@wordpress/i18n'; +import { siteListItemSchema, type SiteListItem } from '@studio/common/lib/cli-events'; +import { + CliCommandError, + runCliCommand, + type ExecuteCliCommand, +} from '@studio/common/lib/cli-process'; import { getProxyPortConflictMessage } from '@studio/common/lib/domains'; import { checkMaintenanceFile } from '@studio/common/lib/maintenance-file'; -import type { ExecuteCliCommand } from '@studio/common/lib/cli-process'; // Start / stop / delete a local site via the Studio CLI's one-shot `site` // commands, for the desktop main process and the `studio ui` server alike. @@ -22,11 +27,31 @@ function runSiteCommand( } ); } +// Start and stop report the site as it is once they finish, so the caller can +// show it without waiting for the matching site event. +async function runSiteStateCommand( + execute: ExecuteCliCommand, + args: string[], + baseMessage: string +): Promise< SiteListItem > { + try { + return await runCliCommand( execute, args, siteListItemSchema ); + } catch ( error ) { + if ( error instanceof CliCommandError ) { + error.baseMessage = baseMessage; + } + throw error; + } +} + /** The site can't start for an expected reason that its message explains to the user. */ export class SiteStartRefusedError extends Error {} /** Start a local site's server via the Studio CLI. Resolves when it's already running. */ -export async function startSite( execute: ExecuteCliCommand, sitePath: string ): Promise< void > { +export async function startSite( + execute: ExecuteCliCommand, + sitePath: string +): Promise< SiteListItem > { const maintenance = checkMaintenanceFile( sitePath ); if ( maintenance.exists && ! maintenance.isStale ) { throw new SiteStartRefusedError( @@ -36,7 +61,7 @@ export async function startSite( execute: ExecuteCliCommand, sitePath: string ): ); } try { - await runSiteCommand( + return await runSiteStateCommand( execute, [ 'site', 'start', '--path', sitePath, '--skip-browser', '--skip-log-details' ], 'Failed to start site' @@ -59,8 +84,12 @@ export async function startSite( execute: ExecuteCliCommand, sitePath: string ): } /** Stop a local site's server via the Studio CLI. */ -export function stopSite( execute: ExecuteCliCommand, sitePath: string ): Promise< void > { - return runSiteCommand( execute, [ 'site', 'stop', '--path', sitePath ], 'Failed to stop site' ); +export function stopSite( execute: ExecuteCliCommand, sitePath: string ): Promise< SiteListItem > { + return runSiteStateCommand( + execute, + [ 'site', 'stop', '--path', sitePath ], + 'Failed to stop site' + ); } /** Remove a site from Studio via the Studio CLI, trashing its files unless `deleteFiles` is false. */ From a180f15aeb704d8cd06b9bb197d90ada06db5b8f Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 28 Oct 2026 11:08:17 -0400 Subject: [PATCH 4/4] Rerun CI