Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions docs/docs/api/appkit/Function.defineManifest.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion docs/docs/api/appkit/Variable.agents.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions docs/docs/api/appkit/index.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions docs/docs/api/appkit/typedoc-sidebar.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/appkit/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export type {
ValidationResult,
} from "./registry";
export {
defineManifest,
getPluginManifest,
getResourceRequirements,
ResourceRegistry,
Expand Down
8 changes: 2 additions & 6 deletions packages/appkit/src/plugins/agents/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ import type {
import { isToolkitEntry } from "../../core/agent/types";
import { createLogger } from "../../logging/logger";
import { Plugin, toPlugin } from "../../plugin";
import type { PluginManifest } from "../../registry";
import { defineManifest } from "../../registry";
import { agentStreamDefaults } from "./defaults";
import { EventChannel } from "./event-channel";
import { AgentEventTranslator } from "./event-translator";
Expand Down Expand Up @@ -142,11 +142,7 @@ interface RunState {
}

export class AgentsPlugin extends Plugin implements ToolProvider {
// Routed through `unknown`: the optional resources have differing `fields`
// keys (serving `name`, experiment `experimentId`), which TS widens to an
// incompatible union on the JSON import. The shape is validated at runtime
// against the plugin-manifest schema.
static manifest = manifest as unknown as PluginManifest;
static manifest = defineManifest<"agents">(manifest);
static phase: PluginPhase = "deferred";

declare protected config: AgentsPluginConfig;
Expand Down
4 changes: 2 additions & 2 deletions packages/appkit/src/plugins/ai-search/ai-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type {
import { getCurrentUserId, getWorkspaceClient } from "../../context";
import { createLogger } from "../../logging/logger";
import { Plugin, toPlugin } from "../../plugin";
import type { PluginManifest } from "../../registry";
import { defineManifest } from "../../registry";
import { formatWarningBanner } from "../../utils/banner";
import { aiSearchDefaults } from "./defaults";
import manifest from "./manifest.json";
Expand All @@ -32,7 +32,7 @@ const querySettings: PluginExecutionSettings = {
};

export class AiSearchPlugin extends Plugin<IAiSearchConfig> {
static manifest = manifest as PluginManifest<"aiSearch">;
static manifest = defineManifest<"aiSearch">(manifest);

protected static description =
"Query Databricks Vector Search indexes with hybrid search, reranking, and pagination";
Expand Down
4 changes: 2 additions & 2 deletions packages/appkit/src/plugins/analytics/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@
import { AppKitError, ExecutionError } from "../../errors";
import { createLogger } from "../../logging/logger";
import { Plugin, toPlugin } from "../../plugin";
import type { PluginManifest } from "../../registry";
import { defineManifest } from "../../registry";
import type { WorkspaceClient } from "../../workspace-client";

Check warning on line 31 in packages/appkit/src/plugins/analytics/analytics.ts

View workflow job for this annotation

GitHub Actions / Lint & Type Check

eslint(no-unused-vars)

packages/appkit/src/plugins/analytics/analytics.ts:31:15: Type 'WorkspaceClient' is imported but never used.
import { queryDefaults } from "./defaults";
import manifest from "./manifest.json";
import {
Expand Down Expand Up @@ -110,7 +110,7 @@

export class AnalyticsPlugin extends Plugin implements ToolProvider {
/** Plugin manifest declaring metadata and resource requirements */
static manifest = manifest as PluginManifest<"analytics">;
static manifest = defineManifest<"analytics">(manifest);

protected static description = "Analytics plugin for data analysis";
declare protected config: IAnalyticsConfig;
Expand Down
6 changes: 3 additions & 3 deletions packages/appkit/src/plugins/files/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ import {
import { AuthenticationError } from "../../errors";
import { createLogger } from "../../logging/logger";
import { Plugin, toPlugin } from "../../plugin";
import type { PluginManifest, ResourceRequirement } from "../../registry";
import { ResourceType } from "../../registry";
import type { ResourceRequirement } from "../../registry";
import { defineManifest, ResourceType } from "../../registry";
import { ApiError } from "../../workspace-client";
import {
FILES_DOWNLOAD_DEFAULTS,
Expand Down Expand Up @@ -69,7 +69,7 @@ export class FilesPlugin extends Plugin implements ToolProvider {
name = "files";

/** Plugin manifest declaring metadata and resource requirements. */
static manifest = manifest as PluginManifest;
static manifest = defineManifest<"files">(manifest);
protected static description = "Files plugin for Databricks file operations";
declare protected config: IFilesConfig;

Expand Down
4 changes: 2 additions & 2 deletions packages/appkit/src/plugins/genie/genie.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
} from "../../core/agent/tools/define-tool";
import { createLogger } from "../../logging";
import { Plugin, toPlugin } from "../../plugin";
import type { PluginManifest } from "../../registry";
import { defineManifest } from "../../registry";
import { genieStreamDefaults } from "./defaults";
import manifest from "./manifest.json";
import type {
Expand All @@ -33,7 +33,7 @@ import type {
const logger = createLogger("genie");

export class GeniePlugin extends Plugin implements ToolProvider {
static manifest = manifest as PluginManifest<"genie">;
static manifest = defineManifest<"genie">(manifest);

protected static description =
"AI/BI Genie space integration for natural language data queries";
Expand Down
6 changes: 3 additions & 3 deletions packages/appkit/src/plugins/jobs/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ import { ExecutionError, ValidationError } from "../../errors";
import { createLogger } from "../../logging/logger";
import type { ExecutionResult } from "../../plugin";
import { Plugin, toPlugin } from "../../plugin";
import type { PluginManifest, ResourceRequirement } from "../../registry";
import { ResourceType } from "../../registry";
import type { ResourceRequirement } from "../../registry";
import { defineManifest, ResourceType } from "../../registry";
import type { jobs as jobsTypes } from "../../workspace-client";
import {
JOBS_READ_DEFAULTS,
Expand Down Expand Up @@ -85,7 +85,7 @@ function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
}

class JobsPlugin extends Plugin {
static manifest = manifest as PluginManifest;
static manifest = defineManifest<"jobs">(manifest);

declare protected config: IJobsConfig;
private connector: JobsConnector;
Expand Down
4 changes: 2 additions & 2 deletions packages/appkit/src/plugins/lakebase/lakebase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
import { assertReadOnlySql } from "../../core/agent/tools/sql-policy";
import { createLogger } from "../../logging/logger";
import { Plugin, toPlugin } from "../../plugin";
import type { PluginManifest } from "../../registry";
import { defineManifest } from "../../registry";
import { createWorkspaceClient } from "../../workspace-client";
import manifest from "./manifest.json";
import type { ILakebaseConfig } from "./types";
Expand Down Expand Up @@ -66,7 +66,7 @@ const OBO_POOL_DEFAULTS = {
*/
export class LakebasePlugin extends Plugin implements ToolProvider {
/** Plugin manifest declaring metadata and resource requirements */
static manifest = manifest as PluginManifest<"lakebase">;
static manifest = defineManifest<"lakebase">(manifest);

declare protected config: ILakebaseConfig;
private pool: RoutingPool | null = null;
Expand Down
4 changes: 2 additions & 2 deletions packages/appkit/src/plugins/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { AppKitError, ServerError } from "../../errors";
import { TelemetryReporter } from "../../internal-telemetry";
import { createLogger } from "../../logging/logger";
import { Plugin, toPlugin } from "../../plugin";
import type { PluginManifest } from "../../registry";
import { defineManifest } from "../../registry";
import { instrumentations } from "../../telemetry";
import { sanitizeClientConfig } from "./client-config-sanitizer";
import manifest from "./manifest.json";
Expand Down Expand Up @@ -66,7 +66,7 @@ export class ServerPlugin extends Plugin {
private static readonly SERVER_CLOSE_TIMEOUT_MS = 2_000;

/** Plugin manifest declaring metadata and resource requirements */
static manifest = manifest as PluginManifest<"server">;
static manifest = defineManifest<"server">(manifest);
private serverApplication: express.Application;
private server: HTTPServer | null;
private viteDevServer?: ViteDevServer;
Expand Down
6 changes: 3 additions & 3 deletions packages/appkit/src/plugins/serving/serving.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import * as servingConnector from "../../connectors/serving/client";
import { getWorkspaceClient } from "../../context";
import { createLogger } from "../../logging";
import { type ExecutionResult, Plugin, toPlugin } from "../../plugin";
import type { PluginManifest, ResourceRequirement } from "../../registry";
import { ResourceType } from "../../registry";
import type { ResourceRequirement } from "../../registry";
import { defineManifest, ResourceType } from "../../registry";
import { servingInvokeDefaults } from "./defaults";
import manifest from "./manifest.json";
import { filterRequestBody, loadEndpointSchemas } from "./schema-filter";
Expand Down Expand Up @@ -42,7 +42,7 @@ interface ResolvedEndpoint {
}

export class ServingPlugin extends Plugin {
static manifest = manifest as PluginManifest<"serving">;
static manifest = defineManifest<"serving">(manifest);

protected static description =
"Authenticated proxy to Databricks Model Serving endpoints";
Expand Down
4 changes: 2 additions & 2 deletions packages/appkit/src/plugins/ui-variants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { BasePluginConfig, IAppRouter } from "shared";

import { createLogger } from "../../logging/logger";
import { Plugin, toPlugin } from "../../plugin";
import type { PluginManifest } from "../../registry";
import { defineManifest } from "../../registry";
import { FileChoiceStore, type UiChoiceRecord } from "./choice-sink";
import manifest from "./manifest.json";

Expand All @@ -26,7 +26,7 @@ interface ConfirmRequestBody {
* per `<Variants>` id — and the plugin only records; it never edits source.
*/
class UiVariantsPlugin extends Plugin<BasePluginConfig> {
static manifest = manifest as PluginManifest<"uiVariants">;
static manifest = defineManifest<"uiVariants">(manifest);

protected static description =
"Dev-only recorder for the <Variants> UI picker";
Expand Down
6 changes: 5 additions & 1 deletion packages/appkit/src/registry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@
* - (Future) Config generators for app.yaml, databricks.yml, .env.example
*/

export { getPluginManifest, getResourceRequirements } from "./manifest-loader";
export {
defineManifest,
getPluginManifest,
getResourceRequirements,
} from "./manifest-loader";
export { ResourceRegistry } from "./resource-registry";
export * from "./types";

Expand Down
28 changes: 27 additions & 1 deletion packages/appkit/src/registry/manifest-loader.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { PluginConstructor } from "shared";
import { type PluginConstructor, pluginManifestSchema } from "shared";

import { ConfigurationError } from "../errors";
import { createLogger } from "../logging/logger";
Expand All @@ -11,6 +11,32 @@ import { PERMISSIONS_BY_TYPE, ResourceType } from "./types";

const logger = createLogger("manifest-loader");

/**
* Validates a raw manifest (typically a `manifest.json` import) against the
* canonical Zod schema and returns it as a strict {@link PluginManifest}.
*
* Plugins declare `static manifest = defineManifest<"my-plugin">(manifestJson)`
* instead of casting. A plain `as PluginManifest` can't work: a JSON import
* widens every field to `string`, and `PluginManifest.resources[].type` is the
* nominal `ResourceType` enum, so the structural JSON shape never assigns. The
* single internal assertion here bridges that gap in one audited place — after
* `parse()` has confirmed the values are real `ResourceType`/permission strings
* — rather than every plugin repeating `as unknown as PluginManifest`.
*
* Pass the plugin name as `TName` so the literal is preserved: `toPlugin`
* derives the typed plugin key from `manifest.name`, and a widened `string`
* there would collapse the typed plugin registry.
*
* @throws {ZodError} If the manifest doesn't match the schema.
*/
export function defineManifest<TName extends string = string>(
manifest: unknown,
): PluginManifest<TName> {
return pluginManifestSchema.parse(
manifest,
) as unknown as PluginManifest<TName>;
}

/** Loose resource from shared/manifest (string type and permission). */
interface LooseResource {
type: string;
Expand Down
59 changes: 59 additions & 0 deletions packages/appkit/src/registry/tests/manifest-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";

import { ConfigurationError } from "../../errors";
import {
defineManifest,
getPluginManifest,
getResourceRequirements,
isValidManifest,
Expand Down Expand Up @@ -555,4 +556,62 @@ describe("Manifest Loader", () => {
expect(isValidManifest(valid)).toBe(true);
});
});

describe("defineManifest", () => {
const validManifest = {
name: "testPlugin",
displayName: "Test Plugin",
description: "A test plugin",
resources: {
required: [
{
type: "sql_warehouse",
alias: "warehouse",
resourceKey: "sql-warehouse",
description: "Test warehouse",
permission: "CAN_USE",
fields: { id: { env: "TEST_WAREHOUSE_ID" } },
},
],
optional: [],
},
};

it("returns a schema-valid manifest unchanged", () => {
const result = defineManifest<"testPlugin">(validManifest);
expect(result.name).toBe("testPlugin");
expect(result.resources.required[0].type).toBe("sql_warehouse");
});

it("throws when a resource has an unknown type", () => {
const bad = {
...validManifest,
resources: {
required: [
{ ...validManifest.resources.required[0], type: "not_a_type" },
],
optional: [],
},
};
expect(() => defineManifest(bad)).toThrow();
});

it("throws when a permission is invalid for the resource type", () => {
const bad = {
...validManifest,
resources: {
required: [
{ ...validManifest.resources.required[0], permission: "CAN_QUERY" },
],
optional: [],
},
};
expect(() => defineManifest(bad)).toThrow();
});

it("throws when a required top-level field is missing", () => {
const { name: _omit, ...noName } = validManifest;
expect(() => defineManifest(noName)).toThrow();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ describe("scaffold", () => {
expect(pluginTs).toContain("class MyPlugin");
expect(pluginTs).toContain("export const myPlugin = toPlugin(MyPlugin)");
expect(pluginTs).toContain('import manifest from "./manifest.json"');
expect(pluginTs).toContain("manifest as PluginManifest");
expect(pluginTs).toContain('defineManifest<"my-plugin">(manifest)');
});

it("generates index.ts with correct exports", () => {
Expand Down
Loading
Loading