Add managed local plugin lifecycle (#3222)
* feat(plugins): add local plugin surfaces and RPCs Keep plugin execution scoped to its daemon while allowing one local TypeScript entry point to contribute validated RPCs and native client surfaces. Paseo owns host selection, navigation chrome, and per-installation query caches. * feat(plugins): add Linear attachment source * fix(nix): refresh npm dependency hash * test(server): exclude internal sessions from Hub counts * test(server): distinguish Hub execution sessions * feat(plugins): make local plugins explicitly opt-in Keep plugin loading dormant until configuration enables it, and collect unsupported examples under one directory. * refactor(plugins): establish host boundaries * fix(nix): refresh plugin dependency hash * fix(plugins): complete packaging and startup rollback * feat(plugins): add managed local lifecycle Make trusted local plugins recoverable and authorable through daemon-owned lifecycle operations, host settings, and a capability-gated CLI. Reloads fully stop the previous plugin before starting new code, while generated projects provide strict TSX checking without changing runtime dependency ownership. * fix(nix): refresh plugin dependency hash
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const PluginAttachmentItemSchema = z.object({
|
||||
id: z.string(),
|
||||
identifier: z.string(),
|
||||
title: z.string(),
|
||||
subtitle: z.string().optional(),
|
||||
url: z.url(),
|
||||
text: z.string(),
|
||||
resourceType: z.string(),
|
||||
});
|
||||
|
||||
export const PluginAttachmentSearchPayloadSchema = z.object({
|
||||
items: z.array(PluginAttachmentItemSchema),
|
||||
});
|
||||
|
||||
export type PluginAttachmentItem = z.infer<typeof PluginAttachmentItemSchema>;
|
||||
export type PluginAttachmentSearchPayload = z.infer<typeof PluginAttachmentSearchPayloadSchema>;
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { ComponentType } from "react";
|
||||
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
|
||||
import type { PluginRpcContract } from "./rpc.js";
|
||||
|
||||
export interface PluginSurfaceProps {
|
||||
theme: Record<string, unknown>;
|
||||
host: {
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
layout: {
|
||||
compact: boolean;
|
||||
platform: "ios" | "android" | "web";
|
||||
};
|
||||
}
|
||||
|
||||
export interface PluginSurfaceContribution {
|
||||
id: string;
|
||||
Component: ComponentType<PluginSurfaceProps>;
|
||||
}
|
||||
|
||||
export interface PluginSidebarContribution {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
surface: string;
|
||||
}
|
||||
|
||||
export interface PluginAttachmentSourceContribution {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
pickerTitle: string;
|
||||
searchPlaceholder: string;
|
||||
search: PluginRpcContract;
|
||||
}
|
||||
|
||||
export interface PluginContext {
|
||||
handle<InputSchema extends ZodType, OutputSchema extends ZodType>(
|
||||
contract: PluginRpcContract<InputSchema, OutputSchema>,
|
||||
handler: (
|
||||
input: ZodOutput<InputSchema>,
|
||||
) => ZodInput<OutputSchema> | Promise<ZodInput<OutputSchema>>,
|
||||
): void;
|
||||
addSurface(id: string, Component: ComponentType<PluginSurfaceProps>): void;
|
||||
addSidebarItem(contribution: PluginSidebarContribution): void;
|
||||
addAttachmentSource(contribution: PluginAttachmentSourceContribution): void;
|
||||
}
|
||||
|
||||
export type PluginCleanup = () => void;
|
||||
|
||||
export type PluginContribution = (plugin: PluginContext) => PluginCleanup;
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { defineAttachmentSource, defineRpc } from "./index.js";
|
||||
import { callPluginRpc, searchPluginAttachments } from "./host.js";
|
||||
|
||||
const contract = defineRpc({
|
||||
name: "increment",
|
||||
input: z.unknown().pipe(z.object({ value: z.number() })),
|
||||
output: z.object({ value: z.number() }),
|
||||
});
|
||||
|
||||
describe("plugin RPC contracts", () => {
|
||||
it("validates input before invoking the daemon", async () => {
|
||||
let invocations = 0;
|
||||
const invoke = async () => {
|
||||
invocations += 1;
|
||||
return { value: 2 };
|
||||
};
|
||||
|
||||
await expect(callPluginRpc(contract, invoke, { value: "wrong" })).rejects.toBeInstanceOf(
|
||||
z.ZodError,
|
||||
);
|
||||
expect(invocations).toBe(0);
|
||||
});
|
||||
|
||||
it("validates output before resolving", async () => {
|
||||
const invoke = async () => ({ value: "wrong" });
|
||||
|
||||
await expect(callPluginRpc(contract, invoke, { value: 1 })).rejects.toBeInstanceOf(z.ZodError);
|
||||
});
|
||||
|
||||
it("returns parsed output for a valid invocation", async () => {
|
||||
const invoke = async (method: string, input: unknown) => ({
|
||||
value: method === "increment" && typeof input === "object" ? 2 : 0,
|
||||
});
|
||||
|
||||
await expect(callPluginRpc(contract, invoke, { value: 1 })).resolves.toEqual({ value: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin attachment sources", () => {
|
||||
const search = defineRpc({
|
||||
name: "issues.search",
|
||||
input: z.object({ query: z.string() }),
|
||||
output: z.object({
|
||||
items: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
identifier: z.string(),
|
||||
title: z.string(),
|
||||
subtitle: z.string().optional(),
|
||||
url: z.string(),
|
||||
text: z.string(),
|
||||
resourceType: z.string(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
it("searches through the declared RPC and validates the standard result", async () => {
|
||||
const source = defineAttachmentSource({
|
||||
id: "issues",
|
||||
title: "Issue",
|
||||
icon: "CircleDot",
|
||||
pickerTitle: "Attach issue",
|
||||
searchPlaceholder: "Search",
|
||||
search,
|
||||
});
|
||||
|
||||
const payload = await searchPluginAttachments(
|
||||
source,
|
||||
async (method, input) => ({
|
||||
items: [
|
||||
{
|
||||
id: "issue-1",
|
||||
identifier: `${method}:${String(Reflect.get(input as object, "query"))}`,
|
||||
title: "Plugin attachments",
|
||||
url: "https://example.com/issues/1",
|
||||
text: "Issue snapshot",
|
||||
resourceType: "issue",
|
||||
},
|
||||
],
|
||||
}),
|
||||
"ENG-123",
|
||||
);
|
||||
|
||||
expect(payload.items[0]?.identifier).toBe("issues.search:ENG-123");
|
||||
});
|
||||
|
||||
it("rejects a malformed standard search result", async () => {
|
||||
const source = defineAttachmentSource({
|
||||
id: "issues",
|
||||
title: "Issue",
|
||||
icon: "CircleDot",
|
||||
pickerTitle: "Attach issue",
|
||||
searchPlaceholder: "Search",
|
||||
search,
|
||||
});
|
||||
|
||||
await expect(
|
||||
searchPluginAttachments(source, async () => ({ items: [{ id: "issue-1" }] }), "issue"),
|
||||
).rejects.toBeInstanceOf(z.ZodError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { PluginAttachmentSearchPayloadSchema } from "./attachments.js";
|
||||
import type {
|
||||
PluginAttachmentSourceContribution,
|
||||
PluginContext,
|
||||
PluginSidebarContribution,
|
||||
PluginSurfaceContribution,
|
||||
PluginSurfaceProps,
|
||||
} from "./contracts.js";
|
||||
import { PluginRpcProvider } from "./rpc-context.js";
|
||||
import { callPluginRpc } from "./rpc.js";
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
interface PluginCollector {
|
||||
addSurface(id: string, Component: ComponentType<PluginSurfaceProps>): void;
|
||||
addSidebarItem(contribution: PluginSidebarContribution): void;
|
||||
addAttachmentSource(contribution: PluginAttachmentSourceContribution): void;
|
||||
}
|
||||
|
||||
export interface PluginRegistrationCollector {
|
||||
surfaces: PluginSurfaceContribution[];
|
||||
sidebarItems: PluginSidebarContribution[];
|
||||
attachmentSources: PluginAttachmentSourceContribution[];
|
||||
}
|
||||
|
||||
export function createPluginContext(
|
||||
collector: PluginCollector,
|
||||
): Pick<PluginContext, "addSurface" | "addSidebarItem" | "addAttachmentSource"> {
|
||||
return {
|
||||
addSurface(id, Component) {
|
||||
collector.addSurface(id, Component);
|
||||
},
|
||||
addSidebarItem(contribution) {
|
||||
collector.addSidebarItem(contribution);
|
||||
},
|
||||
addAttachmentSource(contribution) {
|
||||
collector.addAttachmentSource(contribution);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function searchPluginAttachments(
|
||||
source: PluginAttachmentSourceContribution,
|
||||
invoke: (method: string, input: unknown) => Promise<unknown>,
|
||||
query: string,
|
||||
) {
|
||||
const output = await callPluginRpc(source.search, invoke, { query });
|
||||
return PluginAttachmentSearchPayloadSchema.parseAsync(output);
|
||||
}
|
||||
|
||||
export { callPluginRpc, PluginRpcProvider };
|
||||
@@ -0,0 +1,24 @@
|
||||
export {
|
||||
PluginAttachmentItemSchema,
|
||||
PluginAttachmentSearchPayloadSchema,
|
||||
type PluginAttachmentItem,
|
||||
type PluginAttachmentSearchPayload,
|
||||
} from "./attachments.js";
|
||||
export type {
|
||||
PluginAttachmentSourceContribution,
|
||||
PluginCleanup,
|
||||
PluginContribution,
|
||||
PluginContext,
|
||||
PluginSidebarContribution,
|
||||
PluginSurfaceContribution,
|
||||
PluginSurfaceProps,
|
||||
} from "./contracts.js";
|
||||
import type { PluginAttachmentSourceContribution } from "./contracts.js";
|
||||
export { useRpc } from "./rpc-context.js";
|
||||
export { defineRpc, type PluginRpcContract } from "./rpc.js";
|
||||
|
||||
export function defineAttachmentSource<Definition extends PluginAttachmentSourceContribution>(
|
||||
definition: Definition,
|
||||
): Definition {
|
||||
return definition;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react";
|
||||
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
|
||||
import { callPluginRpc, type PluginRpcContract } from "./rpc.js";
|
||||
|
||||
interface PluginRpcContextValue {
|
||||
invoke(method: string, input: unknown): Promise<unknown>;
|
||||
}
|
||||
|
||||
const PluginRpcContext = createContext<PluginRpcContextValue | null>(null);
|
||||
|
||||
export function PluginRpcProvider({
|
||||
children,
|
||||
invoke,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
invoke(method: string, input: unknown): Promise<unknown>;
|
||||
}) {
|
||||
const value = useMemo(() => ({ invoke }), [invoke]);
|
||||
return <PluginRpcContext.Provider value={value}>{children}</PluginRpcContext.Provider>;
|
||||
}
|
||||
|
||||
export function useRpc<InputSchema extends ZodType, OutputSchema extends ZodType>(
|
||||
contract: PluginRpcContract<InputSchema, OutputSchema>,
|
||||
): (input: ZodInput<InputSchema>) => Promise<ZodOutput<OutputSchema>> {
|
||||
const context = useContext(PluginRpcContext);
|
||||
if (!context) throw new Error("useRpc must run inside a contributed plugin surface");
|
||||
|
||||
return useCallback(
|
||||
(input: ZodInput<InputSchema>) => callPluginRpc(contract, context.invoke, input),
|
||||
[context, contract],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
|
||||
|
||||
export interface PluginRpcContract<
|
||||
InputSchema extends ZodType = ZodType,
|
||||
OutputSchema extends ZodType = ZodType,
|
||||
> {
|
||||
name: string;
|
||||
input: InputSchema;
|
||||
output: OutputSchema;
|
||||
}
|
||||
|
||||
interface RpcDefinition<InputSchema extends ZodType, OutputSchema extends ZodType> {
|
||||
name: string;
|
||||
input: InputSchema;
|
||||
output: OutputSchema;
|
||||
}
|
||||
|
||||
const RPC_NAME = /^[a-z][a-z0-9._-]*$/;
|
||||
|
||||
export function defineRpc<InputSchema extends ZodType, OutputSchema extends ZodType>(
|
||||
definition: RpcDefinition<InputSchema, OutputSchema>,
|
||||
): PluginRpcContract<InputSchema, OutputSchema> {
|
||||
const name = definition.name.trim();
|
||||
if (!RPC_NAME.test(name)) throw new Error(`Invalid plugin RPC method: ${definition.name}`);
|
||||
return { ...definition, name };
|
||||
}
|
||||
|
||||
export async function callPluginRpc<InputSchema extends ZodType, OutputSchema extends ZodType>(
|
||||
contract: PluginRpcContract<InputSchema, OutputSchema>,
|
||||
invoke: (method: string, input: unknown) => Promise<unknown>,
|
||||
input: ZodInput<InputSchema>,
|
||||
): Promise<ZodOutput<OutputSchema>> {
|
||||
const parsedInput = await contract.input.parseAsync(input);
|
||||
const output = await invoke(contract.name, parsedInput);
|
||||
return contract.output.parseAsync(output);
|
||||
}
|
||||
Reference in New Issue
Block a user