/** * defineScope() — define a self-describing scope plugin. * * One call: metadata, capabilities schema, actions (schema + handler). * Handles gRPC startup, action routing, --describe, and lifecycle management. * * Usage: * import { defineScope } from "@nullplatform/plugin/scope"; * * export default defineScope({ * name: "kubernetes-k3d", * version: "0.2.0", * schema: { type: "object", properties: { memory: { ... } } }, * uiSchema: { ... }, * actions: { * [Actions.CREATE_SCOPE]: { * input: { type: "object", properties: { scope: { ... } } }, * output: { type: "object", properties: { domain: { type: "string" } } }, * handler: async (ctx) => { ctx.log.step(`creating ${ctx.scopeId}`); ... }, * }, * }, * }); */ import { createPlugin } from "../create-plugin"; import type { ExecuteRequest, ExecuteResult } from "../types"; import { registerManifest } from "../internal/manifest"; import { StreamingExecutionObserver } from "../workflow"; /** @nullplatform/workflow-engine-in-memory, an optional peer, loaded only for workflow-chain handlers. */ async function inMemoryEngine( observer: StreamingExecutionObserver ): Promise { try { const mod = (await import( "@nullplatform/workflow-engine-in-memory" as string )) as { InMemoryEngine: new (o: { observer: unknown }) => unknown }; return new mod.InMemoryEngine({ observer }); } catch { throw new Error( "this action is a workflow chain, which needs @nullplatform/workflow-engine-in-memory installed" ); } } import { getActionDefaults } from "./actions"; import { handleDescribe } from "./describe"; import { handlePublish } from "./publish"; import { resolvePlatformClient } from "../platform"; import { buildActionContext, type ActionContext, type EmitFn } from "./context"; import { createScopeState } from "./state"; import { cfg } from "../config"; import type { NpJSONSchema } from "../schema"; // ─── Types ─────────────────────────────────────────────────────────── export interface ActionDefinition { /** JSON Schema describing the action's input. Supports $ref to schema.definitions. */ input: NpJSONSchema; /** JSON Schema describing the action's output. */ output?: NpJSONSchema; /** Override action name (defaults from well-known actions). */ name?: string; /** Override action type (defaults from well-known actions). */ type?: string; /** Override retryable flag (defaults from well-known actions). */ retryable?: boolean; /** The handler: a workflow chain or a function. */ handler: WorkflowChain | ActionHandlerFn; } /** A workflow chain with run() and toGraph(). */ interface WorkflowChain { run( input: Notification, options: { engine: unknown; timeoutMs: number } ): Promise<{ status: string; output?: unknown; error?: { message: string } }>; toGraph(): unknown; } /** * A plain handler function: receives the ONE typed context (identity, * attributes, log, emit, state, raw notification). Workflow-chain handlers * are the exception — they receive the raw notification (their input schema * is the wire payload). */ type ActionHandlerFn = (ctx: ActionContext) => Promise | unknown; function isWorkflowChain(handler: unknown): handler is WorkflowChain { return ( !!handler && typeof (handler as WorkflowChain).run === "function" && typeof (handler as WorkflowChain).toGraph === "function" ); } export interface ScopeDefinition { name: string; version: string; description?: string; category?: string; provider?: string; subCategory?: string; /** * Asset type the scope deploys (e.g. "docker-image", "lambda", "bundle"). * Required by the platform for agent-backed scopes. Injected into the schema * as a hidden readOnly asset_type property by both --describe and --publish. * Defaults to "docker-image". */ assetType?: string; /** JSON Schema for scope capabilities (properties). */ schema: NpJSONSchema; /** JSONForms UISchema for rendering the capabilities form. */ uiSchema?: object; /** * How the platform routes actions to the worker. Optional — sensible defaults * are derived from `name`, so most scopes never set this. Override only for a * custom selector or a non-standard worker entrypoint. */ agent?: ScopeAgentConfig; /** Actions: schema (input/output) + handler, keyed by action slug. */ actions: Record; } /** Agent routing config for a scope. See {@link ScopeDefinition.agent}. */ export interface ScopeAgentConfig { /** Selector tags an agent must carry to handle this scope. Default: `{ package: }`. */ selector?: Record; /** Path the agent execs in the worker image. Default: `/app/packages//entrypoint`. */ entrypoint?: string; /** * Notification sources the package's channel subscribes to — any sources the * platform supports (e.g. "service", "telemetry", "approval", "audit", * "alert", "entity", "parameters", "checklist"); the notifications API is the * validator, not the SDK. Default when unset: derived from the declared * actions — always `"service"`, plus `"telemetry"` when the scope declares * data-fetch actions (log:read, instance:data, …). */ sources?: string[]; /** * Bind the package's channel to the version being published: each * `np package publish` stamps that version into the channel's package-exec * command (`data.version`), so ALL actions through the channel — including * version-less ones — run the just-released worker. Default (false): the * channel stays version-agnostic and each action resolves its own version * (its pinned revision, else the package default). */ pinVersion?: boolean; /** * Whether `np package publish` manages the package's notification channel. * Default true: publish upserts one channel per package (identity: nrn + * type + description). Set false to opt out entirely — publish registers the * type/actions/package but touches no channel, and you own the routing * (Terraform, a shared channel, hand-tuned filters, …). */ manageChannel?: boolean; } /** * The scope's schema with the platform-mandatory asset_type property injected * as a hidden readOnly field. The declared value must travel INSIDE the schema: * `np package publish` builds the spec from the --describe manifest and falls * back to a docker-image default when the schema doesn't carry asset_type, so a * manifest without it registers every scope as docker-image no matter what * defineScope declared. A user-declared asset_type property wins over the * injected one. */ export function schemaWithAssetType( definition: ScopeDefinition ): Record { const userSchema = definition.schema as Record & { properties?: Record; }; return { ...userSchema, properties: { asset_type: { type: "string", export: false, readOnly: true, default: definition.assetType ?? "docker-image", }, ...(userSchema.properties ?? {}), }, }; } /** Fill agent config defaults from the scope name (the package slug). */ export function resolveAgentConfig( definition: ScopeDefinition ): Required { const slug = definition.name; const selector = definition.agent?.selector; return { selector: selector && Object.keys(selector).length > 0 ? selector : { package: slug }, entrypoint: definition.agent?.entrypoint || `/app/packages/${slug}/entrypoint`, sources: definition.agent?.sources?.length ? definition.agent.sources : deriveSources(definition.actions), pinVersion: definition.agent?.pinVersion ?? false, manageChannel: definition.agent?.manageChannel ?? true, }; } /** * Derive the channel sources from the declared actions: every package handles * "service" actions; "telemetry" is added only when a data-fetch action exists. * Data-fetch actions are exactly the ephemeral ones (lifecycle: false in * getActionDefaults) — one source of truth, no duplicated prefix lists. */ function deriveSources(actions: Record): string[] { const telemetry = Object.keys(actions).some( (slug) => !getActionDefaults(slug).lifecycle ); return telemetry ? ["service", "telemetry"] : ["service"]; } // ─── Exec payload parsing ──────────────────────────────────────────── // The platform only supports "exec" channel type today. np-agent reroutes // exec commands to this plugin when the channel config includes NP_PLUGIN=scope // in its environment. The payload is still the exec format, so we extract // the notification from environment.NP_ACTION_CONTEXT. // // Once the platform supports a native "scope" channel type, this can be removed. interface ExecPayload { cmdline?: string; arguments?: string[]; environment?: Record; } function parseExecPayload(payload: Buffer): Notification | null { try { const exec: ExecPayload = JSON.parse(payload.toString("utf-8")); const actionContext = exec.environment?.NP_ACTION_CONTEXT; if (!actionContext) return null; const raw = JSON.parse(actionContext); return raw.notification ?? raw; } catch { return null; } } // ─── Notification parsing ──────────────────────────────────────────── export interface Notification { id: string; slug: string; action: string; type: string; name: string; status: string; parameters: Record; tags: Record; entity_nrn: string; specification: { id: string; slug: string }; service: { id: string; slug: string; attributes: Record; specification: { id: string; slug: string }; dimensions: Record; }; results: Record; user: { id: number; email: string }; link: unknown; [key: string]: unknown; } function parseNotification(payload: Buffer): Notification { const raw = JSON.parse(payload.toString("utf-8")); return raw.notification ?? raw; } function resolveActionSlug( notification: Notification, reqActionType: string ): string { return ( notification.slug || notification.specification?.slug || reqActionType || "" ); } // scopeIdOf resolves the scope id the same way buildActionContext does — used // to construct ctx.state before the context itself. function firstStringCtx(notification: Notification): string { const params = (notification.parameters ?? {}) as Record; const scopeRef = (notification as { scope?: { id?: unknown } }).scope; const v = params.scope_id ?? scopeRef?.id; return v != null && v !== "" ? String(v) : ""; } // ─── defineScope ───────────────────────────────────────────────────── export function defineScope(definition: ScopeDefinition): void { if (process.argv.includes("--describe")) { handleDescribe(definition); process.exit(0); } if (process.argv.includes("--publish")) { handlePublish(definition).then( () => process.exit(0), (err) => { console.error(err instanceof Error ? err.message : String(err)); process.exit(1); } ); return; } // No --dev / --dev-agent handlers: those workflows live in np CLI (`np plugin dev`), // which shells to the project-local mise tasks. The SDK only owns --describe, // --publish, and the gRPC server for agent-driven execution. registerManifest({ name: definition.name, version: definition.version, command_types: ["scope"], }); // Subprocess exec mode: agents that run this binary directly (no gRPC host) // pass the action in the NP_ACTION_CONTEXT env var. Run the one action, report // its lifecycle status, print the result, and exit. const execCtx = cfg.actionContext(); if (execCtx && cfg.agentMode() !== "np-agent-v1") { void (async () => { const platform = resolvePlatformClient(); let out: ExecuteResult; let serviceId = ""; let actionId = ""; let lifecycle = false; try { const raw = JSON.parse(execCtx); const notification: Notification = raw.notification ?? raw; serviceId = notification.service?.id ?? ""; actionId = notification.id ?? ""; // emit forwards handler output to the platform as action messages (shown // live on the action). It routes through the injected client, so in dev // it's recorded locally and never touches the API. const emit = (o: { stdout?: string; stderr?: string }) => { if (o?.stdout) void platform .emitLog(serviceId, actionId, o.stdout, "stdout") .catch(() => {}); if (o?.stderr) void platform .emitLog(serviceId, actionId, o.stderr, "stderr") .catch(() => {}); }; // Route by scope-action slug, falling back to notification.action so // data-fetch actions (log:read, instance:data, …) map to a handler too. let actionSlug = resolveActionSlug( notification, notification.type ?? "" ); if ( !definition.actions[actionSlug] && (notification as { action?: string }).action ) { actionSlug = String((notification as { action?: string }).action); } const actionDef = definition.actions[actionSlug]; if (!actionDef) throw new Error(`Unknown action: ${actionSlug}`); // Data-fetch actions carry an output_schema — the platform validates the // worker's stdout against it directly, so we emit the raw handler result // (no {success,data} envelope, no action lifecycle). const outputSchema = (notification as { output_schema?: unknown }) .output_schema; lifecycle = getActionDefaults(actionSlug).lifecycle; if (lifecycle && actionId) { await platform .transitionAction(serviceId, actionId, "in_progress") .catch(() => {}); } let data: unknown; if (isWorkflowChain(actionDef.handler)) { const observer = new StreamingExecutionObserver(emit, { apiUrl: cfg.executionsApiUrl(), }); observer.emitPlan( actionDef.handler.toGraph() as Parameters< typeof observer.emitPlan >[0] ); const engine = await inMemoryEngine(observer); const wf = await actionDef.handler.run(notification, { engine, timeoutMs: 10 * 60 * 1000, }); if (wf.status !== "completed") throw new Error(wf.error?.message ?? `Workflow ${wf.status}`); data = wf.output; } else { const state = createScopeState( firstStringCtx(notification), platform, (reason) => emit({ stdout: ` · state not persisted (${reason})` }) ); const ctx = buildActionContext(notification, emit, state); data = await actionDef.handler(ctx); } // Data-fetch action: stdout IS the result the platform validates. if (outputSchema) { process.stdout.write( typeof data === "string" ? data : JSON.stringify(data ?? {}) ); process.exit(0); } if (lifecycle && actionId) { const results = data && typeof data === "object" ? (data as Record) : undefined; await platform .transitionAction(serviceId, actionId, "success", results) .catch(() => {}); } out = { success: true, data }; } catch (error) { const message = error instanceof Error ? error.message : String(error); if (lifecycle && actionId) { await platform .transitionAction(serviceId, actionId, "failed", { error: message }) .catch(() => {}); } out = { success: false, error: message, errorCode: "EXECUTION_ERROR" }; } process.stdout.write(JSON.stringify(out)); process.exit(out.success ? 0 : 1); })(); return; } const platform = resolvePlatformClient(); const plugin = createPlugin({ async execute(req: ExecuteRequest): Promise { // Handle both native "scope" and legacy "exec" command types let notification: Notification; if (req.commandType === "exec") { const parsed = parseExecPayload(req.payload); if (!parsed) { return { success: false, error: "NP_ACTION_CONTEXT not found in exec payload", errorCode: "MISSING_CONTEXT", }; } notification = parsed; } else { notification = parseNotification(req.payload); } let actionSlug = resolveActionSlug(notification, req.actionType); // Fall back to notification.action so data-fetch actions (log:read, // instance:data, …) — which carry no scope slug — map to a handler too. if ( !definition.actions[actionSlug] && (notification as { action?: string }).action ) { actionSlug = String((notification as { action?: string }).action); } if (!actionSlug) { return { success: false, error: "No action slug found in notification", errorCode: "MISSING_ACTION", }; } const actionDef = definition.actions[actionSlug]; if (!actionDef) { return { success: false, error: `Unknown action: ${actionSlug}`, errorCode: "UNKNOWN_ACTION", }; } // Data-fetch actions carry an output_schema — the platform validates the // worker's stdout against it directly (no {success,data} envelope, no // action lifecycle). We emit the raw result as stdout so it flows through // the agent to the platform, and also return it as data. const outputSchema = (notification as { output_schema?: unknown }) .output_schema; const defaults = getActionDefaults(actionSlug); const actionId = notification.id; const serviceId = notification.service?.id ?? ""; if (defaults.lifecycle && actionId) { await platform .transitionAction(serviceId, actionId, "in_progress") .catch((err) => { console.error( `[defineScope] Failed to patch action status: ${err.message}` ); }); } try { let result: unknown; if (isWorkflowChain(actionDef.handler)) { // Workflow chain: emit plan, run with engine const observer = new StreamingExecutionObserver(req.emit, { apiUrl: cfg.executionsApiUrl(), }); observer.emitPlan( actionDef.handler.toGraph() as Parameters< typeof observer.emitPlan >[0] ); const engine = await inMemoryEngine(observer); const wfResult = await actionDef.handler.run(notification, { engine, timeoutMs: 10 * 60 * 1000, }); if (wfResult.status !== "completed") { throw new Error( wfResult.error?.message ?? `Workflow failed with status: ${wfResult.status}` ); } result = wfResult.output; } else { // Plain function handler. Wrap emit so a handler's progress lines post // to the service action via the platform (emitLog → action messages = // what shows live in the UI). The worker path used to only stream to // the agent, which drops it, so emit() was invisible. // // For DATA-FETCH actions (outputSchema set) the platform validates the // worker's stdout as the pure result JSON, so we MUST NOT push handler // progress onto the stdout stream (req.emit) — post it to the log API // only (emitLog writes to stderr + the API, never stdout). const postLog = (o: { stdout?: string; stderr?: string }) => { if (!serviceId || !actionId) return; if (o?.stdout) void platform .emitLog(serviceId, actionId, o.stdout, "stdout") .catch(() => {}); if (o?.stderr) void platform .emitLog(serviceId, actionId, o.stderr, "stderr") .catch(() => {}); }; const emit = outputSchema ? (o: { stdout?: string; stderr?: string }) => postLog(o) // keep stdout pure : (o: { stdout?: string; stderr?: string }) => { req.emit(o); postLog(o); }; const state = createScopeState( firstStringCtx(notification), platform, (reason) => emit({ stdout: ` · state not persisted (${reason})` }) ); const ctx = buildActionContext(notification, emit, state); result = await actionDef.handler(ctx); } if (outputSchema) { const payload = typeof result === "string" ? result : JSON.stringify(result ?? {}); req.emit({ stdout: payload }); return { success: true, data: result }; } if (defaults.lifecycle && actionId) { const results = result && typeof result === "object" ? (result as Record) : undefined; await platform .transitionAction(serviceId, actionId, "success", results) .catch((err) => { console.error( `[defineScope] Failed to patch action status: ${err.message}` ); }); } return { success: true, data: result }; } catch (error) { const message = error instanceof Error ? error.message : String(error); if (defaults.lifecycle && actionId) { await platform .transitionAction(serviceId, actionId, "failed", { error: message }) .catch((err) => { console.error( `[defineScope] Failed to patch action status: ${err.message}` ); }); } return { success: false, error: message, errorCode: "EXECUTION_ERROR" }; } }, }); plugin.start(); } // ── Author-facing API surface ──────────────────────────────────────── export { Actions, ServiceActions, type ActionSlug, type ServiceActionSlug, } from "./constants"; export { buildActionContext, createActionLogger } from "./context"; export { createEntityContext, parseNrn } from "./entity"; export type { EntityContext, EntityApi, NrnIds } from "./entity"; export { createStepRunner } from "./step"; export type { StepFn, StepOptions, StepWork } from "./step"; export { silentNarrative } from "./narrative"; export type { Narrative, Explanation, ExternalLink } from "./narrative"; export type { ActionContext, ActionLogger, ScopeState, EmitFn, } from "./context";