@nullplatform/plugin 0.0.4-alpha.1 → 0.0.4-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nullplatform/plugin",
3
- "version": "0.0.4-alpha.1",
3
+ "version": "0.0.4-alpha.2",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -9,7 +9,9 @@
9
9
  "./schema": "./src/schema.ts",
10
10
  "./scope": "./src/scope/index.ts",
11
11
  "./testing": "./src/testing/index.ts",
12
- "./workflow": "./src/workflow.ts"
12
+ "./workflow": "./src/workflow.ts",
13
+ "./service": "./src/service/index.ts",
14
+ "./platform": "./src/platform/index.ts"
13
15
  },
14
16
  "dependencies": {
15
17
  "@grpc/grpc-js": "^1.13.0",
package/src/config.ts CHANGED
@@ -39,6 +39,14 @@ export const cfg = {
39
39
  /** Agent transport mode marker. env: NP_AGENT_PLUGIN */
40
40
  agentMode: () => read("agent.mode", "NP_AGENT_PLUGIN"),
41
41
 
42
+ /**
43
+ * Execution mode: "dev" | "test" | "" (production/agent). Set explicitly by
44
+ * `np package dev` / `np package test`. When "dev" or "test" the SDK uses the
45
+ * in-memory platform client and NEVER touches the real API — even if an API
46
+ * key is present in the environment. env: NP_MODE
47
+ */
48
+ mode: () => read("mode", "NP_MODE"),
49
+
42
50
  /** The action payload injected in subprocess-exec mode. env: NP_ACTION_CONTEXT */
43
51
  actionContext: () => read("agent.actionContext", "NP_ACTION_CONTEXT"),
44
52
 
@@ -22,11 +22,13 @@
22
22
 
23
23
  import * as grpc from "@grpc/grpc-js";
24
24
  import * as protoLoader from "@grpc/proto-loader";
25
- import { unlinkSync } from "fs";
25
+ import { unlinkSync, writeFileSync } from "fs";
26
26
  import { tmpdir } from "os";
27
- import { join, resolve, dirname } from "path";
28
- import { fileURLToPath } from "url";
27
+ import { join } from "path";
29
28
  import type { PluginHandler, ExecuteRequest, PluginManifest } from "../types";
29
+ // Embed the proto as text so it survives `bun build --compile` (a compiled
30
+ // binary has no plugin.proto on disk next to the source). Works from source too.
31
+ import protoSource from "./plugin.proto" with { type: "text" };
30
32
 
31
33
  const MAGIC_COOKIE_KEY = "NP_AGENT_PLUGIN";
32
34
  const MAGIC_COOKIE_VALUE = "np-agent-v1";
@@ -158,12 +160,11 @@ export function startGrpcServer(
158
160
  handler,
159
161
  manifest,
160
162
  };
161
- // Resolve proto path import.meta.dir is Bun-only; fall back to
162
- // import.meta.url (standard ESM) for Node.js compatibility.
163
- const protoDir = typeof import.meta.dir === "string"
164
- ? import.meta.dir
165
- : dirname(fileURLToPath(import.meta.url));
166
- const protoPath = resolve(protoDir, "plugin.proto");
163
+ // @grpc/proto-loader loads from a file path, so materialize the embedded proto
164
+ // text to a temp file. This is the one path that works both from source and
165
+ // from a compiled single-file binary.
166
+ const protoPath = join(tmpdir(), `np-plugin-${process.pid}.proto`);
167
+ writeFileSync(protoPath, protoSource);
167
168
  const packageDefinition = protoLoader.loadSync(protoPath, {
168
169
  keepCase: true,
169
170
  longs: String,
@@ -19,7 +19,11 @@ export function registerManifest(manifest: PluginManifest): void {
19
19
  }
20
20
 
21
21
  export function loadManifest(): PluginManifest {
22
- // If a manifest was registered in-memory (by defineScope), use it
22
+ // Two ways a plugin declares its manifest (name/version/command_types):
23
+ // 1. defineScope() registers it in-memory at import time — the common path;
24
+ // scope plugins never need a plugin.yaml.
25
+ // 2. Raw createPlugin() plugins (no defineScope) fall back to a plugin.yaml
26
+ // file on disk. Override its path with NP_PLUGIN_MANIFEST.
23
27
  if (registeredManifest) {
24
28
  return registeredManifest;
25
29
  }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * PlatformClient — the ONE port through which a plugin talks to the control
3
+ * plane. A handler never calls the platform API directly; the SDK injects a
4
+ * client and swaps the implementation by mode:
5
+ *
6
+ * - HttpPlatformClient real API calls (the dockerized agent / `np package run`)
7
+ * - InMemoryPlatformClient records calls, prints locally, never networks
8
+ * (`np package dev`, `np package test`)
9
+ *
10
+ * This is the pattern every comparable system converges on (Temporal activity
11
+ * context, Crossplane ExternalClient, Terraform provider client, go-plugin host
12
+ * services): local dev is isolated by construction, not by the accident of a
13
+ * missing credential.
14
+ */
15
+
16
+ import { cfg } from "../config";
17
+ import { patchActionStatus, patchActionMessages, type ActionStatus } from "../compat";
18
+
19
+ export type { ActionStatus };
20
+ export type LogStream = "stdout" | "stderr";
21
+
22
+ export interface PlatformClient {
23
+ /** How this client is wired — "http" hits the real API, "memory" stays local. */
24
+ readonly mode: "http" | "memory";
25
+
26
+ /** Report an action lifecycle transition (in_progress → success/failed). */
27
+ transitionAction(
28
+ serviceId: string,
29
+ actionId: string,
30
+ status: ActionStatus,
31
+ results?: Record<string, unknown>,
32
+ ): Promise<void>;
33
+
34
+ /** Append a log line to the action (shows live in the UI as it runs). */
35
+ emitLog(serviceId: string, actionId: string, line: string, stream: LogStream): Promise<void>;
36
+ }
37
+
38
+ /** Real client: talks to the nullplatform API. Used by the agent / `np package run`. */
39
+ export class HttpPlatformClient implements PlatformClient {
40
+ readonly mode = "http" as const;
41
+
42
+ async transitionAction(
43
+ serviceId: string,
44
+ actionId: string,
45
+ status: ActionStatus,
46
+ results?: Record<string, unknown>,
47
+ ): Promise<void> {
48
+ await patchActionStatus(serviceId, actionId, status, results);
49
+ }
50
+
51
+ async emitLog(serviceId: string, actionId: string, line: string, stream: LogStream): Promise<void> {
52
+ const message = line.trimEnd();
53
+ if (!message) return;
54
+ // Local visibility: logs go to STDERR (stdout is reserved for the result
55
+ // JSON the agent parses). This is the standard data-on-stdout /
56
+ // logs-on-stderr split. The platform UI gets the same line as a properly
57
+ // leveled action message below.
58
+ process.stderr.write(message + "\n");
59
+ await patchActionMessages(serviceId, actionId, [
60
+ { level: stream === "stderr" ? "error" : "info", message },
61
+ ]);
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Fake client: records every call and echoes logs to the terminal, but makes NO
67
+ * network calls. Used for `np package dev` and tests so a plugin runs fully
68
+ * local. Exposed calls are inspectable via `.calls` for assertions.
69
+ */
70
+ export class InMemoryPlatformClient implements PlatformClient {
71
+ readonly mode = "memory" as const;
72
+
73
+ readonly calls: Array<
74
+ | { kind: "transition"; actionId: string; status: ActionStatus; results?: Record<string, unknown> }
75
+ | { kind: "log"; actionId: string; line: string; stream: LogStream }
76
+ > = [];
77
+
78
+ constructor(private readonly opts: { echo?: boolean } = { echo: true }) {}
79
+
80
+ async transitionAction(
81
+ _serviceId: string,
82
+ actionId: string,
83
+ status: ActionStatus,
84
+ results?: Record<string, unknown>,
85
+ ): Promise<void> {
86
+ this.calls.push({ kind: "transition", actionId, status, results });
87
+ if (this.opts.echo) process.stderr.write(` • action ${status}\n`);
88
+ }
89
+
90
+ async emitLog(_serviceId: string, actionId: string, line: string, stream: LogStream): Promise<void> {
91
+ const message = line.trimEnd();
92
+ if (!message) return;
93
+ this.calls.push({ kind: "log", actionId, line: message, stream });
94
+ if (this.opts.echo) process.stderr.write(` → ${message}\n`);
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Pick the client for the current process. Explicit dev/test mode ALWAYS returns
100
+ * the in-memory fake — even when NP_API_KEY is set — so local runs can never
101
+ * mutate the real platform. Only a real agent run (no dev mode + a key present)
102
+ * gets the HTTP client.
103
+ */
104
+ export function resolvePlatformClient(): PlatformClient {
105
+ const mode = cfg.mode();
106
+ if (mode === "dev" || mode === "test") return new InMemoryPlatformClient();
107
+ if (!cfg.apiKey()) return new InMemoryPlatformClient(); // no creds → stay local
108
+ return new HttpPlatformClient();
109
+ }
@@ -0,0 +1,8 @@
1
+ export {
2
+ type PlatformClient,
3
+ type ActionStatus,
4
+ type LogStream,
5
+ HttpPlatformClient,
6
+ InMemoryPlatformClient,
7
+ resolvePlatformClient,
8
+ } from "./client";
@@ -30,7 +30,7 @@ import { InMemoryEngine } from "@nullplatform/workflow-engine-in-memory";
30
30
  import { getActionDefaults } from "./actions";
31
31
  import { handleDescribe } from "./describe";
32
32
  import { handlePublish } from "./publish";
33
- import { patchActionStatus, patchActionMessages } from "../compat";
33
+ import { resolvePlatformClient } from "../platform";
34
34
  import { cfg } from "../config";
35
35
  import type { NpJSONSchema } from "../schema";
36
36
 
@@ -210,6 +210,7 @@ export function defineScope(definition: ScopeDefinition): void {
210
210
  const execCtx = cfg.actionContext();
211
211
  if (execCtx && cfg.agentMode() !== "np-agent-v1") {
212
212
  void (async () => {
213
+ const platform = resolvePlatformClient();
213
214
  let out: ExecuteResult;
214
215
  let serviceId = "";
215
216
  let actionId = "";
@@ -219,24 +220,19 @@ export function defineScope(definition: ScopeDefinition): void {
219
220
  const notification: Notification = raw.notification ?? raw;
220
221
  serviceId = notification.service?.id ?? "";
221
222
  actionId = notification.id ?? "";
222
- // emit forwards handler output to stderr (worker logs) AND, as action
223
- // messages, to the platform so they show live on the action.
223
+ // emit forwards handler output to the platform as action messages (shown
224
+ // live on the action). It routes through the injected client, so in dev
225
+ // it's recorded locally and never touches the API.
224
226
  const emit = (o: { stdout?: string; stderr?: string }) => {
225
- if (o?.stdout) {
226
- process.stderr.write(o.stdout);
227
- void patchActionMessages(serviceId, actionId, [{ level: "info", message: o.stdout.trimEnd() }]).catch(() => {});
228
- }
229
- if (o?.stderr) {
230
- process.stderr.write(o.stderr);
231
- void patchActionMessages(serviceId, actionId, [{ level: "error", message: o.stderr.trimEnd() }]).catch(() => {});
232
- }
227
+ if (o?.stdout) void platform.emitLog(serviceId, actionId, o.stdout, "stdout").catch(() => {});
228
+ if (o?.stderr) void platform.emitLog(serviceId, actionId, o.stderr, "stderr").catch(() => {});
233
229
  };
234
230
  const actionSlug = resolveActionSlug(notification, notification.type ?? "");
235
231
  const actionDef = definition.actions[actionSlug];
236
232
  if (!actionDef) throw new Error(`Unknown action: ${actionSlug}`);
237
233
  lifecycle = getActionDefaults(actionSlug).lifecycle;
238
234
  if (lifecycle && actionId) {
239
- await patchActionStatus(serviceId, actionId, "in_progress").catch(() => {});
235
+ await platform.transitionAction(serviceId, actionId, "in_progress").catch(() => {});
240
236
  }
241
237
  let data: unknown;
242
238
  if (isWorkflowChain(actionDef.handler)) {
@@ -251,13 +247,13 @@ export function defineScope(definition: ScopeDefinition): void {
251
247
  }
252
248
  if (lifecycle && actionId) {
253
249
  const results = data && typeof data === "object" ? (data as Record<string, unknown>) : undefined;
254
- await patchActionStatus(serviceId, actionId, "success", results).catch(() => {});
250
+ await platform.transitionAction(serviceId, actionId, "success", results).catch(() => {});
255
251
  }
256
252
  out = { success: true, data };
257
253
  } catch (error) {
258
254
  const message = error instanceof Error ? error.message : String(error);
259
255
  if (lifecycle && actionId) {
260
- await patchActionStatus(serviceId, actionId, "failed", { error: message }).catch(() => {});
256
+ await platform.transitionAction(serviceId, actionId, "failed", { error: message }).catch(() => {});
261
257
  }
262
258
  out = { success: false, error: message, errorCode: "EXECUTION_ERROR" };
263
259
  }
@@ -267,6 +263,7 @@ export function defineScope(definition: ScopeDefinition): void {
267
263
  return;
268
264
  }
269
265
 
266
+ const platform = resolvePlatformClient();
270
267
  const plugin = createPlugin({
271
268
  async execute(req: ExecuteRequest): Promise<ExecuteResult> {
272
269
  // Handle both native "scope" and legacy "exec" command types
@@ -297,7 +294,7 @@ export function defineScope(definition: ScopeDefinition): void {
297
294
  const serviceId = notification.service?.id ?? "";
298
295
 
299
296
  if (defaults.lifecycle && actionId) {
300
- await patchActionStatus(serviceId, actionId, "in_progress").catch((err) => {
297
+ await platform.transitionAction(serviceId, actionId, "in_progress").catch((err) => {
301
298
  console.error(`[defineScope] Failed to patch action status: ${err.message}`);
302
299
  });
303
300
  }
@@ -326,7 +323,7 @@ export function defineScope(definition: ScopeDefinition): void {
326
323
 
327
324
  if (defaults.lifecycle && actionId) {
328
325
  const results = result && typeof result === "object" ? (result as Record<string, unknown>) : undefined;
329
- await patchActionStatus(serviceId, actionId, "success", results).catch((err) => {
326
+ await platform.transitionAction(serviceId, actionId, "success", results).catch((err) => {
330
327
  console.error(`[defineScope] Failed to patch action status: ${err.message}`);
331
328
  });
332
329
  }
@@ -336,7 +333,7 @@ export function defineScope(definition: ScopeDefinition): void {
336
333
  const message = error instanceof Error ? error.message : String(error);
337
334
 
338
335
  if (defaults.lifecycle && actionId) {
339
- await patchActionStatus(serviceId, actionId, "failed", { error: message }).catch((err) => {
336
+ await platform.transitionAction(serviceId, actionId, "failed", { error: message }).catch((err) => {
340
337
  console.error(`[defineScope] Failed to patch action status: ${err.message}`);
341
338
  });
342
339
  }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * --describe for a service package. Prints the manifest as JSON. The `kind:
3
+ * "service"` marker tells the CLI to register a dependency service_specification
4
+ * (with use_default_actions) instead of a scope + scope type.
5
+ */
6
+
7
+ import { resolveAgentConfig } from "../scope";
8
+ import type { ServiceDefinition } from "./index";
9
+
10
+ export function handleServiceDescribe(definition: ServiceDefinition): void {
11
+ // Only CUSTOM actions are described — the platform generates create/update/
12
+ // delete when useDefaultActions is on, so we don't ship specs for them.
13
+ const defaultTypes = new Set(["create", "update", "delete"]);
14
+ const actionSpecs: Record<string, unknown> = {};
15
+ for (const [slug, action] of Object.entries(definition.actions)) {
16
+ if (definition.useDefaultActions !== false && defaultTypes.has(slug)) continue;
17
+ actionSpecs[slug] = {
18
+ name: action.name ?? slug,
19
+ type: action.type ?? "custom",
20
+ retryable: action.retryable ?? false,
21
+ lifecycle: true,
22
+ input: action.input,
23
+ output: action.output,
24
+ };
25
+ }
26
+
27
+ const output = {
28
+ kind: "service",
29
+ name: definition.name,
30
+ version: definition.version,
31
+ description: definition.description,
32
+ category: definition.category,
33
+ provider: definition.provider,
34
+ schema: definition.schema,
35
+ uiSchema: definition.uiSchema,
36
+ useDefaultActions: definition.useDefaultActions ?? true,
37
+ assignableTo: definition.assignableTo ?? "any",
38
+ actions: actionSpecs,
39
+ agent: resolveAgentConfig(definition as any),
40
+ };
41
+
42
+ console.log(JSON.stringify(JSON.parse(JSON.stringify(output)), null, 2));
43
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * defineService() — define a self-describing SERVICE package.
3
+ *
4
+ * A service is an application *dependency* (a database, cache, queue, …), not a
5
+ * deployment target. It differs from a scope:
6
+ * - the service_specification is `type: "dependency"` (no scope type),
7
+ * - with `useDefaultActions` the platform auto-generates create/update/delete
8
+ * action specs from the schema — you only write handlers, not specs,
9
+ * - action slugs are dynamic (`create-<name>`), so handlers are keyed by the
10
+ * action TYPE (create/update/delete) or a custom slug.
11
+ *
12
+ * The runtime (subprocess-exec + gRPC, lifecycle reporting) mirrors defineScope.
13
+ */
14
+
15
+ import { createPlugin } from "../create-plugin";
16
+ import type { ExecuteRequest, ExecuteResult } from "../types";
17
+ import { registerManifest } from "../internal/manifest";
18
+ import { StreamingExecutionObserver } from "../workflow";
19
+ import { InMemoryEngine } from "@nullplatform/workflow-engine-in-memory";
20
+ import { resolvePlatformClient } from "../platform";
21
+ import { cfg } from "../config";
22
+ import { resolveAgentConfig, type ScopeAgentConfig, type ActionDefinition, type Notification } from "../scope";
23
+ import { handleServiceDescribe } from "./describe";
24
+ import type { NpJSONSchema } from "../schema";
25
+
26
+ export interface ServiceDefinition {
27
+ name: string;
28
+ version: string;
29
+ description?: string;
30
+ category?: string;
31
+ provider?: string;
32
+
33
+ /** JSON Schema for the service's attributes (its configurable properties). */
34
+ schema: NpJSONSchema;
35
+
36
+ /** JSONForms UISchema for the attributes form. */
37
+ uiSchema?: object;
38
+
39
+ /**
40
+ * Let the platform auto-generate create/update/delete action specs from the
41
+ * schema (default true). Set false to declare all action specs yourself.
42
+ */
43
+ useDefaultActions?: boolean;
44
+
45
+ /** Where the service can be assigned. Default "any". */
46
+ assignableTo?: "any" | "dimension" | "scope";
47
+
48
+ /** How the platform routes actions to the worker. Defaults from `name`. */
49
+ agent?: ScopeAgentConfig;
50
+
51
+ /**
52
+ * Handlers, keyed by action type (`create`/`update`/`delete`) for the default
53
+ * actions, and by slug for any custom action.
54
+ */
55
+ actions: Record<string, ActionDefinition>;
56
+ }
57
+
58
+ function isWorkflowChain(handler: unknown): handler is { run: Function; toGraph: Function } {
59
+ return !!handler && typeof (handler as any).run === "function" && typeof (handler as any).toGraph === "function";
60
+ }
61
+
62
+ // resolveServiceHandler maps a notification to a handler. Default actions arrive
63
+ // with a dynamic slug (`create-<name>`) but a stable `type` (create/update/
64
+ // delete), so we try: exact slug → action type → the type-prefixed slug.
65
+ function resolveServiceHandler(def: ServiceDefinition, notification: Notification): { key: string; action?: ActionDefinition } {
66
+ const slug = notification.slug || notification.specification?.slug || "";
67
+ const type = String((notification as any).type ?? "");
68
+ if (slug && def.actions[slug]) return { key: slug, action: def.actions[slug] };
69
+ if (type && def.actions[type]) return { key: type, action: def.actions[type] };
70
+ // "create-postgres" → "create"
71
+ const prefix = slug.split("-")[0];
72
+ if (prefix && def.actions[prefix]) return { key: prefix, action: def.actions[prefix] };
73
+ return { key: slug || type };
74
+ }
75
+
76
+ export function defineService(definition: ServiceDefinition): void {
77
+ if (process.argv.includes("--describe")) {
78
+ handleServiceDescribe(definition);
79
+ process.exit(0);
80
+ }
81
+
82
+ registerManifest({
83
+ name: definition.name,
84
+ version: definition.version,
85
+ command_types: ["service"],
86
+ });
87
+
88
+ // Subprocess-exec mode (how the agent runs the worker): the action arrives in
89
+ // NP_ACTION_CONTEXT; run it, report lifecycle, print the result, exit.
90
+ const execCtx = cfg.actionContext();
91
+ if (execCtx && cfg.agentMode() !== "np-agent-v1") {
92
+ void (async () => {
93
+ const platform = resolvePlatformClient();
94
+ let out: ExecuteResult;
95
+ let serviceId = "";
96
+ let actionId = "";
97
+ try {
98
+ const raw = JSON.parse(execCtx);
99
+ const notification: Notification = raw.notification ?? raw;
100
+ serviceId = notification.service?.id ?? "";
101
+ actionId = notification.id ?? "";
102
+ const emit = (o: { stdout?: string; stderr?: string }) => {
103
+ if (o?.stdout) void platform.emitLog(serviceId, actionId, o.stdout, "stdout").catch(() => {});
104
+ if (o?.stderr) void platform.emitLog(serviceId, actionId, o.stderr, "stderr").catch(() => {});
105
+ };
106
+ const { key, action } = resolveServiceHandler(definition, notification);
107
+ if (!action) throw new Error(`Unknown service action: ${key}`);
108
+ if (actionId) await platform.transitionAction(serviceId, actionId, "in_progress").catch(() => {});
109
+ let data: unknown;
110
+ if (isWorkflowChain(action.handler)) {
111
+ const observer = new StreamingExecutionObserver(emit, { apiUrl: cfg.executionsApiUrl() });
112
+ observer.emitPlan((action.handler as any).toGraph());
113
+ const engine = new InMemoryEngine({ observer });
114
+ const wf = await (action.handler as any).run(notification, { engine, timeoutMs: 10 * 60 * 1000 });
115
+ if (wf.status !== "completed") throw new Error(wf.error?.message ?? `Workflow ${wf.status}`);
116
+ data = wf.output;
117
+ } else {
118
+ data = await (action.handler as any)(notification, emit);
119
+ }
120
+ if (actionId) {
121
+ const results = data && typeof data === "object" ? (data as Record<string, unknown>) : undefined;
122
+ await platform.transitionAction(serviceId, actionId, "success", results).catch(() => {});
123
+ }
124
+ out = { success: true, data };
125
+ } catch (error) {
126
+ const message = error instanceof Error ? error.message : String(error);
127
+ if (actionId) await platform.transitionAction(serviceId, actionId, "failed", { error: message }).catch(() => {});
128
+ out = { success: false, error: message, errorCode: "EXECUTION_ERROR" };
129
+ }
130
+ process.stdout.write(JSON.stringify(out));
131
+ process.exit(out.success ? 0 : 1);
132
+ })();
133
+ return;
134
+ }
135
+
136
+ // gRPC host path (dev / np-agent-v1).
137
+ const platform = resolvePlatformClient();
138
+ const plugin = createPlugin({
139
+ async execute(req: ExecuteRequest): Promise<ExecuteResult> {
140
+ let notification: Notification;
141
+ try {
142
+ const raw = JSON.parse(req.payload.toString("utf-8"));
143
+ notification = raw.notification ?? raw;
144
+ } catch {
145
+ return { success: false, error: "invalid payload", errorCode: "MISSING_CONTEXT" };
146
+ }
147
+ const { key, action } = resolveServiceHandler(definition, notification);
148
+ if (!action) return { success: false, error: `Unknown service action: ${key}`, errorCode: "UNKNOWN_ACTION" };
149
+ const actionId = notification.id;
150
+ const serviceId = notification.service?.id ?? "";
151
+ if (actionId) await platform.transitionAction(serviceId, actionId, "in_progress").catch(() => {});
152
+ try {
153
+ const result = await (action.handler as any)(notification, req.emit);
154
+ if (actionId) {
155
+ const results = result && typeof result === "object" ? (result as Record<string, unknown>) : undefined;
156
+ await platform.transitionAction(serviceId, actionId, "success", results).catch(() => {});
157
+ }
158
+ return { success: true, data: result };
159
+ } catch (error) {
160
+ const message = error instanceof Error ? error.message : String(error);
161
+ if (actionId) await platform.transitionAction(serviceId, actionId, "failed", { error: message }).catch(() => {});
162
+ return { success: false, error: message, errorCode: "EXECUTION_ERROR" };
163
+ }
164
+ },
165
+ });
166
+ plugin.start();
167
+ }
168
+
169
+ export { resolveAgentConfig };
170
+ // Author-facing types: a service handler receives the action notification and an
171
+ // `emit` callback. `ServiceActionInput` is the notification shape (aliased from
172
+ // the shared Notification type) so action files read as service code.
173
+ export type { Notification as ServiceActionInput, ActionDefinition, ScopeAgentConfig } from "../scope";
@@ -53,6 +53,10 @@ export async function startPluginProcess(
53
53
  ...process.env,
54
54
  ...opts?.env,
55
55
  NP_AGENT_PLUGIN: "np-agent-v1",
56
+ // This helper only ever runs a plugin for local dev/test (the real agent
57
+ // execs the binary directly). Force in-memory platform mode so a dev/test
58
+ // run can never hit the real API, even if NP_API_KEY is in the env.
59
+ NP_MODE: process.env.NP_MODE || "dev",
56
60
  },
57
61
  stdout: "pipe",
58
62
  stderr: "pipe",