Repository navigation
Refuse a different Activity under an AiSdkPlugin Activity name (breaking) #162
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,92 @@ | ||
| /** | ||
| * Lets the plugin refuse an Activity name that is already taken. The SDK's | ||
| * plugin merge (`{...existing, ...param}`) keeps the last Activity of each | ||
| * name, so without this check a plugin Activity silently replaces a different | ||
| * Activity with the same name, or a later plugin silently replaces it. @internal | ||
| */ | ||
| import type { Worker } from '@temporalio/worker'; | ||
|
|
||
| /** | ||
| * What each plugin Activity is: the name it was built under (its operation) | ||
| * followed by what it depends on. Two Activities with the same operation and | ||
| * the same dependencies do the same thing, so one may replace the other. | ||
| */ | ||
| const activitySources = new WeakMap<object, readonly unknown[]>(); | ||
|
|
||
| /** | ||
| * Records, for every Activity in `activities`, its own key and `sources`, and | ||
| * returns `activities`. The key keeps one operation, registered under another | ||
| * operation's name, from passing as that operation. | ||
| */ | ||
| export function withSources<T extends object>(activities: T, sources: readonly unknown[]): T { | ||
| for (const [operation, fn] of Object.entries(activities)) activitySources.set(fn, [operation, ...sources]); | ||
| return activities; | ||
| } | ||
|
|
||
| /** | ||
| * Throws if an Activity in `added` would replace a different Activity with the | ||
| * same name in `existing`. The same function, or one built for the same | ||
| * operation from the same sources, may replace itself: that is the same plugin | ||
| * instance, or one with the same options, registered twice (e.g. on the | ||
| * NativeConnection and the Worker). | ||
| */ | ||
| export function refuseReplacedActivities(plugin: string, existing: object | undefined, added: object): void { | ||
| // What the Worker registers: own enumerable keys with function values. | ||
| const registered = new Map(Object.entries(existing ?? {}).filter(([, fn]) => typeof fn === 'function')); | ||
| for (const [name, fn] of Object.entries(added)) { | ||
| const other = registered.get(name); | ||
| if (other === undefined || other === fn || sameSources(other, fn)) continue; | ||
| throw new TypeError( | ||
| `Duplicate registration of Activity '${name}': ${plugin} would replace a different Activity with this name. ` + | ||
| remedy(plugin) | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * For `runWorker`, which runs after every plugin's `configureWorker`: if a | ||
| * plugin applied after this one replaced an Activity in `added` with a | ||
| * different one, returns a promise that rejects with a `TypeError`. Before | ||
| * rejecting, it stops the Worker so that it never polls, and lets `next` | ||
| * release it as `run()` does, so `connection.close()` still works. | ||
| * | ||
| * Otherwise it returns `undefined` without yielding, so the caller can start | ||
| * the Worker in the same tick. `Worker.runUntil` calls its callback right | ||
| * after `run()` and stops the Worker afterwards only if it is already running; | ||
| * a Worker started later polls until something else stops it. | ||
| */ | ||
| export function refuseLaterReplacement( | ||
| plugin: string, | ||
| worker: Worker, | ||
| added: object, | ||
| next: (worker: Worker) => Promise<void> | ||
| ): Promise<never> | undefined { | ||
| for (const [name, fn] of Object.entries(added)) { | ||
| const kept = worker.options.activities.get(name); | ||
| if (kept === undefined || kept === fn || sameSources(kept, fn)) continue; | ||
| const error = new TypeError( | ||
| `Duplicate registration of Activity '${name}': a plugin applied after ${plugin} replaced its Activity with this name. ` + | ||
| remedy(plugin) | ||
| ); | ||
| // There is no public way to stop a Worker that has not run yet. | ||
| (worker as unknown as { nativeWorker: { initiateShutdown(): void } }).nativeWorker.initiateShutdown(); | ||
| const fail = (): never => { | ||
| throw error; | ||
| }; | ||
| return next(worker).then(fail, fail); | ||
| } | ||
| return undefined; | ||
| } | ||
|
|
||
| function remedy(plugin: string): string { | ||
| return ( | ||
| 'Rename or remove the other Activity, run the other integration on its own Worker and task queue, ' + | ||
| `or use one ${plugin} instance on this Worker.` | ||
| ); | ||
| } | ||
|
|
||
| function sameSources(a: object, b: object): boolean { | ||
| const x = activitySources.get(a); | ||
| const y = activitySources.get(b); | ||
| return x !== undefined && y !== undefined && x.length === y.length && x.every((v, i) => v === y[i]); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,8 +1,9 @@ | ||
| import type { ProviderV4 } from '@ai-sdk/provider'; | ||
| import { SimplePlugin } from '@temporalio/plugin'; | ||
| import type { BundleOptions } from '@temporalio/worker'; | ||
| import type { BundleOptions, Worker, WorkerOptions } from '@temporalio/worker'; | ||
| import type { Duration } from '@temporalio/common/lib/time'; | ||
| import { createActivities, _evictMcpConnectionsForServer } from './activities'; | ||
| import { refuseLaterReplacement, refuseReplacedActivities } from './activity-sources'; | ||
| import type { McpClientFactories } from './mcp'; | ||
|
|
||
| /** | ||
|
|
@@ -60,6 +61,35 @@ export class AiSdkPlugin extends SimplePlugin { | |
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Adds the model and MCP Activities to the Worker. | ||
| * | ||
| * @throws TypeError if the Worker already has a different Activity with one | ||
| * of these names: a caller Activity, another integration's Activity, or an | ||
| * `AiSdkPlugin` with a different `modelProvider`, MCP client factory or | ||
| * `mcpConnectionIdleTimeout`. | ||
| */ | ||
| override configureWorker(options: WorkerOptions): WorkerOptions { | ||
| refuseReplacedActivities('AiSdkPlugin', options.activities, this.options.activities as object); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2 (non-blocking): the check only sees Activities registered before this plugin, so a plugin configured after
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added the Before throwing, it stops the native Worker and lets New test:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Follow-up in 3f769db: the first version of this check awaited before starting the Worker, even when nothing was replaced. |
||
| return super.configureWorker(options); | ||
| } | ||
|
|
||
| /** | ||
| * Runs the Worker. | ||
| * | ||
| * @throws TypeError, before the Worker polls, if a plugin applied after this | ||
| * one replaced one of its Activities with a different one. `Worker.create` | ||
| * applies the Worker's plugins in order, then the `NativeConnection`'s. | ||
| */ | ||
| override runWorker(worker: Worker, next: (worker: Worker) => Promise<void>): Promise<void> { | ||
| // Not async: when the check passes, the Worker starts in this tick, as | ||
| // `Worker.runUntil` needs (see `refuseLaterReplacement`). | ||
| return ( | ||
| refuseLaterReplacement('AiSdkPlugin', worker, this.options.activities as object, next) ?? | ||
| super.runWorker(worker, next) | ||
| ); | ||
| } | ||
|
|
||
| override configureBundler(options: BundleOptions): BundleOptions { | ||
| // Prepend a polyfill-installer module to the webpack `entry` array so it | ||
| // evaluates before any other module in the Workflow bundle. Webpack 5 | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P3 (non-blocking): 'Two equal object literals are refused' says the opposite of what the code does in the common case. Equal option literals pass whenever they reference the same provider and factory objects. The PR's own test accepts
new AiSdkPlugin({ modelProvider: provider, mcpClientFactories: { files } })written out twice (test-ai-sdk.ts:994, 'the same options twice'). I chainedconfigureWorkerthe wayWorker.createdoes. Two{ modelProvider: openai }literals (the@ai-sdk/openaisingleton) are accepted, and so are two{ modelProvider: provider, mcpClientFactories: { files } }literals. What is refused is a provider or factory created once per plugin:createOpenAI({ apiKey: 'k' })in each literal fails oninvokeModel, and an inlinefiles: async () => ...in each fails onfiles-listTools. Also, 'for each MCP server name' reads as if both instances must register the same servers. In fact two instances with the same provider and disjoint servers ({ a }and{ b }) are accepted, and the Worker gets both servers' Activities. Suggested: 'TwoAiSdkPlugininstances count as the same only when they share themodelProviderobject and, for each MCP server name both register, the same factory function and idle timeout. A provider or factory created separately for each instance is refused even when its settings are equal, for examplecreateOpenAI({ apiKey })called once per plugin, or anasync () => ...factory written inline in each options object.' The sentence matches #202's README. There it holds for themodelsmap, becauseStrandsPlugincompares that object itself.