/** * ActionContext — the ONE argument every handler receives. * * This module is the platform↔author boundary: the wire payload is * snake_case and loosely shaped; the context is camelCase and typed. The SDK * maps only PLATFORM-owned names here (scope_id, deployment_id, asset_url, * weight). Author-declared attribute keys pass through untouched — declare * them camelCase in your schema and read them the same way. * * Everything a handler needs hangs off ctx (identity, attributes, log, emit, * state, the raw notification) so adding a capability later never changes the * handler signature. */ import * as platformApi from "../api"; import { cyan, dim, green, red } from "../term"; import { createEntityContext, type EntityApi, type EntityContext, } from "./entity"; import { silentNarrative, type Narrative } from "./narrative"; import { createStepRunner, type StepFn } from "./step"; import type { Notification } from "./index"; export type EmitFn = (o: { stdout?: string; stderr?: string }) => void; export interface ActionLogger { /** `▸ msg` — a step starting. */ step(msg: string): void; /** ` · msg` — detail / progress under the current step. */ detail(msg: string): void; /** `✓ msg` — a step completed. */ ok(msg: string): void; /** `✗ msg` — a failure line (stderr). */ fail(msg: string): void; } /** Scope infrastructure state, persisted in the scope's non-rendered `state` * attribute. Best-effort by design: a failed save logs and moves on. */ export interface ScopeState { read(): Promise>; merge(patch: Record): Promise; } export interface ActionContext> { /** The scope this action targets (empty for pure service actions). */ scopeId: string; /** The deployment, on deployment actions. */ deploymentId?: string; /** The scope instance slug. */ scopeSlug: string; /** The service entity id (lifecycle reporting target). */ serviceId: string; /** Traffic weight, on switch-traffic. */ weight?: number; /** The asset (image) being deployed, on deployment actions. */ assetUrl?: string; /** The user's configured attribute values (your schema's namespace). */ attributes: A; /** Structured action logger — lines show live on the action in the console. */ log: ActionLogger; /** * Authenticated platform API: NP_API_KEY is exchanged for an access token * (cached, refreshed before expiry, retried once on 401) by the shared auth * module — handlers never hand-roll an HTTP client again. */ api: typeof platformApi; /** * The entity hierarchy, lazily resolved and memoized: scope, application, * namespace, account, deployment, release, assets, parameters, providers — * what `np service workflow build-context` builds for YAML workflows. */ entity: EntityContext; /** * The unit of work: logs start/outcome with elapsed time, is the retry and * timeout boundary, and names itself when it fails. */ step: StepFn; /** * The action's own narrative on the trace — the words the console leads * with for this run: `trace.explain({ title, what, why, next })`, a * `trace.link(...)` out, a `trace.output(name, value)` worth keeping. Each * step's narrator is the argument of its `ctx.step` callback. Silent when * tracing is off. */ trace: Narrative; /** * Deliberately narrow. Deployment orchestration — status transitions, * strategy stepping, finalization — belongs to the platform's orchestrator; * a plugin only answers the actions it is sent. reportTraffic is the one * exception because the platform does not read traffic off action results: * the PATCH is the handler's own obligation, so the SDK does it correctly. */ deployment: { reportTraffic(switchedTraffic: number): Promise }; /** Raw stdout/stderr escape hatch (log is built on it). */ emit: EmitFn; /** Scope infra state (read/merge). No-ops with a note off-scope. */ state: ScopeState; /** The raw wire payload (snake_case) — the escape hatch of last resort. */ notification: Notification; } export function createActionLogger(emit: EmitFn): ActionLogger { // The platform's log frame renders ANSI, so the base marks carry color: // steps in the accent, detail dimmed, outcomes green/red. NO_COLOR opts out. return { step: (msg) => emit({ stdout: `${cyan("▸")} ${msg}` }), detail: (msg) => emit({ stdout: dim(" · ") + msg }), ok: (msg) => emit({ stdout: `${green("✓")} ${msg}` }), fail: (msg) => emit({ stderr: `${red("✗")} ${msg}` }), }; } export function buildActionContext>( notification: Notification, emit: EmitFn, state: ScopeState, deps: { api?: typeof platformApi; entityApi?: EntityApi } = {} ): ActionContext { const params = (notification.parameters ?? {}) as Record; const attrs = (notification.service?.attributes ?? {}) as Record< string, unknown >; const scopeRef = (notification as { scope?: { id?: unknown } }).scope; const api = deps.api ?? platformApi; const scopeId = firstString(params.scope_id, scopeRef?.id); const deploymentId = params.deployment_id != null ? String(params.deployment_id) : undefined; const log = createActionLogger(emit); return { scopeId, deploymentId, scopeSlug: notification.service?.slug ?? (notification.tags as Record | undefined)?.scope ?? "", serviceId: notification.service?.id ?? "", // The platform's switch-traffic carries `desired_traffic`; `weight` was this SDK's own name for it. weight: params.desired_traffic != null ? Number(params.desired_traffic) : params.weight != null ? Number(params.weight) : undefined, assetUrl: typeof attrs.asset_url === "string" ? attrs.asset_url : undefined, attributes: attrs as A, log, api, entity: createEntityContext( notification, scopeId, deploymentId, deps.entityApi ?? api ), step: createStepRunner(log), trace: silentNarrative, deployment: { reportTraffic: (switchedTraffic: number) => { if (!deploymentId) { return Promise.reject( new Error("reportTraffic: this action carries no deployment_id") ); } return api.reportSwitchedTraffic(deploymentId, switchedTraffic); }, }, emit, state, notification, }; } function firstString(...vals: unknown[]): string { for (const v of vals) { if (v != null && v !== "") return String(v); } return ""; }