/** * definePackage — the whole package: its components, each with its actions. * * export default definePackage({ components: [bucket, connect, target, gate, raw] }); * * It validates the definition and returns a Package that can describe itself * (`describe()`: the manifest `np package publish` reads), run one action in * a test (`run()`, `execute()`), and serve as the worker (`start()`). Nothing * starts on import: a test imports the package the way `main.ts` does. */ import { existsSync, readFileSync } from "fs"; import { resolve as resolvePath } from "path"; import { createPlugin } from "../create-plugin"; import { registerManifest } from "../internal/manifest"; import { resolvePlatformClient } from "../platform"; import { cfg } from "../config"; import type { ExecuteRequest, ExecuteResult } from "../types"; import { isEvent, isExecute, type Component, type EventComponent, type ExecuteComponent, } from "./components"; import { describeManifest } from "./describe"; import { dispatch, resolve, unwrap, type Dispatchable, type Document, } from "./dispatch"; import type { Handler } from "./handler"; import { buildManifest, type Manifest, type PackageAgentConfig, type ResolvedAgentConfig, } from "./manifest"; import { isResource, type Resource } from "./resource"; import { runAction, runEvent, runExecute, type RunContext, type RunResult, } from "./run"; import { isSpecification } from "./service"; import { createTracing, tracingWith, planFor, type PlanDeclaration, type TracingClientLike, type Tracing, type TracingClient, } from "./tracing"; export interface PackageDefinition { /** Defaults to package.json's name (the trailing path segment, scoped names included). */ name?: string; /** Defaults to the name, slugified. */ slug?: string; /** Defaults to package.json's version. */ version?: string; /** One line the UI shows on the package. */ description?: string; /** What the package publishes and answers: `service()`, `link()`, `scope()`, `resource()`, `event()`, `execute()`. */ components: Component[]; /** How the platform routes to the worker; defaults derived from the slug. */ agent?: PackageAgentConfig; /** * Tracing: a `@nullplatform/tracing` Tracer (or anything with its run/step * shape) to use instead of the one NP_TRACING_URL configures. */ tracing?: TracingClientLike; } export interface Package { readonly name: string; readonly slug: string; readonly version: string; readonly description?: string; readonly components: Component[]; readonly resources: Resource[]; readonly events: EventComponent[]; readonly agent: ResolvedAgentConfig; /** The manifest `np package publish` reads: one scope or one service, with its links. Throws when the package has more than one. */ describe(): Record; /** The package as components with the API's own bodies, for tooling. */ manifest(): Manifest; /** One action, in-process, no platform. */ run( resource: Resource, action: string, input?: Record, context?: RunContext ): Promise; /** One event, in-process: `payload` is the notification's context. */ run( event: EventComponent, payload?: Record, context?: RunContext ): Promise; /** The execute component, in-process, with the document as the agent would hand it over. */ execute(document: unknown, context?: RunContext): Promise; /** Run as the worker: `--describe`, one action from NP_ACTION_CONTEXT, or the gRPC server. */ start(argv?: string[]): void; } const slugify = (s: string) => s .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); function packageJson(): { name?: string; version?: string; description?: string; } { const p = resolvePath(process.cwd(), "package.json"); if (!existsSync(p)) return {}; try { return JSON.parse(readFileSync(p, "utf-8")); } catch { return {}; } } function collect(resources: Resource[]): Resource[] { const out: Resource[] = []; const seen = new Set(); const visit = (r: Resource) => { if (seen.has(r)) return; seen.add(r); out.push(r); r.children.forEach(visit); }; resources.forEach(visit); return out; } export function definePackage(def: PackageDefinition): Package { const pj = packageJson(); const name = def.name ?? (pj.name ? pj.name.split("/").pop()! : undefined); if (!name) throw new Error( "definePackage: no name — pass `name` or run inside a project with a package.json" ); const version = def.version ?? pj.version ?? "0.0.0"; const slug = def.slug ?? slugify(name); const description = def.description ?? pj.description; const agent: ResolvedAgentConfig = { selector: def.agent?.selector && Object.keys(def.agent.selector).length ? def.agent.selector : { package: slug }, entrypoint: def.agent?.entrypoint || `/app/packages/${slug}/entrypoint`, sources: def.agent?.sources ?? [], pinVersion: def.agent?.pinVersion ?? false, manageChannel: def.agent?.manageChannel ?? true, }; // ── Validation: exhaustive and unambiguous, before anything is published. if (!Array.isArray(def.components)) throw new Error(`definePackage(${name}): components must be an array`); const problems: string[] = []; const events: EventComponent[] = []; const executes: ExecuteComponent[] = []; const declared: Resource[] = []; for (const c of def.components) { if (isEvent(c)) events.push(c); else if (isExecute(c)) executes.push(c); else if (isResource(c)) declared.push(c); else problems.push( "components must be values from service(), link(), scope(), resource(), event() or execute()" ); } const resources = collect(declared); const seenSlugs = new Set(); for (const r of resources) { if (seenSlugs.has(r.ref.ref)) problems.push(`${r.ref.ref} is declared twice`); seenSlugs.add(r.ref.ref); } const seenEvents = new Set(); for (const e of events) { if (seenEvents.has(e.name)) problems.push(`two event() components for "${e.name}"`); seenEvents.add(e.name); } if (executes.length > 1) problems.push("only one execute() component is allowed"); if (problems.length) throw new Error(`definePackage(${name}): ${problems.join("; ")}`); const handlers: Handler[] = []; for (const r of resources) { if (!isSpecification(r)) continue; for (const action of r.actions()) handlers.push({ resource: r, kind: r.kind, action, fn: r.handlers[action], }); } const dispatchable: Dispatchable = { resources, handlers, events, execute: executes[0], slug, version, }; const tracing: Tracing = def.tracing ? tracingWith(def.tracing) : createTracing(slug); const pkg: Package = { name, slug, version, description, components: def.components.slice(), resources, events, agent, describe: () => describeManifest({ slug, version, description, agent, resources, events, execute: executes[0], }).manifest, manifest: () => buildManifest({ name, slug, version, description, agent, resources, events, }), run: (( target: Resource | EventComponent, a?: unknown, b?: unknown, c?: unknown ) => isEvent(target) ? runEvent( dispatchable, target, a as Record | undefined, b as RunContext | undefined ) : runAction( dispatchable, target, String(a), b as Record | undefined, c as RunContext | undefined )) as Package["run"], execute: (document, context) => runExecute(dispatchable, document, context), start: (argv = process.argv) => startWorker(pkg, dispatchable, tracing, argv), }; return pkg; } function startWorker( pkg: Package, dispatchable: Dispatchable, tracing: Tracing, argv: string[] ): void { if (argv.includes("--describe")) { try { const { manifest, notes } = describeManifest({ ...pkg, execute: dispatchable.execute, }); for (const note of notes) console.error(`note: ${note}`); console.log(JSON.stringify(manifest, null, 2)); process.exit(0); } catch (err) { console.error(err instanceof Error ? err.message : String(err)); process.exit(1); } } registerManifest({ name: pkg.slug, version: pkg.version, command_types: ["service", "scope"], }); // One controller per action: SIGTERM/SIGINT abort the in-flight handler's ctx.signal. const stopping = new AbortController(); for (const sig of ["SIGTERM", "SIGINT"] as const) process.once(sig, () => stopping.abort(new Error(`worker received ${sig}`)) ); const runOne = async ( document: Document, emit: (o: { stdout?: string; stderr?: string }) => void ) => { const platform = resolvePlatformClient(); const notification = unwrap(document.doc) as { id?: string; service?: { id?: string }; }; const serviceId = notification.service?.id ?? ""; const actionId = notification.id ?? ""; const withLogs = (o: { stdout?: string; stderr?: string }) => { 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(() => {}); } }; let kind = "service"; let action = String((notification as { slug?: unknown }).slug ?? ""); let resolvedBody: unknown; try { const r = resolve(dispatchable, document.doc); resolvedBody = r.resource?.body; kind = r.kind === "action" ? r.resource && (r.resource.body as { type?: string }).type === "scope" ? "scope" : r.resource?.kind === "link_specification" ? "link" : "service" : r.kind; action = r.action; } catch { // dispatch() reports the resolution failure itself } const span = tracing.action(notification as Record, { kind, action, packageSlug: pkg.slug, packageVersion: pkg.version, plan: planFor( (resolvedBody as { plans?: Record } | undefined)?.plans, action, notification as Record ), }); try { const outcome = await dispatch(dispatchable, document, { platform, emit: withLogs, onStep: (name, run) => span.step(name, run), trace: span.trace, package: { slug: pkg.slug, version: pkg.version }, signal: stopping.signal, }); await span.end(outcome.status, outcome.error); return outcome; } catch (err) { await span.end("failed", { code: "EXECUTION_ERROR", message: err instanceof Error ? err.message : String(err), }); throw err; } }; const toResult = (o: Awaited>): ExecuteResult => o.status === "success" ? { success: true, data: o.data } : { success: false, error: o.error?.message, errorCode: o.error?.code }; // Subprocess-exec mode: the action arrives in NP_ACTION_CONTEXT. const execCtx = cfg.actionContext(); if (execCtx && cfg.agentMode() !== "np-agent-v1") { void (async () => { let out: ExecuteResult; try { const outcome = await runOne( { doc: JSON.parse(execCtx), raw: execCtx }, () => {} ); if (outcome.status === "success" && outcome.raw) { // A data action: stdout IS the answer the platform validates. No envelope. process.stdout.write( typeof outcome.data === "string" ? outcome.data : JSON.stringify(outcome.data ?? {}) ); process.exit(0); } out = toResult(outcome); } catch (err) { out = { success: false, error: err instanceof Error ? err.message : String(err), errorCode: "EXECUTION_ERROR", }; } process.stdout.write(JSON.stringify(out)); process.exit(out.success ? 0 : 1); })(); return; } // gRPC host path (the agent dials the worker). The exec payload carries the // document in environment.NP_ACTION_CONTEXT; a native payload is the document. createPlugin({ async execute(req: ExecuteRequest): Promise { let document: Document; try { const text = req.payload.toString("utf-8"); const parsed = JSON.parse(text) as { environment?: Record; }; const inner = parsed?.environment?.NP_ACTION_CONTEXT; document = inner ? { doc: JSON.parse(inner), raw: inner } : { doc: parsed, raw: text }; } catch { return { success: false, error: "invalid payload", errorCode: "MISSING_CONTEXT", }; } const outcome = await runOne(document, req.emit); if (outcome.status === "success" && outcome.raw) { // A data action: the raw answer goes out as stdout for the platform, and back as data. req.emit({ stdout: typeof outcome.data === "string" ? outcome.data : JSON.stringify(outcome.data ?? {}), }); } return toResult(outcome); }, }).start(); }