Skip to content

feat: Add UI to actions whose definitions indicate they return a result, to optionally store that result in a local or custom variable#4065

Merged
Julusian merged 11 commits into
bitfocus:mainfrom
jswalden:action-callback-return-result
May 26, 2026
Merged

feat: Add UI to actions whose definitions indicate they return a result, to optionally store that result in a local or custom variable#4065
Julusian merged 11 commits into
bitfocus:mainfrom
jswalden:action-callback-return-result

Conversation

@jswalden
Copy link
Copy Markdown
Contributor

@jswalden jswalden commented Apr 2, 2026

The one moderately-major thing in all this that I am uncertain about, is how to propagate a result to a subsequent concurrent action group that contains waits.

If I read the code correctly, Companion basically splits concurrent action groups into sets of non-wait actions, and then it performs all those non-waits of the first set at once, then does the intervening wait, then does the next set of non-waits, etc.

I think it is reasonable to propagate a previous result to all actions in a concurrent action group that doesn't contain any waits. It is less clear that it makes sense to do so when the action group contains waits. Or that they should propagate beyond the first set of actions? For prototype-ful purposes I just acted like the waits weren't there. For actual suitability for landing purposes, maybe that should change. Don't propagate if there are any waits? Only propagate to actions before the first wait? The answer isn't immediately obvious to me.

Summary by CodeRabbit

Release Notes

  • New Features

    • Actions can now return values that are automatically stored in variables
    • Added support for storing action results in local and custom variables
    • Users can configure where action results should be saved
  • Bug Fixes

    • Improved action validation and error handling for missing connections

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 2, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR adds support for capturing and storing action return values in Companion variables. It refactors internal action result handling from boolean to structured types, introduces a generalized special-expression manager to track computed metadata (isInverted, storeResult), creates LocalVariablesController for local variable management, and updates action execution paths across process boundaries to propagate result values through storage targets.

Changes

Action Result Storage & Special Expression Framework

Layer / File(s) Summary
Type system for action results and special expressions
companion/lib/Internal/Types.ts, companion/lib/Controls/Entities/Types.ts, companion/lib/Controls/Entities/SpecialExpressions.ts
Introduces ActionResult and InternalActionResult types for structured result payloads, defines the StoreResult union for local/custom variable targets, and exports SpecialExpressions schema with isInverted and storeResult as the two tracked special expressions.
Local variable controller
companion/lib/Variables/LocalVariablesController.ts
New controller provides localVariableFor(...) to resolve local variable descriptors, and methods to setLocalVariable, resetLocalVariable, and writeLocalVariableStartupValue on identified feedback entities.
Special expression manager
companion/lib/Controls/Entities/EntitySpecialExpressionManager.ts
Replaces the inversion-only manager with a generalized EntityPoolSpecialExpressionManager that tracks entities requiring either isInverted or storeResult computation, evaluates expressions/values with variable-dependency tracking, queues reprocessing on variable changes, and propagates updates through registered callbacks.
Entity instance store-result support
companion/lib/Controls/Entities/EntityInstance.ts, companion/lib/Controls/Entities/EntityList.ts
Adds #cachedStoreResult field, rawStoreResult/storeResult getters, and setRawStoreResult setter. Updates subscription to conditionally track storeResult on actions via the special-expression manager and implements updateStoreResultValues to detect and propagate result-target changes through child groups.
Pool-level manager replacement
companion/lib/Controls/Entities/EntityListPoolBase.ts, companion/lib/Controls/Entities/EntityListPoolButton.ts, companion/lib/Controls/Entities/EntityListPoolExpressionVariable.ts, companion/lib/Controls/Entities/EntityListPoolTrigger.ts
Replaces EntityPoolIsInvertedManager with EntityPoolSpecialExpressionManager throughout the entity pool hierarchy. Adds abstract updateStoreResultValues contracts and public entitySetRawStoreResult API. Concrete pools implement store-result propagation to their child entity lists and optional redraw/variable-notification triggering.
Store result setter API
companion/lib/Controls/EntitiesTrpcRouter.ts
Exposes setRawStoreResult tRPC mutation to allow clients to set an entity's storeResult target, including validation and control lookup.

Action Execution & Result Capture

Layer / File(s) Summary
Action runner result storage
companion/lib/Controls/ActionRunner.ts
Updated to accept VariablesController and LocalVariablesController dependencies. #runAction now captures the return value from internal/external action execution, and when action.storeResult is set, stores the result into either a local variable (via localVariablesController) or a custom variable (via customVars), with validation warnings for invalid targets.
Internal action result refactoring
companion/lib/Internal/Controller.ts, companion/lib/Internal/ActionRecorder.ts, companion/lib/Internal/Variables.ts, companion/lib/Internal/CustomVariables.ts, companion/lib/Internal/Controls.ts, companion/lib/Internal/BuildingBlocks.ts, companion/lib/Internal/Instance.ts, companion/lib/Internal/Surface.ts, companion/lib/Internal/System.ts, companion/lib/Internal/Triggers.ts
All internal modules refactor executeAction from boolean-returning if/else chains to switch-based implementations returning InternalActionResult (structured result or null). Actions return { result: undefined } on success, null when unhandled. Internal action definitions set hasResult appropriately (true for actions supporting result, false for feedback).
Local variable action integration
companion/lib/Internal/Variables.ts, companion/lib/Internal/Util.ts
Refactors local-variable actions (local_variable_set_value, reset, sync) to use LocalVariablesController and shared ControlLocationOption/LocalVariableNameOption builders instead of parsing location strings and managing entity pools. Removes previous CHOICES_LOCATION export and entity-update helpers.

Cross-Process Communication & IPC

Layer / File(s) Summary
IPC response schema for action results
companion/lib/Instance/Connection/IpcTypesNew.ts, companion/lib/Instance/Connection/Thread/Entrypoint.ts
Converts ExecuteActionResponseMessage from a flat interface to a discriminated union: ExecuteActionSuccessMessage (success: true, result) and ExecuteActionFailureMessage (success: false, errorMessage). IPC handler in Entrypoint.ts shapes the response payload conditionally based on success/failure state.
Child handler action result propagation
companion/lib/Instance/Connection/ChildHandlerApi.ts, companion/lib/Instance/Connection/ChildHandlerLegacy.ts, companion/lib/Instance/Connection/ChildHandlerNew.ts, companion/lib/Instance/Connection/Thread/HostContext.ts
Updates actionRun return types to Promise<JsonValue | undefined>. Legacy handler returns undefined, new handler returns result from executeAction on success. Host context sets hasResult field in client action/feedback definitions sent over IPC.

Tests

Layer / File(s) Summary
Special expression manager test migration
companion/test/Controls/Entities/EntitySpecialExpressionManager.test.ts, companion/test/Controls/Entities/EntityList.test.ts
Tests migrated from EntityPoolIsInvertedManager to EntityPoolSpecialExpressionManager. Tracking calls now include explicit special-expression keys. Assertions updated to expect NewSpecialExpressionValue<'isInverted'> (with value field) instead of the previous NewIsInvertedValue shape.

Poem

Actions return their precious gold,
Stored in variables, brave and bold!
Expressions dance and values flow,

Through local scopes where numbers grow—
A unified manager guides the way,
Tracking truth both night and day.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title accurately describes the main feature added: UI support for actions to store results in variables.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
companion/lib/Internal/Controller.ts (1)

448-470: ⚠️ Potential issue | 🟠 Major

Internal action results are still dropped on this path.

This now wires $(this:result) into parsing, but Line 448 still returns Promise<void>, and the fragment dispatch below still treats the fragment return as a truthy “handled” flag. The result is that internal actions cannot propagate any value to the next action, and falsy results like 0, false, or '' additionally fall through as unhandled. Please switch this path to an explicit handled/result contract and return the actual result from executeAction().

Also applies to: 496-505

companion/lib/Controls/ActionRunner.ts (1)

89-110: ⚠️ Potential issue | 🟠 Major

Please lock down previousResult semantics across concurrent groups with waits

Friendly heads-up: actions after a wait currently still receive the same extras.previousResult as actions before the wait (Line 104), which matches the prototype but leaves ambiguous behavior in a user-visible path. This can make $(this:result) feel non-deterministic once waits are involved.

I’d recommend choosing and enforcing one explicit policy here (e.g., clear after first wait, or disable propagation when any wait exists).

Suggested direction (disable propagation when waits are present)
 		} else {
 			const groupedActions = this.#splitActionsAroundWaits(actions)
+			const hasWaits = groupedActions.some((group) => !!group.waitAction)

 			const ps: Promise<VariableValue>[] = []

 			for (const { waitAction, actions } of groupedActions) {
 				if (extras.abortDelayed.aborted) break
@@
 				// Spawn all the actions in parallel
 				for (const action of actions) {
+					const actionExtras: RunActionExtras = {
+						...extras,
+						previousResult: hasWaits ? undefined : extras.previousResult,
+					}
 					ps.push(
-						this.#runAction(action, extras).catch((e) => {
+						this.#runAction(action, actionExtras).catch((e) => {
 							this.#logger.silly(`Error executing action for ${action.connectionId}: ${e.message ?? e}`)
 							return undefined
 						})
 					)
 				}
🧹 Nitpick comments (2)
companion/lib/Controls/IControlStore.ts (1)

48-52: Consider making previousResult optional in the interface

Nice addition overall. For this interface, making previousResult optional can keep behavior the same while avoiding repetitive undefined at every non-action call site.

Proposed tweak
 createVariablesAndExpressionParser(
 	controlId: string | null | undefined,
 	overrideVariableValues: VariableValues | null,
-	previousResult: VariableValue
+	previousResult?: VariableValue
 ): VariablesAndExpressionParser
companion/lib/Controls/ActionRunner.ts (1)

75-83: Nice chaining logic — consider avoiding in-place mutation of extras

This works, but mutating extras.previousResult directly (Line 77) makes the function stateful against its input object. Using a local previousResult (and passing a fresh extras object per action) keeps behavior easier to reason about.

Refactor sketch
 		if (executeSequential) {
 			// Future: abort on error?
+			let previousResult = extras.previousResult

 			for (const action of actions) {
 				if (extras.abortDelayed.aborted) break
-				extras.previousResult = await this.#runAction(action, extras).catch((e) => {
+				previousResult = await this.#runAction(action, { ...extras, previousResult }).catch((e) => {
 					this.#logger.silly(`Error executing action for ${action.connectionId}: ${e.message ?? e}`)
 					return undefined
 				})
 			}

-			return extras.previousResult
+			return previousResult

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7d3bd3af-aba0-4f33-b03d-b6cd4ddcdc72

📥 Commits

Reviewing files that changed from the base of the PR and between 0a7ada7 and 21b0e38.

⛔ Files ignored due to path filters (1)
  • yarn.lock is excluded by !**/yarn.lock, !**/*.lock
📒 Files selected for processing (26)
  • companion/lib/Controls/ActionRunner.ts
  • companion/lib/Controls/ControlStore.ts
  • companion/lib/Controls/ControlTypes/Button/Base.ts
  • companion/lib/Controls/ControlTypes/Button/Preset.ts
  • companion/lib/Controls/ControlTypes/Button/Util.ts
  • companion/lib/Controls/ControlTypes/Triggers/Trigger.ts
  • companion/lib/Controls/Controller.ts
  • companion/lib/Controls/Entities/EntityIsInvertedManager.ts
  • companion/lib/Controls/Entities/EntityListPoolBase.ts
  • companion/lib/Controls/IControlStore.ts
  • companion/lib/ImportExport/Backups.ts
  • companion/lib/ImportExport/Export.ts
  • companion/lib/Instance/Connection/ChildHandlerApi.ts
  • companion/lib/Instance/Connection/ChildHandlerLegacy.ts
  • companion/lib/Instance/Connection/ChildHandlerNew.ts
  • companion/lib/Instance/Connection/EntityManager.ts
  • companion/lib/Instance/Connection/IpcTypesNew.ts
  • companion/lib/Instance/Connection/Thread/Entrypoint.ts
  • companion/lib/Internal/Controller.ts
  • companion/lib/Preview/ExpressionStream.ts
  • companion/lib/Preview/Graphics.ts
  • companion/lib/Surface/Controller.ts
  • companion/lib/Variables/Values.ts
  • companion/package.json
  • docs/user-guide/3_config/variables.md
  • webui/src/Controls/LocalVariablesStore.tsx

Comment thread companion/package.json Outdated
Comment thread docs/user-guide/3_config/variables.md Outdated
@jswalden
Copy link
Copy Markdown
Contributor Author

jswalden commented Apr 2, 2026

Internal action results are still dropped on this path.

I chose for internal actions to not produce their own results, and also not to propagate a prior result across them. (Why should they? Don't see good reason for them to, honestly.) It makes most sense IMO for only an action that produces a result itself, to be source of one.

Please lock down previousResult semantics across concurrent groups with waits

Flagged in the initial comment.

Consider making previousResult optional in the interface

Explicit is better than implicit. There's always a previous result value/variable. It's not actually optional semantically. (I even considered using const previousResult: VariableValue = undefined every place, for documentation, but didn't carry through on it.)

Nice chaining logic — consider avoiding in-place mutation of extras

I kind of prefer less object creation churn, but Julian's the one who will have a meaningful preference here, probably.

@jswalden
Copy link
Copy Markdown
Contributor Author

jswalden commented Apr 2, 2026

This will fix #4064

@jswalden jswalden changed the title feat: Allow actions to return result values, exposed to the subsequent sequential action as $(this:result). (#4064) feat: Allow actions to return result values, exposed to the subsequent sequential action as $(this:result) Apr 2, 2026
@makstech
Copy link
Copy Markdown
Contributor

makstech commented Apr 2, 2026

The idea is awesome and I 100% support it but my suggestion to your question

how to propagate a result to a subsequent concurrent action group that contains waits.

Why not use something like n8n does it: any node that's downstream, can access any upstream node's result using the node's name. For example, first node is called "Message", the next node can get the data directly with $json (in n8n) or $(this:result) with your implementation in Companion, but then next nodes or even the current one, can also get it with $('Message').item.json. This is an example that works elsewhere and is just an idea, but obviously completely out of scope for this PR.

@Julusian
Copy link
Copy Markdown
Member

Julusian commented Apr 2, 2026

I'm wondering if this should work such that for any action definition which says 'i can return a value' we let the user pick a local-variable name to write the value into.
move the state into the existing local variable system, instead of introducing a new flow for state that the user will need to understand behaves differently.

This would make it similar to how the generic-http module (and others?) do this today, but as they predate local-variables they can only write to custom variables. Perhaps it should be able to do both, but lets limit this to local to begin with.

I see pros and cons to each:

  • This will be more tolerant to multiple concurrent executions, using local variables will not
  • Local variables makes it obvious how things will flow around waits
  • Sometimes, the user will want to fire off multiple requests in parallel and operate on the result of all of them, local variables solves the id/reference problem
  • This is simpler to use in simple scenarios, no boilerplate of creating a local variable

$('Message').item.json.

I'm not opposed to something like this in the future, but I think what we use as the identifier needs some thought first. Something like this would be a start to addressing some other requests to reference/update properties of actions/feedbacks (some of which I am not sure is a good idea)

@jswalden jswalden force-pushed the action-callback-return-result branch from 21b0e38 to e646622 Compare April 2, 2026 21:28
@jswalden
Copy link
Copy Markdown
Contributor Author

jswalden commented Apr 2, 2026

Why not use something like n8n does it: any node that's downstream, can access any upstream node's result using the node's name. For example, first node is called "Message", the next node can get the data directly with $json (in n8n) or $(this:result) with your implementation in Companion, but then next nodes or even the current one, can also get it with $('Message').item.json.

My concern here is that an action that sets a result, should not have that result remain live for unbounded period of time. (At least not unless a subsequent user of it chooses to keep it live.) Tying results directly and only to a bounded set of sequentially-next actions does this. Whereas if you could refer backwards to previous actions, perhaps even dynamically, then every action's result has to be kept alive "just in case".

There are plainly a bunch of ways to skin this cat. "Use it immediately or lose it" doesn't seem like too complicated a way to grasp, to me.

@jswalden
Copy link
Copy Markdown
Contributor Author

jswalden commented Apr 2, 2026

I'm wondering if this should work such that for any action definition which says 'i can return a value' we let the user pick a local-variable name to write the value into. move the state into the existing local variable system, instead of introducing a new flow for state that the user will need to understand behaves differently.

That makes some idiomatic sense.

I see pros and cons to each:

  • This will be more tolerant to multiple concurrent executions, using local variables will not

Yeah. (If I were to grump on terminology, I'd name local variables as "per-entity variables", or better non-internal idiom terminology. And then maybe have some kind of truly local variable that can be created by an internal action, set from expression [or with this enhancement from an action result], that remains live only for the duration of the sequential action group it's found in. And would not have concurrency considerations to think about. But I digress.)

I guess the concurrency of local variables is my biggest quirk/beef here. Same as, but at least significantly reduced in troublesomeness, of a module variable, or of writing to a custom variable.

  • Local variables makes it obvious how things will flow around waits

I think you could arguably say that implicitly piping the result distributing it across a concurrent action group is...of uncertain intuitiveness, as mechanism to enable multiple distinct manners of consuming an action result. So possibly the root problem isn't really the waits.

(I don't fully understand why waits in concurrent action groups are even given special meaning. Why not just require users to create a sequential action group, then interleave waits between concurrent action groups? But I digress even harder...)

  • Sometimes, the user will want to fire off multiple requests in parallel and operate on the result of all of them, local variables solves the id/reference problem
  • This is simpler to use in simple scenarios, no boilerplate of creating a local variable

Forcing stuff to be named doesn't feel like boilerplate to me. What does feel like boilerplate, is having to create/name them out of the actual UI of the step flow being executed. And having that variable exist across all steps, rather than only narrowly in the exact set of actions the computation/initialization happens in.

@Julusian
Copy link
Copy Markdown
Member

Julusian commented Apr 4, 2026

I don't fully understand why waits in concurrent action groups are even given special meaning

it was done to make it easier to program these buttons. having a wait inside of a concurrent group otherwise makes no real sense, and this way we probably cut down the number of groups the user needs to add massively (perhaps even avoiding them altogether).

The thing to remember is that this was to replace the old behaviour where we never awaited actions, and it was possible to give each action an either relative or absolute start delay. That relative mode is what this is emulating.

And consider that adding a delay used to be simply typing in a number. Then it moved to an action and many users couldnt find it. Making them also add a group and drag their actions around to arrange them to get the same behaviour would likely not have been well received.

And would not have concurrency considerations to think about.

Another solution to concurrency issues would be to add a checkbox onto the 'user value' type, so that it ignores external updates while running the stack of actions.
Maybe that is worse for user complexity, but also maybe not.
Maybe this 'cached' mode should be the default, with an opt-out for the few scenarios that would need it (eg, a loop waiting for another button/trigger to change a variable and break the loop)

I think you could arguably say that implicitly piping the result distributing it across a concurrent action group is...of uncertain intuitiveness,

True, I didn't consider that. It for sure wont be able to work within the concurrent group, but the group could return a single value (from I guess the last?) to be used later? But this will make it hard to explain to users what the variable will refer to


I think my main concern here is just that I feel this will be a half-solution to concurrency issues over using local-variables. I expect that in many cases users will still want or need to write the result to a local-variable (even if just to make debugging/tracing the values easier).

For anything sensitive to concurrency, I do also wonder if our behaviour of allowing it to run in parallel is good enough. Not an argument against this, just that if we adopt a flow like home-assistant where they have modes:
image


So I am leaning towards saying that I think this needs to be done with local-variables, as this approach essentially does not work in concurrent groups.

For 4.3, I am open to including the ability to write action results to local-variables (but be aware that I had originally intended to release 4.3 by now, so that could happen any day now). And any work on better handling concurrency I would like to delay for 4.4, so it can have some time to be tested in the betas. Doesn't mean it can't be implemented now, it just won't be merged until 4.3 is finished

@jswalden
Copy link
Copy Markdown
Contributor Author

jswalden commented Apr 4, 2026

I think you could arguably say that implicitly piping the result distributing it across a concurrent action group is...of uncertain intuitiveness,

True, I didn't consider that. It for sure wont be able to work within the concurrent group, but the group could return a single value (from I guess the last?) to be used later? But this will make it hard to explain to users what the variable will refer to

It's entirely reasonable for a concurrent group's result to always be undefined, IMO. It's basically an internal action same as every other internal action in not returning a result.

(I need to tweak slightly so an empty sequential group results in undefined. Worth mentioning while it's on the mind.)

I think my main concern here is just that I feel this will be a half-solution to concurrency issues over using local-variables. I expect that in many cases users will still want or need to write the result to a local-variable (even if just to make debugging/tracing the values easier).

It mostly is a Companion-side decision how/where to sink result values, once the base/host side of it is adequately solved. Sink into something can be done as first step. Enabling sink into more stuff is deferrable.

So I am leaning towards saying that I think this needs to be done with local-variables, as this approach essentially does not work in concurrent groups.

Given the name "concurrent", nobody should expect bare act of yielding a result to be usable in them. Get a value, then set it somewhere, by nature incorporates a dependency that requires sequential operation.

For 4.3, I am open to including the ability to write action results to local-variables (but be aware that I had originally intended to release 4.3 by now, so that could happen any day now).

...short of there being some kind of breaking API change to make, and wanting it to be in before the 2.0 API actually ships, I don't think you should delay. I've been on the side of things you might want in a release, that nevertheless were rejected for one because not in time for it. Logrolling near end of a cycle can end badly, and always creates precedent for more delays in the future. It's rarely worth it for a bare enhancement. If this is not in time, so be it. There's always next time.

That said.

If this is a time where breaking API changes can be made still -- is it? is a hypothetical 2.0.1 or most pedantically 3.0.0 out of line now, before there has been a Companion release supporting it? -- if you changed the callback return type to undefined | Promise<undefined> now (and assuming this doesn't create linter warnings for code that uses unadorned return and falling off the end -- I'm not sure whether it does), then adding in | JsonValue in the future would not be a breaking change (for code that complies with the types, at least) requiring hasResult be added. (A hasResult that requires gnarly tricks to support only { hasResult: true; callback: (...) => JsonValue | Promise<JsonValue> } xor { hasResult?: false; callback: (...) => void | Promise<void> } -- tricks that mangle Typedoc output in ways that are hard to reduce.)

And any work on better handling concurrency I would like to delay for 4.4, so it can have some time to be tested in the betas. Doesn't mean it can't be implemented now, it just won't be merged until 4.3 is finished

I don't think an action that returns a result can ever be meaningfully usable (at least as to the result value; side effects are still usable, of course) inside a concurrent group.

And I will reiterate that I don't think you should hold anything up for this -- save that if we are still at a juncture where incompatibly changing the type that CompanionActionDefinition.callback returns is viable, we should change it from void to undefined.

@jswalden jswalden force-pushed the action-callback-return-result branch 2 times, most recently from b4c8e2d to 8bc3b8d Compare April 5, 2026 22:27
@jswalden jswalden changed the title feat: Allow actions to return result values, exposed to the subsequent sequential action as $(this:result) feat: Add an action that sequentially performs a series of actions, then sets a local variable to the result of the final action Apr 5, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 36662a95-8712-48ad-9268-a1d9a29fe0d9

📥 Commits

Reviewing files that changed from the base of the PR and between b4c8e2d and 8bc3b8d.

⛔ Files ignored due to path filters (1)
  • yarn.lock is excluded by !**/yarn.lock, !**/*.lock
📒 Files selected for processing (10)
  • companion/lib/Controls/ActionRunner.ts
  • companion/lib/Instance/Connection/ChildHandlerApi.ts
  • companion/lib/Instance/Connection/ChildHandlerLegacy.ts
  • companion/lib/Instance/Connection/ChildHandlerNew.ts
  • companion/lib/Instance/Connection/IpcTypesNew.ts
  • companion/lib/Instance/Connection/Thread/Entrypoint.ts
  • companion/lib/Internal/Controller.ts
  • companion/lib/Internal/Variables.ts
  • companion/lib/Variables/Values.ts
  • companion/package.json
✅ Files skipped from review due to trivial changes (2)
  • companion/lib/Internal/Controller.ts
  • companion/package.json
🚧 Files skipped from review as they are similar to previous changes (4)
  • companion/lib/Instance/Connection/ChildHandlerApi.ts
  • companion/lib/Variables/Values.ts
  • companion/lib/Instance/Connection/Thread/Entrypoint.ts
  • companion/lib/Instance/Connection/ChildHandlerNew.ts

Comment thread companion/lib/Controls/ActionRunner.ts Outdated
Comment thread companion/lib/Internal/Variables.ts Outdated
Comment thread companion/lib/Internal/Variables.ts Outdated
@jswalden jswalden force-pushed the action-callback-return-result branch from 8bc3b8d to 4fe31cf Compare April 6, 2026 00:22
@jswalden
Copy link
Copy Markdown
Contributor Author

jswalden commented Apr 6, 2026

Updated to add actions to set a local/custom variable from the result of a series of actions.

A series of actions is allowed mostly because I cribbed from logic_if which allows sequences. But I can also rationalize this as allowing actions/computations that culminate in the produced result, to be enclosed within the overall variable-set action. (If it were limited to one action, you could still do a series by using a sequential action group as the action.)

@jswalden jswalden force-pushed the action-callback-return-result branch from 4fe31cf to 07b8e3a Compare April 6, 2026 03:56
@jswalden
Copy link
Copy Markdown
Contributor Author

jswalden commented Apr 6, 2026

There's already maximumChildren: 1 to permit restricting to just one action. Made that change.

I think that's everything previously mooted here, so this seems ready to be taken for this release, or sat on til after release.

I'd still argue for the mini-type-fix of making callbacks return undefined (if breaking type changes are still in-bounds) if you delay on this. But I found ways to make the generated typedoc results of bitfocus/companion-module-base#217 tolerable, so forcing hasResult on module authors -- rather than us simply expanding the range of callback-returnable types from undefined to undefined | JsonValue, backwards-compatibly -- is a workable decision IMO.

@Julusian Julusian added this to the v4.4 milestone Apr 17, 2026
@github-project-automation github-project-automation Bot moved this to In Progress in Companion Plan Apr 17, 2026
@Julusian
Copy link
Copy Markdown
Member

Julusian commented May 2, 2026

I'm not keen on this requiring putting an action inside another action to be able to save the result.

I am worried about a combination of:

  • User discovery. How will I know that an action I have added can return a value that I might want to use? Users are used to having this option on the action (since before 3.0 for a few modules which use an experimental flow), and will be confused if it disappears with no indication of what to look for instead.
  • Usage UX. The less clicks the better (within reason), as long as we arent bloating ui. Especially as I imagine a common flow will be to add the action, then realise they want to save the value and have to rearrange things to achieve that

I would much rather that this added an extra field in the ui to any actions which support this, handled within companion. I think the closest we have to this today is the inverted field for boolean feedbacks.

So I am wondering about a couple of auto-added fields at the bottom of the action by the ui, when the action supports this:

  • Store result - a dropdown (disabled/local variable/custom variable)
  • Variable name - same style field as we use today for storing to variables? should have the expression toggle. This should only be visible when 'store result' is not disabled. (may want to be different depending on which of local/custom are selected)
  • Create if not exists - only be visible when 'store result' is not disabled. matches what we already have.

I think that by doing this we both solve the discovery issue with minimal ui bloat, matching a flow users are already used to (in generic-http and friends), and simplify usage by making it a couple of clicks to setup.

In either case the ui will need to be aware of which actions support this, so that it can stop the user adding an action to the Set to result of an action actions which dont support returning a value.


For the implementation of this, I wonder if we should have an 'internalOptions' object on each entity, for storing these values? This could be well typed too I expect. Ideally the feedback 'inverted' property could be moved into this too, but I would leave that for a follow up as that will be a bit fiddly to untangle.


As a follow up to this (for me to do), I will look into what we can do to auto-migrate some existing modules across to this instead of using the old setCustomVariable flow that they are using today. Either approach should be possible to fixup for the modules we know about (likely with comapnion assuming more knowledge on module actions than we do today, but I think that acceptable as its only a couple)

@jswalden
Copy link
Copy Markdown
Contributor Author

jswalden commented May 3, 2026

I would much rather that this added an extra field in the ui to any actions which support this, handled within companion. I think the closest we have to this today is the inverted field for boolean feedbacks.

So I am wondering about a couple of auto-added fields at the bottom of the action by the ui, when the action supports this:

* Store result - a dropdown (disabled/local variable/custom variable)

* Variable name - same style field as we use today for storing to variables? should have the expression toggle. This should only be visible when 'store result' is not disabled. (may want to be different depending on which of local/custom are selected)

* Create if not exists - only be visible when 'store result' is not disabled. matches what we already have.

This seems like solidly better UI than my approach.

In either case the ui will need to be aware of which actions support this, so that it can stop the user adding an action to the Set to result of an action actions which dont support returning a value.

The hasResult boolean in the action definition should be sufficient to know this, if I'm not underthinking it.

For the implementation of this, I wonder if we should have an 'internalOptions' object on each entity, for storing these values? This could be well typed too I expect. Ideally the feedback 'inverted' property could be moved into this too, but I would leave that for a follow up as that will be a bit fiddly to untangle.

On no more than two minutes' thought that seems plausible. Need to noodle a little more, not at EOD, more before being certain.

I'll try to get back on this in the next few days.

@Julusian
Copy link
Copy Markdown
Member

Julusian commented May 4, 2026

The hasResult boolean in the action definition should be sufficient to know this, if I'm not underthinking it.

yes it should be. defining that requirement in the generic-ness of the entity child lists requires a little bit more glue to handle. but hopefully not necessary now

I'll try to get back on this in the next few days.

no rush :)

@phillipivan
Copy link
Copy Markdown
Contributor

Just piping in here, based on my experience working with the value feedbacks, it would make life considerable easier for users if the current value or last value (if that makes more conceptual sense here) of the returned variable was brought to the UI along with a copy button for the variable name. Before these were added in 4.3 I was frequently having to drop local variables on buttons just to confirm values while configuring an action/feedback.

@jswalden jswalden force-pushed the action-callback-return-result branch from 07b8e3a to 52081a2 Compare May 13, 2026 08:25
@jswalden
Copy link
Copy Markdown
Contributor Author

Okay, for the most part this is all "ready" to go. At least in terms of it not being a waste to look at, if nothing else.

The extent of change has grown a fair bit from the "just add some actions that can do all the stuff" previous state. The final two commits here are not for committing, ignore them and/or realize they would be excluded. That done, we have:

59dda5a refactor: Extract local variable manipulation out of the InternalVariables module fragment into a standalone controller.

This is the bit about local-variable manipulation only being possible through the variables fragment now, and this creating a controller that ActionRunner can later in the stack use in addition to the fragment.

cc39980 refactor: Convert internal action handling-by-definitionId to use switch statements instead of if/else if/else`.
64abcba refactor: Add scaffolding for internal actions to return a result (that is for the moment ignored).

In order for internal actions to be able to return results -- none do now, but I feel like I ought be making it possible for them to, so when you eventually want such an action you don't have to do the plumbing yourself -- I change executeAction from returning true (handled) or false (not), to returning null (not handled) or { result: JsonValue | undefined } (perhaps behind a promise). IMO it is nice to avoid typing out { result: undefined } everywhere, so making all these use switch and then break to a single trailing return { result: undefined } is aesthetically pleasing. Feel free to disagree.

6b1de48 refactor: Tweak action-running code for simpler flow-through once actions return results.
825a322 refactor: Compute the result of an action when running it, then for the moment simply discard it.

This just gets the internal action executeAction and non-internal actionRunn flows into place where they're both returning a result value from the action -- which will be undefined if the action doesn't have a result. Then the result value gets visibly dropped, to be adjusted in subsequent patching.

5027407 refactor: Refactor isInverted expression evaluation/invalidation code into a system that allows additional kinds of special expressions to be handled.

EntityIsInvertedManager is all about tracking is-inverted expressions, because those expressions exist outside of action/feedback options where they would otherwise be automatically parsed/tracked.

What this PR aims to do, is allow for another field similar to isInverted?: ExpressionOrValue<boolean> in the entity model -- storeResultTarget?: StoreResultToLocalVariable | StoreResultToCustomVariable. These are { target: <discriminator-literal>; variableName: ExpressionOrValue<string> } combined with either { createIfNotExists: boolean } or { location: ExpressionOrValue<string> } depending whether custom or local.

It seems to me that the simplest/clearest way to do this, is to generalize is-inverted handling so that it's just one instance of "special expression", that exists outside of option values. So in this rev I do that -- defining a record of special expression name to expression value, which is initially just isInverted => boolean. A similarly structured object is added to the special-expressions manager, with contents of each key being special expression-specific handling functions (like to compute a new value of the special expression, be updated on these new values as updateIsInvertedValues does now). Then most functions in the manager just loop over Object.values(this.#specialExpressions) to do their thing.

Updating is still debounced acros all updates. Entities are tracked by the specific special expression at issue. Entities are forgotten across all special expressions at once -- but it would be easy to add a later optimization to forget only for specific cases.

5a82d4f refactor: Rename special-expressions files now that edits to them are done.

I didn't rename any files for the new class names in the previous rev. This renames them, separately so as not to break readable rev history.

dc388e3 feat: Add UI to actions that return a result, to permit storing that result in a local or custom variable.

This is the biggest meat of change. There is not a lot you could do to trim it down. I guess the webui changes could have been split into their own rev, and the actionrunner changes could be a separate rev, and and and.

I tried to simply copy pretty heavily the isInverted handling when devising similar-yet-different code for action result handling. It's totally possible I screwed up something somewhere in it, that I'm not aware of.

I added no tests for any of this, just worked through the UI manually. I can likely be guilted into adding tests, but this probably needs a skim before I discover I've gone astray somewhere that would invalidate any test-writing.

The names for "storeActionResultTarget", "storeResultTarget", etc. are under-thought-out and mostly just named to have a name to refer to at all. I did not have ideas I really liked on this. Maybe storeResultTo is my current idea, but you might have a better one.

I don't really understand the logic of ControllEntityInstance#subscribe, and the trackEntity I added there might be off-base in some way. Also the this.subscribe(false) in CEI#setStoreActionResultTarget maybe should pass in EntityModelType.Action? But is-inverted doesn't pass in feedback and I was copying.

Most of the scattered updateStoreActionResultTargetValues logic is copied. You would understand better what entity sources do not need recurring into to find all actions that must be updated.

I assume the reportChange argument in entitySetStoreActionResultTarget is correct but really have no idea.

This PR is not developed against main but against a version of it that's slightly older. When I rebase the PR onto main -- it does so without error -- trying to switch an action result target from local variable to <discard> triggers a tRPC error about the setStoreActionResultTarget mutation not processing argument right. It's fantastically bad luck, I think there's a chance that the update you did a few days back to zod (4.4.2->4.4.3) caused my definition of zodStoreActionResultTarget to trigger errors. Or I just don't understand what I'm doing enough. But somehow this PR works all fine, but a rebased version of it onto main does not.

My React skills are meager, and I almost can't believe my useRef usage actually worked exactly as I meant it to the first time I got all the code to compile. The webui changes could easily have things wrong in them that I just don't know I did wrong!

Oh, and the final patch that actually makes webui changes, that depends on #217 landing and an updated @companion-module/host to be available to rely on. But all changes before that patch, do not require any changes to @companion-module/host (or base).

I think that's a summary of everything here that is worth flagging. There are things I could do to split up that final patch (e.g. move the action option-commoning into shared-lib into a separate rev), but those aren't really the parts of it that are questionable.

@jswalden jswalden force-pushed the action-callback-return-result branch from 52081a2 to 1bfc4dc Compare May 14, 2026 05:56
@jswalden
Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 14, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
companion/test/Controls/Entities/EntitySpecialExpressionManager.test.ts (1)

1-679: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Please run Prettier on this test file before merge.

CI is already failing format checks for this file, so this will block merge until fixed.

companion/lib/Controls/Entities/EntityInstance.ts (1)

691-706: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

replaceProps doesn’t propagate action storeResult, so replacements can keep stale target state.

With the new store-result feature, replacing an action should also update the underlying action model’s storeResult.

Suggested fix
 	replaceProps(newProps: SomeReplaceableEntityModel, skipNotifyModule = false): void {
 		this.#data.definitionId = newProps.definitionId
 		this.#data.options = newProps.options
 		this.#data.upgradeIndex = newProps.upgradeIndex
 
 		if (this.#data.type === EntityModelType.Feedback) {
 			const feedbackData = this.#data as FeedbackEntityModel
 			const newPropsData = newProps as FeedbackEntityModel
 			feedbackData.isInverted = newPropsData.isInverted ?? feedbackData.isInverted
@@
 					: feedbackData.styleOverrides
+		} else if (this.#data.type === EntityModelType.Action) {
+			const actionData = this.#data as ActionEntityModel
+			const newActionProps = newProps as ActionEntityModel
+			actionData.storeResult = newActionProps.storeResult
 		}
🧹 Nitpick comments (2)
companion/lib/Variables/LocalVariablesController.ts (1)

38-54: 💤 Low value

Consider adding a safety check for entity type.

The #getControlAndVariable helper assumes that any entity with a matching rawLocalVariableName is the correct target. While the isInternalUserValueFeedback check on line 51 provides type safety, it might be clearer to filter for the expected entity type earlier in the lookup chain (line 45) to avoid potential confusion if multiple entity types could theoretically have the same rawLocalVariableName.

That said, if the current architecture guarantees unique rawLocalVariableName values across all entity types on a control, this is fine as-is! Just wanted to flag it in case there are edge cases I'm not aware of.

companion/lib/Controls/Entities/EntityInstance.ts (1)

329-334: ⚡ Quick win

Consider tracking isInverted only for feedback entities.

Right now actions are also queued for isInverted processing, which is unnecessary work and noise in update batches.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: adc205f2-d82e-4dc9-bc27-33bae41e2705

📥 Commits

Reviewing files that changed from the base of the PR and between 8bc3b8d and 1bfc4dc.

📒 Files selected for processing (34)
  • companion/lib/Controls/ActionRunner.ts
  • companion/lib/Controls/Entities/EntityInstance.ts
  • companion/lib/Controls/Entities/EntityIsInvertedManager.ts
  • companion/lib/Controls/Entities/EntityList.ts
  • companion/lib/Controls/Entities/EntityListPoolBase.ts
  • companion/lib/Controls/Entities/EntityListPoolButton.ts
  • companion/lib/Controls/Entities/EntityListPoolExpressionVariable.ts
  • companion/lib/Controls/Entities/EntityListPoolTrigger.ts
  • companion/lib/Controls/Entities/EntitySpecialExpressionManager.ts
  • companion/lib/Controls/Entities/SpecialExpressions.ts
  • companion/lib/Controls/Entities/Types.ts
  • companion/lib/Controls/EntitiesTrpcRouter.ts
  • companion/lib/Instance/Connection/ChildHandlerApi.ts
  • companion/lib/Instance/Connection/ChildHandlerLegacy.ts
  • companion/lib/Instance/Connection/ChildHandlerNew.ts
  • companion/lib/Instance/Connection/IpcTypesNew.ts
  • companion/lib/Instance/Connection/Thread/Entrypoint.ts
  • companion/lib/Instance/Connection/Thread/HostContext.ts
  • companion/lib/Internal/ActionRecorder.ts
  • companion/lib/Internal/BuildingBlocks.ts
  • companion/lib/Internal/Controller.ts
  • companion/lib/Internal/Controls.ts
  • companion/lib/Internal/CustomVariables.ts
  • companion/lib/Internal/Instance.ts
  • companion/lib/Internal/Surface.ts
  • companion/lib/Internal/System.ts
  • companion/lib/Internal/Triggers.ts
  • companion/lib/Internal/Types.ts
  • companion/lib/Internal/Util.ts
  • companion/lib/Internal/Variables.ts
  • companion/lib/Registry.ts
  • companion/lib/Variables/LocalVariablesController.ts
  • companion/test/Controls/Entities/EntityList.test.ts
  • companion/test/Controls/Entities/EntitySpecialExpressionManager.test.ts
💤 Files with no reviewable changes (2)
  • companion/lib/Internal/Util.ts
  • companion/lib/Controls/Entities/EntityIsInvertedManager.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • companion/lib/Instance/Connection/ChildHandlerApi.ts
  • companion/lib/Instance/Connection/ChildHandlerLegacy.ts

Comment thread companion/lib/Instance/Connection/Thread/HostContext.ts Outdated
Comment thread companion/lib/Internal/BuildingBlocks.ts
@jswalden jswalden force-pushed the action-callback-return-result branch 2 times, most recently from 8ff7b69 to 1aa7033 Compare May 14, 2026 21:21
@jswalden jswalden changed the title feat: Add an action that sequentially performs a series of actions, then sets a local variable to the result of the final action feat: Add UI to actions whose definitions indicate they return a result, to optionally store that result in a local or custom variable May 14, 2026
Copy link
Copy Markdown
Member

@Julusian Julusian left a comment

Choose a reason for hiding this comment

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

Sorry for taking so long to properly look at this.
It looks good!

I have a handful of small niggles, but nothing major

Comment thread shared-lib/lib/Model/EntityDefinitionModel.ts Outdated
Comment thread webui/src/Controls/Components/EntityCommonCells.tsx Outdated
Comment thread webui/src/Services/Controls/ControlActionsService.ts Outdated
Comment thread companion/lib/Instance/Connection/Thread/Entrypoint.ts Outdated
Comment thread companion/lib/Controls/Entities/Types.ts
Comment thread companion/lib/Controls/Entities/EntityListPoolTrigger.ts Outdated
Comment thread companion/lib/Controls/Entities/EntityInstance.ts Outdated
Comment thread companion/lib/Controls/Entities/EntityInstance.ts Outdated
Comment thread companion/lib/Controls/Entities/EntityListPoolBase.ts Outdated
@Julusian
Copy link
Copy Markdown
Member

@jswalden I have updated the module-api after merging your pr there, so there are a couple of small merge conflicts here now. This does mean that once you have resolved it the tests and types should be fully happy though

@jswalden jswalden force-pushed the action-callback-return-result branch from 1aa7033 to d2b50d1 Compare May 25, 2026 05:47
@jswalden
Copy link
Copy Markdown
Contributor Author

jswalden commented May 25, 2026

Okay, responded to all review comments...plus a touch more. Running through the notable adjustments:

New special expression values can be computed in standalone functions

After the generalize-isInverted-handling patch, I added:

  • refactor: Move #computeIsInverted into a standalone function, as on second look it doesn't use this.

for the reason in the summary. I did this because it seemed like the change ought be in the initial refactor, not tacked atop handling for storeResult as well. Unfortunately this meant rebasing it through the addition of storeResult handling -- so the original "Add UI to actions that return a result" necessarily had to address those conflicts, and not be fixed in a clear followup patch.

Don't track storeResult for disabled actions

I noticed that isInverted tracking doesn't happen for disabled feedbacks, so I adjusted the code path so the same is done for storeResult.

(It would be really nice if we could recognize special expressions that contain no variables and simply never track them, to keep the entity maps minimally sized given that most feedbacks don't have isInverted and most actions don't have storeResult, even without any expressions/parseVariables strings in them. This seems like potential followup material.)

Changes made that debatably could use a quick skim

I addressed the ClientEntityDefinition.actionHasResult in a separate rev because of the slightly further changes required there. Same for making IEntityEditorService.setRawStoreResult also allow undefined. The bulk of other review comments were menial so I dumped them in the "chore: Respond to some review comments." rev.

storeResult tests

I did in fact add a boatload of storeResult tests. Perfectly possible some of it could be more cleanly done, but I personally have always tried to tolerate a little more code fugly in testing code because it, unlike actual functional code, in principle is ignorable as long as it passes, and polishing it is IMO not an ideal tradeoff in use of time.

I recommend squashing the "move compute-isInverted code to a standalone function" into its preceding rev, and I recommend squashing everything from "Add UI to actions that return a result" to end into one patch. The splitting is intended to be helpful for reviewing of incremental modifications, but I don't think it helps future readers to see the intermediate states directly.

@Julusian Julusian force-pushed the action-callback-return-result branch from cc0f570 to e4d9367 Compare May 26, 2026 17:43
@Julusian Julusian merged commit cbd64da into bitfocus:main May 26, 2026
8 checks passed
@github-project-automation github-project-automation Bot moved this from In Progress to Done in Companion Plan May 26, 2026
@jswalden jswalden deleted the action-callback-return-result branch May 26, 2026 18:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

4 participants