// // Copyright 2024 DXOS.org // import { Effect, Option, pipe, Ref } from 'effect'; import { type Simplify } from 'effect/Types'; import { live } from '@dxos/live-object'; import { log } from '@dxos/log'; import { byPosition, type MaybePromise, type Position, type GuardedType } from '@dxos/util'; import { IntentAction } from './actions'; import { CycleDetectedError, NoResolversError } from './errors'; import { createIntent, type AnyIntent, type AnyIntentChain, type Intent, type IntentChain, type IntentData, type IntentParams, type IntentResultData, type IntentSchema, type Label, } from './intent'; import { Events, Capabilities } from '../common'; import { contributes, type PluginContext } from '../core'; const EXECUTION_LIMIT = 100; const HISTORY_LIMIT = 100; /** * The return value of an intent effect. */ export type IntentEffectResult = { /** * The output of the action that was performed. * * If the intent is apart of a chain of intents, the data will be passed to the next intent. */ data?: Output; /** * If provided, the action will be undoable. */ undoable?: { /** * Message to display to the user when indicating that the action can be undone. */ message: Label; /** * Will be merged with the original intent data when firing the undo intent. */ data?: Partial; }; /** * An error that occurred while performing the action. * * If the intent is apart of a chain of intents and an error occurs, the chain will be aborted. * * Return caught error instead of throwing to trigger other intent to be triggered prior to returning the error. */ error?: Error; /** * Other intent chains to be triggered. */ intents?: AnyIntentChain[]; }; export type AnyIntentEffectResult = IntentEffectResult; /** * The result of an intent dispatcher. */ export type IntentDispatcherResult = Pick, 'data' | 'error'>; /** * The implementation of an intent effect. */ export type IntentEffectDefinition = ( data: Input, undo: boolean, ) => | MaybePromise | void> | Effect.Effect | void, Error>; /** * Intent resolver to match intents to their effects. */ export type IntentResolver> = Readonly<{ /** * The schema of the intent to be resolved. */ intent: IntentSchema; /** * Hint to determine the order the resolvers are processed if multiple resolvers are defined for the same intent. * Only one resolver will be used. */ position?: Position; /** * Optional filter to determine if the resolver should be used. */ filter?: (data: IntentData) => data is Data; /** * The effect to be performed when the intent is resolved. */ resolve: IntentEffectDefinition['filter']>, IntentResultData>; }>; export type AnyIntentResolver = IntentResolver; /** * Creates an intent resolver to match intents to their effects. * @param schema Schema of the intent. Must be a tagged class with input and output schemas. * @param effect Effect to be performed when the intent is resolved. * @param params.disposition Determines the priority of the resolver when multiple are resolved. * @param params.filter Optional filter to determine if the resolver should be used. */ export const createResolver = >( resolver: IntentResolver, ) => resolver; /** * Invokes intents and returns the result. */ export type PromiseIntentDispatcher = ( intent: IntentChain, ) => Promise, IntentResultData>>>; /** * Creates an effect for intents. */ export type IntentDispatcher = ( intent: IntentChain, depth?: number, ) => Effect.Effect< Simplify, IntentResultData>>['data']>, Error >; type IntentResult = IntentEffectResult< IntentData, IntentResultData > & { _intent: Intent; }; export type AnyIntentResult = IntentResult; /** * Invokes the most recent undoable intent with undo flags. */ export type PromiseIntentUndo = () => Promise>; /** * Creates an effect which undoes the last intent. */ export type IntentUndo = () => Effect.Effect; /** * Check if a chain of results is undoable. */ const isUndoable = (historyEntry: AnyIntentResult[]): boolean => historyEntry.length > 0 && historyEntry.every(({ undoable }) => !!undoable); export type IntentContext = { dispatch: IntentDispatcher; dispatchPromise: PromiseIntentDispatcher; undo: IntentUndo; undoPromise: PromiseIntentUndo; }; /** * Sets of an intent dispatcher. * * @param getResolvers A function that returns an array of available intent resolvers. * @param params.historyLimit The maximum number of intent results to keep in history. * @param params.executionLimit The maximum recursion depth of intent chains. */ export const createDispatcher = ( getResolvers: () => AnyIntentResolver[], { executionLimit = EXECUTION_LIMIT, historyLimit = HISTORY_LIMIT } = {}, ): IntentContext => { const historyRef = Effect.runSync(Ref.make([])); const handleIntent = (intent: AnyIntent) => Effect.gen(function* () { const candidates = getResolvers() .filter((resolver) => resolver.intent._tag === intent.id) .filter((resolver) => !resolver.filter || resolver.filter(intent.data)) .toSorted(byPosition); if (candidates.length === 0) { yield* Effect.fail(new NoResolversError(intent.id)); } const effect = candidates[0].resolve(intent.data, intent.undo ?? false); const result = Effect.isEffect(effect) ? yield* effect : yield* Effect.promise(async () => effect); return { _intent: intent, ...result } as AnyIntentResult; }); const dispatch: IntentDispatcher = (intentChain, depth = 0) => { return Effect.gen(function* () { if (depth > executionLimit) { yield* Effect.fail(new CycleDetectedError()); } const resultsRef = yield* Ref.make([]); for (const intent of intentChain.all) { const { data: prev } = (yield* resultsRef.get)[0] ?? {}; const result = yield* handleIntent({ ...intent, data: { ...intent.data, ...prev } }); yield* Ref.update(resultsRef, (results) => [result, ...results]); if (result.intents) { for (const intent of result.intents) { // Returned intents are dispatched but not yielded into results, as such they cannot be undone. // TODO(wittjosiah): Use higher execution concurrency? yield* dispatch(intent, depth + 1); } } if (result.error) { // yield* dispatch( // createIntent(IntentAction.Track, { // intents: intentChain.all.map((i) => i.id), // error: result.error.message, // }), // ); yield* Effect.fail(result.error); } } // Track the intent chain. // if (intentChain.all.some((intent) => intent.id !== IntentAction.Track._tag)) { // yield* dispatch(createIntent(IntentAction.Track, { intents: intentChain.all.map((i) => i.id) })); // } const results = yield* resultsRef.get; const result = results[0]; yield* Ref.update(historyRef, (history) => { const next = [...history, results]; if (next.length > historyLimit) { next.splice(0, next.length - historyLimit); } return next; }); if (result.undoable && isUndoable(results)) { // TODO(wittjosiah): Is there a better way to handle showing undo for chains? yield* pipe( dispatch(createIntent(IntentAction.ShowUndo, { message: result.undoable.message })), Effect.catchSome((err) => err instanceof NoResolversError ? Option.some(Effect.succeed(undefined)) : Option.none(), ), ); } return result.data; }); }; const dispatchPromise: PromiseIntentDispatcher = (intentChain) => { return Effect.runPromise(dispatch(intentChain)) .then((data) => ({ data })) .catch((error) => { log.catch(error); return { error }; }); }; const undo: IntentUndo = () => { return Effect.gen(function* () { const history = yield* historyRef.get; const last = history.findLastIndex(isUndoable); const result = last !== -1 ? history[last] : undefined; if (result) { const all = result.map(({ _intent, undoable }): AnyIntent => { const data = _intent.data; const undoData = undoable?.data ?? {}; return { ..._intent, data: { ...data, ...undoData }, undo: true } satisfies AnyIntent; }); const intent = { first: all[0], last: all.at(-1)!, all } satisfies AnyIntentChain; yield* Ref.update(historyRef, (h) => h.filter((_, index) => index !== last)); return yield* dispatch(intent); } }); }; const undoPromise: PromiseIntentUndo = () => { return Effect.runPromise(undo()) .then((data) => ({ data })) .catch((error) => ({ error })); }; return { dispatch, dispatchPromise, undo, undoPromise }; }; const defaultEffect = () => Effect.fail(new Error('Intent runtime not ready')); const defaultPromise = () => Effect.runPromise(defaultEffect()); export default (context: PluginContext) => { const state = live({ dispatch: defaultEffect, dispatchPromise: defaultPromise, undo: defaultEffect, undoPromise: defaultPromise, }); // TODO(wittjosiah): Make getResolver callback async and allow resolvers to be requested on demand. const { dispatch, dispatchPromise, undo, undoPromise } = createDispatcher(() => context.getCapabilities(Capabilities.IntentResolver).flat(), ); const manager = context.getCapability(Capabilities.PluginManager); state.dispatch = (intentChain, depth) => { return Effect.gen(function* () { yield* manager._activate(Events.SetupIntentResolver); return yield* dispatch(intentChain, depth); }); }; state.dispatchPromise = async (intentChain) => { await manager.activate(Events.SetupIntentResolver); return await dispatchPromise(intentChain); }; state.undo = undo; state.undoPromise = undoPromise; return contributes(Capabilities.IntentDispatcher, state); };