Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Website query API: Continue plugin installs on error #605

Closed
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
797dce0
Implement callback onBlueprintStepError to allow plugin installs to c…
eliot-akira Jul 19, 2023
71f32d9
Format
eliot-akira Jul 19, 2023
c6fbe1f
Export type OnStepError
eliot-akira Jul 19, 2023
38791f2
Format
eliot-akira Jul 19, 2023
71b2467
Catch resource fetch errors to let others continue to resolve
eliot-akira Jul 25, 2023
486a632
Website: Until we have a way to show notifications, output console er…
eliot-akira Jul 25, 2023
bc803cd
Define default onStepError callback that rethrows
eliot-akira Jul 25, 2023
5fb2d41
Add comment to clarify how a blueprint step's resources are resolved
eliot-akira Aug 5, 2023
1281355
Implement onRunningSteps for success/error handling per step; onSteps…
eliot-akira Aug 8, 2023
4681f81
Update types
eliot-akira Aug 8, 2023
4722601
Format
eliot-akira Aug 8, 2023
69eb4af
Website: Implement onRunningBlueprintSteps handler that allows plugin…
eliot-akira Aug 8, 2023
978a3fa
Merge from upstream/trunk and resolve conflict
eliot-akira Dec 11, 2023
c8467b1
Refactor as onStepProgress
eliot-akira Dec 11, 2023
06d7f26
Add blueprint step option `failMode` with value `abort` or `skip`, an…
eliot-akira Feb 8, 2024
df14575
Refactor `OnStepProgress` and `OnStepsCompleted` into `onStepError` a…
eliot-akira Feb 8, 2024
4243e59
For fail mode "skip", resolve step status instead of rejecting it on …
eliot-akira Feb 8, 2024
b32c27b
compileBlueprint option shouldBoot returns boolean
eliot-akira Feb 8, 2024
c7b7920
Use shared type StepFailMode
eliot-akira Feb 8, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/playground/blueprints/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export type {
CompiledBlueprint,
CompileBlueprintOptions,
OnStepCompleted,
OnStepsCompleted,
OnStepProgress,
} from './lib/compile';
export type {
CachedResource,
Expand Down
96 changes: 82 additions & 14 deletions packages/playground/blueprints/src/lib/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,17 +48,35 @@ export interface CompiledBlueprint {
run: (playground: UniversalPHP) => Promise<void>;
}

export type OnStepProgress = (
promise: Promise<void>,
step: StepDefinition
) => void;
export type OnStepCompleted = (output: any, step: StepDefinition) => any;
export type OnStepsCompleted = (
completedStatus: Map<StepDefinition, boolean>
) => void;

export interface CompileBlueprintOptions {
/** Optional progress tracker to monitor progress */
progress?: ProgressTracker;
/** Optional semaphore to control access to a shared resource */
semaphore?: Semaphore;
/** Optional callback with step output */
onStepProgress?: OnStepProgress;
onStepCompleted?: OnStepCompleted;
onStepsCompleted?: OnStepsCompleted;
eliot-akira marked this conversation as resolved.
Show resolved Hide resolved
}

/**
* Convert a Promise into a boolean indicating if it resolved or rejeted.
*/
const didResolve = <P extends Promise<any>>(promise: P): Promise<boolean> =>
Copy link
Collaborator

@adamziel adamziel Jan 29, 2024

Choose a reason for hiding this comment

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

didResolve() === false suggests the promise may still be pending. I wonder what would be a more descriptive name here.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

It was tough to find a suitable naming for this didResolve function. I consulted MDN: Promise to see what the standard terminology is.

A Promise is in one of these states:

  • pending: initial state, neither fulfilled nor rejected.
  • fulfilled: meaning that the operation was completed successfully.
  • rejected: meaning that the operation failed.

A promise is said to be settled if it is either fulfilled or rejected, but not pending.

In the end I settled on a verbose name isSettledPromiseResolved, to make it clear that it's checking an already settled promise for its resolved status.

promise.then(
() => true,
() => false
);

/**
* Compiles Blueprint into a form that can be executed.
*
Expand All @@ -73,6 +91,8 @@ export function compileBlueprint(
progress = new ProgressTracker(),
semaphore = new Semaphore({ concurrency: 3 }),
onStepCompleted = () => {},
onStepProgress = () => {},
onStepsCompleted,
}: CompileBlueprintOptions = {}
): CompiledBlueprint {
blueprint = {
Expand Down Expand Up @@ -171,33 +191,81 @@ export function compileBlueprint(
networking: blueprint.features?.networking ?? false,
},
run: async (playground: UniversalPHP) => {
const progressStatus = new Map<StepDefinition, Promise<void>>();
eliot-akira marked this conversation as resolved.
Show resolved Hide resolved
const progressPromises = new Map<
eliot-akira marked this conversation as resolved.
Show resolved Hide resolved
StepDefinition,
{
resolve: (result: any) => void;
reject: (error: any) => void;
}
>();
for (const { step } of compiled) {
const promise = new Promise<void>((resolve, reject) => {
progressPromises.set(step, { resolve, reject });
});
progressStatus.set(step, promise);
onStepProgress(promise, step);
}

try {
// Start resolving resources early
for (const { resources } of compiled) {
for (const { resources, step } of compiled) {
const stepPromise = progressPromises.get(step);
for (const resource of resources) {
resource.setPlayground(playground);
if (resource.isAsync) {
resource.resolve();
resource.resolve().catch(function (error) {
dmsnell marked this conversation as resolved.
Show resolved Hide resolved
stepPromise?.reject(error);
});
}
}
}

/**
* When a compiled step's `run` method is called, it
* uses `resolveArguments` to await dependent resources.
*/
for (const { run, step } of compiled) {
const result = await run(playground);
onStepCompleted(result, step);
const stepPromise = progressPromises.get(step);
try {
const result = await run(playground);
stepPromise?.resolve(result);
onStepCompleted(result, step);
Copy link
Collaborator

Choose a reason for hiding this comment

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

If the onStepCompleted handler throws an error, the stepPromise?.reject(error); will run. It would be nice to just rethrow that error so the API consumer may notice and correct it.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Instead of rethrowing, the two lines stepStatus?.resolve(result) and onStepCompleted have been moved below the try/catch clause. If the latter throws (by the user or an unexpected error), it bubbles up to the API consumer, the caller of CompiledBlueprint.run().

} catch (error) {
stepPromise?.reject(error);
Copy link
Collaborator

@adamziel adamziel Jan 29, 2024

Choose a reason for hiding this comment

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

I wonder whether the failure consequences should be possible to specify per step type, e.g. rm could default to triggering a warning but not stopping the execution, while defineWpConfigConsts would default to throwing an error and stopping the execution.

There seem to be a hierarchy of control here:

  1. Consumer has a preference. If missing, fall back to...
  2. Step has a preference. If missing, fall back to...
  3. Blueprint engine has a preference – that's implicit from the code structure here

It would be nice to stop the execution as soon as we know the failure is fatal. Imagine the first step of a work-intense Blueprint fails and we already know this is irrecoverable – stopping right then and there would spare the user 30s of waiting before learning about the error.

Code-wise, it could mean a logic similar to this:

Suggested change
stepPromise?.reject(error);
stepPromise?.reject(error);
if( isFailureFatal( step, error ) ) {
throw e;
}

Now, the isFailureFatal would need to know about both the step preference and the user preference, which means we need to provide developers with a way of overriding the default behavior on a granular, per-step basis.

This PR introduces a decision point in the onStepsCompleted callback registered by the application calling the compileBlueprint function. I think it would be more useful to make that decision inside of a Blueprint. Imagine a live product demo. The installPlugin installing the demoed plugin is indispensable, but the next one installing a dev utility plugin can likely be skipped if it fails.

Technically, this could mean introducing a new step-level property:

export type StepDefinition = Step & {
	progress?: {
		weight?: number;
		caption?: string;
	};
+	onFailure: 'abort' | 'skip';
};

This would enable writing blueprints such as...

{
	"steps": [
		{
			"step": "installPlugin",
			"pluginZipFile": {
				"resource": "wordpress.org/plugins",
				"slug": "my-product"
			},
			"onFailure": "abort"
		},
		{
			"step": "installPlugin",
			"pluginZipFile": {
				"resource": "wordpress.org/plugins",
				"slug": "devtools"
			},
			"onFailure": "skip"
		}
}

The default value could be abort. Each step handler would be able to override it, for example like this:

/**
 * Removes a file at the specified path.
 */
export const rm: StepHandler<RmStep> = async (playground, { path }) => {
	await playground.unlink(path);
};

import { defaultStepFailureHandlers } from '.';
defaultStepFailureHandlers.set( 'rm', 'continue' );

The compileStep() function could then output the desired failure behavior:

function compileStep<S extends StepDefinition>(
	step: S,
	{
		semaphore,
		rootProgressTracker,
		totalProgressWeight,
	}: CompileStepArgsOptions
): { run: CompiledStep; step: S; resources: Array<Resource> } {
	return {
		run,
		step,
		resources,
		onFailure:  'onFailure' in step ? step.onFailure : defaultStepFailureHandlers.get( step.step ) ?? 'abort'
	};

And this loop could handle it as follows:

				for (const { run, step, onFailure } of compiled) {
					const stepPromise = progressPromises.get(step);
					try {
						const result = await run();
						onStepSettled(step, SUCCESS, result);
					} catch(e) {
						onStepSettled(step, FAILURE, e);
						if( onFailure === 'abort' ) {
							throw e;
						}
					}

Copy link
Collaborator

Choose a reason for hiding this comment

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

I wonder how the ideas above resonate with you @eliot-akira @dmsnell @bgrgicak

Copy link
Member

Choose a reason for hiding this comment

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

I think it fits within the idea I had, which was to expose each to the application and let it decide.

we could provide default behaviors, which I think should be rare, and then if someone in userspace adds their own handler for the step, then they could take their own action.

or, we could expose our default handling to the callback and only if they implement the callback would we use theirs instead.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I like this approach, it would at least help with development and while creating blueprints.

Personally, I love opinionated code that I can adjust, so I would go with having defaults for each step, but let users override them. Maybe even include a global override for all steps.

Copy link
Collaborator Author

@eliot-akira eliot-akira Jan 30, 2024

Choose a reason for hiding this comment

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

Thank you for the detailed review and feedback. I'm starting to get a feel for the public interface that's shaping up.

I like the idea of adding to the StepDefinition a new property onFailure, with value abort or skip. That addresses the original issue that motivated this PR, which is to allow some plugins to be skipped if install fails, while still keeping plugins required by default (error on install fail).

It makes sense for each step handler to have a default failure mode (abort or skip), which can be overridden by the user.

I'll get started on implementing the suggestions, and will either resolve each reviewed point or reply with any questions.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Thank you so much @eliot-akira, you are the best!

}
}
} finally {
try {
await (playground as any).goTo(
blueprint.landingPage || '/'
const completedStatus = new Map();
for (const { step } of compiled) {
completedStatus.set(
step,
await didResolve(
progressStatus.get(step) as Promise<any>
)
);
} catch (e) {
/*
* NodePHP exposes no goTo method.
* We can't use `goto` in playground here,
* because it may be a Comlink proxy object
* with no such method.
*/
}

// Either someone made a choice to boot or all steps succeeded.
const shouldBoot = onStepsCompleted
? await onStepsCompleted(completedStatus)
: await didResolve(
Promise.all([...progressStatus.values()])
);

if (shouldBoot) {
try {
await (playground as any)?.goTo(
blueprint.landingPage || '/'
);
} catch (e) {
/*
* NodePHP exposes no goTo method.
* We can't use `goto` in playground here,
* because it may be a Comlink proxy object
* with no such method.
*/
}
}
progress.finish();
}
Expand Down
6 changes: 6 additions & 0 deletions packages/playground/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ import {
Blueprint,
compileBlueprint,
OnStepCompleted,
OnStepsCompleted,
OnStepProgress,
runBlueprintSteps,
} from '@wp-playground/blueprints';
import { consumeAPI } from '@php-wasm/web';
Expand All @@ -46,6 +48,8 @@ export interface StartPlaygroundOptions {
disableProgressBar?: boolean;
blueprint?: Blueprint;
onBlueprintStepCompleted?: OnStepCompleted;
onBlueprintStepsCompleted?: OnStepsCompleted;
eliot-akira marked this conversation as resolved.
Show resolved Hide resolved
onBlueprintStepProgress?: OnStepProgress;
}

/**
Expand All @@ -62,6 +66,7 @@ export async function startPlaygroundWeb({
progressTracker = new ProgressTracker(),
disableProgressBar,
onBlueprintStepCompleted,
onBlueprintStepProgress,
}: StartPlaygroundOptions): Promise<PlaygroundClient> {
assertValidRemote(remoteUrl);
allowStorageAccessByUserActivation(iframe);
Expand All @@ -76,6 +81,7 @@ export async function startPlaygroundWeb({
const compiled = compileBlueprint(blueprint, {
progress: progressTracker.stage(0.5),
onStepCompleted: onBlueprintStepCompleted,
onStepProgress: onBlueprintStepProgress,
});
const playground = await doStartPlaygroundWeb(
iframe,
Expand Down
15 changes: 15 additions & 0 deletions packages/playground/website/src/lib/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { Blueprint, startPlaygroundWeb } from '@wp-playground/client';
import type { PlaygroundClient } from '@wp-playground/client';
import type { Step } from '@wp-playground/blueprints';
import { getRemoteUrl } from './config';

interface UsePlaygroundOptions {
Expand Down Expand Up @@ -40,10 +41,24 @@ export function usePlayground({ blueprint, storage }: UsePlaygroundOptions) {
remoteUrl.searchParams.set('storage', storage);
}

const onBlueprintStepProgress = (
promise: Promise<void>,
step: Step
) => {
promise.catch((error: any) => {
if (step.step === 'installPlugin') {
alert(`Failed to install: ${step.pluginZipFile}`);
} else {
throw error;
}
});
};

startPlaygroundWeb({
iframe,
remoteUrl: remoteUrl.toString(),
blueprint,
onBlueprintStepProgress,
}).then(async (playground) => {
playground.onNavigation((url) => setUrl(url));
setPlayground(() => playground);
Expand Down
8 changes: 6 additions & 2 deletions packages/playground/wordpress/build/build.js
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,9 @@ versions = Object.keys(versions)
// Write the updated JSON back to the file
await fs.writeFile(versionsPath, JSON.stringify(versions, null, 2));

const latestStableVersion = Object.keys(versions).filter((v) => v.match(/^\d/))[0];
const latestStableVersion = Object.keys(versions).filter((v) =>
v.match(/^\d/)
)[0];

// Refresh get-wordpress-module.ts
const getWordPressModulePath = `${outputJsDir}/get-wordpress-module.ts`;
Expand All @@ -186,7 +188,9 @@ const getWordPressModuleContent = `
* This file must statically exists in the project because of the way
* vite resolves imports.
*/
export function getWordPressModule(wpVersion: string = ${JSON.stringify(latestStableVersion)}) {
export function getWordPressModule(wpVersion: string = ${JSON.stringify(
latestStableVersion
)}) {
switch (wpVersion) {
${Object.keys(versions)
.map(
Expand Down
Loading