/** * defineService() — define a self-describing SERVICE package. * * A service is an application *dependency* (a database, cache, queue, …), not a * deployment target. It differs from a scope: * - the service_specification is `type: "dependency"` (no scope type), * - with `useDefaultActions` the platform auto-generates create/update/delete * action specs from the schema — you only write handlers, not specs, * - action slugs are dynamic (`create-`), so handlers are keyed by the * action TYPE (create/update/delete) or a custom slug. * * The runtime (subprocess-exec + gRPC, lifecycle reporting) mirrors defineScope. */ 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 { resolvePlatformClient } from "../platform"; import { cfg } from "../config"; import { resolveAgentConfig, buildActionContext, type ScopeAgentConfig, type ActionDefinition, type Notification, type ActionContext, } from "../scope"; import { createScopeState } from "../scope/state"; import { handleServiceDescribe } from "./describe"; import type { NpJSONSchema } from "../schema"; export interface ServiceDefinition { name: string; version: string; description?: string; category?: string; provider?: string; /** JSON Schema for the service's attributes (its configurable properties). */ schema: NpJSONSchema; /** JSONForms UISchema for the attributes form. */ uiSchema?: object; /** * Let the platform auto-generate create/update/delete action specs from the * schema (default true). Set false to declare all action specs yourself. */ useDefaultActions?: boolean; /** Where the service can be assigned. Default "any". */ assignableTo?: "any" | "dimension" | "scope"; /** How the platform routes actions to the worker. Defaults from `name`. */ agent?: ScopeAgentConfig; /** * Handlers, keyed by action type (`create`/`update`/`delete`) for the default * actions, and by slug for any custom action. */ actions: Record; } function isWorkflowChain( handler: unknown ): handler is { run: Function; toGraph: Function } { return ( !!handler && typeof (handler as any).run === "function" && typeof (handler as any).toGraph === "function" ); } // resolveServiceHandler maps a notification to a handler. Default actions arrive // with a dynamic slug (`create-`) but a stable `type` (create/update/ // delete), so we try: exact slug → action type → the type-prefixed slug. function resolveServiceHandler( def: ServiceDefinition, notification: Notification ): { key: string; action?: ActionDefinition } { const slug = notification.slug || notification.specification?.slug || ""; const type = String((notification as any).type ?? ""); if (slug && def.actions[slug]) return { key: slug, action: def.actions[slug] }; if (type && def.actions[type]) return { key: type, action: def.actions[type] }; // "create-postgres" → "create" const prefix = slug.split("-")[0]; if (prefix && def.actions[prefix]) return { key: prefix, action: def.actions[prefix] }; return { key: slug || type }; } export function defineService(definition: ServiceDefinition): void { if (process.argv.includes("--describe")) { handleServiceDescribe(definition); process.exit(0); } registerManifest({ name: definition.name, version: definition.version, command_types: ["service"], }); // Subprocess-exec mode (how the agent runs the worker): the action arrives in // NP_ACTION_CONTEXT; run it, report lifecycle, print the result, exit. const execCtx = cfg.actionContext(); if (execCtx && cfg.agentMode() !== "np-agent-v1") { void (async () => { const platform = resolvePlatformClient(); let out: ExecuteResult; let serviceId = ""; let actionId = ""; try { const raw = JSON.parse(execCtx); const notification: Notification = raw.notification ?? raw; serviceId = notification.service?.id ?? ""; actionId = notification.id ?? ""; 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(() => {}); }; const { key, action } = resolveServiceHandler(definition, notification); if (!action) throw new Error(`Unknown service action: ${key}`); if (actionId) await platform .transitionAction(serviceId, actionId, "in_progress") .catch(() => {}); let data: unknown; if (isWorkflowChain(action.handler)) { const observer = new StreamingExecutionObserver(emit, { apiUrl: cfg.executionsApiUrl(), }); observer.emitPlan((action.handler as any).toGraph()); const engine = await inMemoryEngine(observer); const wf = await (action.handler as any).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("", platform, (reason) => emit({ stdout: ` · state not persisted (${reason})` }) ); const ctx = buildActionContext(notification, emit, state); data = await (action.handler as (c: ActionContext) => unknown)(ctx); } if (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 (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; } // gRPC host path (dev / np-agent-v1). const platform = resolvePlatformClient(); const plugin = createPlugin({ async execute(req: ExecuteRequest): Promise { let notification: Notification; try { const raw = JSON.parse(req.payload.toString("utf-8")); notification = raw.notification ?? raw; } catch { return { success: false, error: "invalid payload", errorCode: "MISSING_CONTEXT", }; } const { key, action } = resolveServiceHandler(definition, notification); if (!action) return { success: false, error: `Unknown service action: ${key}`, errorCode: "UNKNOWN_ACTION", }; const actionId = notification.id; const serviceId = notification.service?.id ?? ""; if (actionId) await platform .transitionAction(serviceId, actionId, "in_progress") .catch(() => {}); // Wrap emit so a handler's progress lines both stream to the agent AND post // to the service action (what shows live in the UI); the worker path used // to only stream to the agent, which drops it. const emit = (o: { stdout?: string; stderr?: string }) => { req.emit(o); if (serviceId && actionId) { if (o?.stdout) void platform .emitLog(serviceId, actionId, o.stdout, "stdout") .catch(() => {}); if (o?.stderr) void platform .emitLog(serviceId, actionId, o.stderr, "stderr") .catch(() => {}); } }; try { const state = createScopeState("", platform, (reason) => emit({ stdout: ` · state not persisted (${reason})` }) ); const ctx = buildActionContext(notification, emit, state); const result = await (action.handler as (c: ActionContext) => unknown)( ctx ); if (actionId) { const results = result && typeof result === "object" ? (result as Record) : undefined; await platform .transitionAction(serviceId, actionId, "success", results) .catch(() => {}); } return { success: true, data: result }; } catch (error) { const message = error instanceof Error ? error.message : String(error); if (actionId) await platform .transitionAction(serviceId, actionId, "failed", { error: message }) .catch(() => {}); return { success: false, error: message, errorCode: "EXECUTION_ERROR" }; } }, }); plugin.start(); } export { resolveAgentConfig }; // Author-facing API: service handlers receive the same typed ActionContext as // scopes (state no-ops off-scope, with a note). ServiceActionInput remains an // alias of the raw wire notification for input-schema typing. export { Actions, ServiceActions } from "../scope"; export type { ActionContext, ActionLogger, ScopeState, EmitFn } from "../scope"; export type { Notification as ServiceActionInput, ActionDefinition, ScopeAgentConfig, } from "../scope";