Skip to content

Commit ca9cfca

Browse files
committed
feat(appkit): plugin infrastructure — attachContext lifecycle + PluginContext mediator
Third layer: the substrate every downstream PR relies on. No user- facing API changes here; the surface for this PR is the mediator pattern, lifecycle semantics, and factory stamping. ### Split Plugin construction from context binding `Plugin` constructors become pure — no `CacheManager.getInstanceSync()`, no `TelemetryManager.getProvider()`, no `PluginContext` wiring inside `constructor()`. That work moves to a new lifecycle method: ```ts interface BasePlugin { attachContext?(deps: { context?: unknown; telemetryConfig?: TelemetryOptions; }): void; } ``` `createApp` calls `attachContext()` on every plugin after all constructors have run, before `setup()`. This lets factories return `PluginData` tuples at module scope without pulling core services into the import graph — a prerequisite for later PRs that construct agent definitions before `createApp`. ### PluginContext mediator `packages/appkit/src/core/plugin-context.ts` — new class that mediates all inter-plugin communication: - **Route buffering**: `addRoute()` / `addMiddleware()` buffer until the server plugin calls `registerAsRouteTarget()`, then flush via `addExtension()`. Eliminates plugin-ordering fragility. - **ToolProvider registry**: `registerToolProvider(name, plugin)` + live `getToolProviders()`. Typed discovery of tool-exposing plugins. - **User-scoped tool execution**: `executeTool(req, pluginName, localName, args, signal?)` resolves the provider, wraps in `asUser(req)` for OBO, opens a telemetry span, applies a 30s timeout, dispatches, returns. - **Lifecycle hooks**: `onLifecycle('setup:complete' | 'server:ready' | 'shutdown', cb)` + `emitLifecycle(event)`. Callback errors don't block siblings. ### `toPlugin` stamps `pluginName` `packages/appkit/src/plugin/to-plugin.ts` — the factory now attaches a read-only `pluginName` property to the returned function. Later PRs' `fromPlugin(factory)` reads it to identify which plugin a factory refers to without needing to construct an instance. `NamedPluginFactory` type exported for consumers who want to type-constrain factories. ### Server plugin defers start to `setup:complete` `ServerPlugin.setup()` no longer calls `extendRoutes()` synchronously. It subscribes to the `setup:complete` lifecycle event via `PluginContext` and starts the HTTP server there. This ensures that any deferred-phase plugin (agents plugin in a later PR) has had a chance to register routes via `PluginContext.addRoute()` before the server binds. Removes the `plugins` field from `ServerConfig` (routes are now discovered via the context, not a config snapshot). ### Test plan - 25 new PluginContext tests (route buffering, tool provider registry, executeTool paths, lifecycle hooks, plugin metadata) - Updated AppKit lifecycle tests to inject `context` instead of `plugins` - Full appkit vitest suite: 1237 tests passing - Typecheck clean across all 8 workspace projects Signed-off-by: MarioCadenas <MarioCadenas@users.noreply.github.com>
1 parent 7077eb0 commit ca9cfca

11 files changed

Lines changed: 799 additions & 39 deletions

File tree

packages/appkit/src/core/appkit.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,18 @@ import { ServiceContext } from "../context";
1313
import { ResourceRegistry, ResourceType } from "../registry";
1414
import type { TelemetryConfig } from "../telemetry";
1515
import { TelemetryManager } from "../telemetry";
16+
import { isToolProvider, PluginContext } from "./plugin-context";
1617

1718
export class AppKit<TPlugins extends InputPluginMap> {
1819
#pluginInstances: Record<string, BasePlugin> = {};
1920
#setupPromises: Promise<void>[] = [];
21+
#context: PluginContext;
2022

2123
private constructor(config: { plugins: TPlugins }) {
2224
const { plugins, ...globalConfig } = config;
2325

26+
this.#context = new PluginContext();
27+
2428
const pluginEntries = Object.entries(plugins);
2529

2630
const corePlugins = pluginEntries.filter(([_, p]) => {
@@ -35,20 +39,24 @@ export class AppKit<TPlugins extends InputPluginMap> {
3539

3640
for (const [name, pluginData] of corePlugins) {
3741
if (pluginData) {
38-
this.createAndRegisterPlugin(globalConfig, name, pluginData);
42+
this.createAndRegisterPlugin(globalConfig, name, pluginData, {
43+
context: this.#context,
44+
});
3945
}
4046
}
4147

4248
for (const [name, pluginData] of normalPlugins) {
4349
if (pluginData) {
44-
this.createAndRegisterPlugin(globalConfig, name, pluginData);
50+
this.createAndRegisterPlugin(globalConfig, name, pluginData, {
51+
context: this.#context,
52+
});
4553
}
4654
}
4755

4856
for (const [name, pluginData] of deferredPlugins) {
4957
if (pluginData) {
5058
this.createAndRegisterPlugin(globalConfig, name, pluginData, {
51-
plugins: this.#pluginInstances,
59+
context: this.#context,
5260
});
5361
}
5462
}
@@ -70,8 +78,20 @@ export class AppKit<TPlugins extends InputPluginMap> {
7078
};
7179
const pluginInstance = new Plugin(baseConfig);
7280

81+
if (typeof pluginInstance.attachContext === "function") {
82+
pluginInstance.attachContext({
83+
context: this.#context,
84+
telemetryConfig: baseConfig.telemetry,
85+
});
86+
}
87+
7388
this.#pluginInstances[name] = pluginInstance;
7489

90+
this.#context.registerPlugin(name, pluginInstance);
91+
if (isToolProvider(pluginInstance)) {
92+
this.#context.registerToolProvider(name, pluginInstance);
93+
}
94+
7595
this.#setupPromises.push(pluginInstance.setup());
7696

7797
const self = this;
@@ -199,6 +219,7 @@ export class AppKit<TPlugins extends InputPluginMap> {
199219
const instance = new AppKit(mergedConfig);
200220

201221
await Promise.all(instance.#setupPromises);
222+
await instance.#context.emitLifecycle("setup:complete");
202223

203224
return instance as unknown as PluginMap<T>;
204225
}
Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
import type express from "express";
2+
import type { BasePlugin, ToolProvider } from "shared";
3+
import { createLogger } from "../logging/logger";
4+
import { TelemetryManager } from "../telemetry";
5+
6+
const logger = createLogger("plugin-context");
7+
8+
interface BufferedRoute {
9+
method: string;
10+
path: string;
11+
handlers: express.RequestHandler[];
12+
}
13+
14+
interface RouteTarget {
15+
addExtension(fn: (app: express.Application) => void): void;
16+
}
17+
18+
interface ToolProviderEntry {
19+
plugin: BasePlugin & ToolProvider;
20+
name: string;
21+
}
22+
23+
type LifecycleEvent = "setup:complete" | "server:ready" | "shutdown";
24+
25+
/**
26+
* Mediator for inter-plugin communication.
27+
*
28+
* Created by AppKit core and passed to every plugin. Plugins request
29+
* capabilities from the context instead of holding direct references
30+
* to sibling plugin instances.
31+
*
32+
* Capabilities:
33+
* - Route mounting with buffering (order-independent)
34+
* - Typed ToolProvider registry (live, not snapshot-based)
35+
* - User-scoped tool execution with automatic telemetry
36+
* - Lifecycle hooks for plugin coordination
37+
*/
38+
export class PluginContext {
39+
private routeBuffer: BufferedRoute[] = [];
40+
private routeTarget: RouteTarget | null = null;
41+
private toolProviders = new Map<string, ToolProviderEntry>();
42+
private plugins = new Map<string, BasePlugin>();
43+
private lifecycleHooks = new Map<
44+
LifecycleEvent,
45+
Set<() => void | Promise<void>>
46+
>();
47+
private telemetry = TelemetryManager.getProvider("plugin-context");
48+
49+
/**
50+
* Register a route on the root Express application.
51+
*
52+
* If a route target (server plugin) has registered, the route is applied
53+
* immediately. Otherwise it is buffered and flushed when a route target
54+
* becomes available.
55+
*/
56+
addRoute(
57+
method: string,
58+
path: string,
59+
...handlers: express.RequestHandler[]
60+
): void {
61+
if (this.routeTarget) {
62+
this.applyRoute({ method, path, handlers });
63+
} else {
64+
this.routeBuffer.push({ method, path, handlers });
65+
}
66+
}
67+
68+
/**
69+
* Register middleware on the root Express application.
70+
*
71+
* Same buffering semantics as `addRoute`.
72+
*/
73+
addMiddleware(path: string, ...handlers: express.RequestHandler[]): void {
74+
if (this.routeTarget) {
75+
this.applyMiddleware(path, handlers);
76+
} else {
77+
this.routeBuffer.push({ method: "use", path, handlers });
78+
}
79+
}
80+
81+
/**
82+
* Called by the server plugin to opt in as the route target.
83+
* Flushes all buffered routes via the server's `addExtension`.
84+
*/
85+
registerAsRouteTarget(target: RouteTarget): void {
86+
this.routeTarget = target;
87+
88+
for (const route of this.routeBuffer) {
89+
if (route.method === "use") {
90+
this.applyMiddleware(route.path, route.handlers);
91+
} else {
92+
this.applyRoute(route);
93+
}
94+
}
95+
this.routeBuffer = [];
96+
}
97+
98+
/**
99+
* Register a plugin that implements the ToolProvider interface.
100+
* Called by AppKit core after constructing each plugin.
101+
*/
102+
registerToolProvider(name: string, plugin: BasePlugin & ToolProvider): void {
103+
this.toolProviders.set(name, { plugin, name });
104+
}
105+
106+
/**
107+
* Register a plugin instance.
108+
* Called by AppKit core after constructing each plugin.
109+
*/
110+
registerPlugin(name: string, instance: BasePlugin): void {
111+
this.plugins.set(name, instance);
112+
}
113+
114+
/**
115+
* Returns all registered plugin instances keyed by name.
116+
* Used by the server plugin for route injection, client config,
117+
* and shutdown coordination.
118+
*/
119+
getPlugins(): Map<string, BasePlugin> {
120+
return this.plugins;
121+
}
122+
123+
/**
124+
* Returns all registered ToolProvider plugins.
125+
* Always returns the current set — not a frozen snapshot.
126+
*/
127+
getToolProviders(): Array<{ name: string; provider: ToolProvider }> {
128+
return Array.from(this.toolProviders.values()).map((entry) => ({
129+
name: entry.name,
130+
provider: entry.plugin,
131+
}));
132+
}
133+
134+
/**
135+
* Execute a tool on a ToolProvider plugin with automatic user scoping
136+
* and telemetry.
137+
*
138+
* The context:
139+
* 1. Resolves the plugin by name
140+
* 2. Calls `asUser(req)` for user-scoped execution
141+
* 3. Wraps the call in a telemetry span with a 30s timeout
142+
*/
143+
async executeTool(
144+
req: express.Request,
145+
pluginName: string,
146+
toolName: string,
147+
args: unknown,
148+
signal?: AbortSignal,
149+
): Promise<unknown> {
150+
const entry = this.toolProviders.get(pluginName);
151+
if (!entry) {
152+
throw new Error(
153+
`PluginContext: unknown plugin "${pluginName}". Available: ${Array.from(this.toolProviders.keys()).join(", ")}`,
154+
);
155+
}
156+
157+
const tracer = this.telemetry.getTracer();
158+
const operationName = `executeTool:${pluginName}.${toolName}`;
159+
160+
return tracer.startActiveSpan(operationName, async (span) => {
161+
const timeout = 30_000;
162+
const timeoutSignal = AbortSignal.timeout(timeout);
163+
const combinedSignal = signal
164+
? AbortSignal.any([signal, timeoutSignal])
165+
: timeoutSignal;
166+
167+
try {
168+
const userPlugin = (entry.plugin as any).asUser(req);
169+
const result = await (userPlugin as ToolProvider).executeAgentTool(
170+
toolName,
171+
args,
172+
combinedSignal,
173+
);
174+
span.setStatus({ code: 0 });
175+
return result;
176+
} catch (error) {
177+
span.setStatus({
178+
code: 2,
179+
message:
180+
error instanceof Error ? error.message : "Tool execution failed",
181+
});
182+
span.recordException(
183+
error instanceof Error ? error : new Error(String(error)),
184+
);
185+
throw error;
186+
} finally {
187+
span.end();
188+
}
189+
});
190+
}
191+
192+
/**
193+
* Register a lifecycle hook callback.
194+
*/
195+
onLifecycle(event: LifecycleEvent, fn: () => void | Promise<void>): void {
196+
let hooks = this.lifecycleHooks.get(event);
197+
if (!hooks) {
198+
hooks = new Set();
199+
this.lifecycleHooks.set(event, hooks);
200+
}
201+
hooks.add(fn);
202+
}
203+
204+
/**
205+
* Emit a lifecycle event, calling all registered callbacks.
206+
* Errors in individual callbacks are logged but do not prevent
207+
* other callbacks from running.
208+
*
209+
* @internal Called by AppKit core only.
210+
*/
211+
async emitLifecycle(event: LifecycleEvent): Promise<void> {
212+
const hooks = this.lifecycleHooks.get(event);
213+
if (!hooks) return;
214+
215+
if (
216+
event === "setup:complete" &&
217+
this.routeBuffer.length > 0 &&
218+
!this.routeTarget
219+
) {
220+
logger.warn(
221+
"%d buffered routes were never applied — no server plugin registered as route target",
222+
this.routeBuffer.length,
223+
);
224+
}
225+
226+
for (const fn of hooks) {
227+
try {
228+
await fn();
229+
} catch (error) {
230+
logger.error("Lifecycle hook '%s' failed: %O", event, error);
231+
}
232+
}
233+
}
234+
235+
/**
236+
* Returns all registered plugin names.
237+
*/
238+
getPluginNames(): string[] {
239+
return Array.from(this.plugins.keys());
240+
}
241+
242+
/**
243+
* Check if a plugin with the given name is registered.
244+
*/
245+
hasPlugin(name: string): boolean {
246+
return this.plugins.has(name);
247+
}
248+
249+
private applyRoute(route: BufferedRoute): void {
250+
if (!this.routeTarget) return;
251+
this.routeTarget.addExtension((app) => {
252+
const method = route.method.toLowerCase() as keyof express.Application;
253+
if (typeof app[method] === "function") {
254+
(app[method] as (...a: unknown[]) => void)(
255+
route.path,
256+
...route.handlers,
257+
);
258+
}
259+
});
260+
}
261+
262+
private applyMiddleware(
263+
path: string,
264+
handlers: express.RequestHandler[],
265+
): void {
266+
if (!this.routeTarget) return;
267+
this.routeTarget.addExtension((app) => {
268+
app.use(path, ...handlers);
269+
});
270+
}
271+
}
272+
273+
/**
274+
* Type guard: checks whether a plugin implements the ToolProvider interface.
275+
*/
276+
export function isToolProvider(
277+
plugin: unknown,
278+
): plugin is BasePlugin & ToolProvider {
279+
return (
280+
typeof plugin === "object" &&
281+
plugin !== null &&
282+
"getAgentTools" in plugin &&
283+
typeof (plugin as ToolProvider).getAgentTools === "function" &&
284+
"executeAgentTool" in plugin &&
285+
typeof (plugin as ToolProvider).executeAgentTool === "function"
286+
);
287+
}

0 commit comments

Comments
 (0)