@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 +4 -2
- package/src/config.ts +8 -0
- package/src/internal/grpc-server.ts +10 -9
- package/src/internal/manifest.ts +5 -1
- package/src/platform/client.ts +109 -0
- package/src/platform/index.ts +8 -0
- package/src/scope/index.ts +14 -17
- package/src/service/describe.ts +43 -0
- package/src/service/index.ts +173 -0
- package/src/testing/plugin-client.ts +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nullplatform/plugin",
|
|
3
|
-
"version": "0.0.4-alpha.
|
|
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
|
|
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
|
-
//
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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,
|
package/src/internal/manifest.ts
CHANGED
|
@@ -19,7 +19,11 @@ export function registerManifest(manifest: PluginManifest): void {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
export function loadManifest(): PluginManifest {
|
|
22
|
-
//
|
|
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
|
+
}
|
package/src/scope/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
223
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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",
|