@nullplatform/plugin 0.0.3 → 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/README.md +59 -8
- package/package.json +6 -2
- package/src/api.ts +5 -3
- package/src/compat/index.ts +14 -0
- package/src/compat/lifecycle.ts +84 -0
- package/src/config.ts +55 -0
- package/src/internal/grpc-server.ts +10 -9
- package/src/internal/manifest.ts +9 -3
- package/src/platform/client.ts +109 -0
- package/src/platform/index.ts +8 -0
- package/src/scope/describe.ts +4 -1
- package/src/scope/index.ts +93 -5
- package/src/scope/publish.ts +2 -1
- package/src/service/describe.ts +43 -0
- package/src/service/index.ts +173 -0
- package/src/testing/plugin-client.ts +4 -0
- package/src/scope/lifecycle.ts +0 -39
package/README.md
CHANGED
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
<br>
|
|
9
9
|
</h2>
|
|
10
10
|
|
|
11
|
-
TypeScript SDK for building nullplatform plugins. Handles
|
|
11
|
+
TypeScript SDK for building nullplatform plugins. Handles transport (gRPC
|
|
12
|
+
go-plugin **and** subprocess-exec), action routing, action lifecycle reporting,
|
|
13
|
+
`--describe`, and `--publish`.
|
|
12
14
|
|
|
13
15
|
## Install
|
|
14
16
|
|
|
@@ -16,6 +18,17 @@ TypeScript SDK for building nullplatform plugins. Handles gRPC transport (HashiC
|
|
|
16
18
|
bun add @nullplatform/plugin
|
|
17
19
|
```
|
|
18
20
|
|
|
21
|
+
## Layers
|
|
22
|
+
|
|
23
|
+
The SDK is layered so the stable core is insulated from today's rough platform
|
|
24
|
+
APIs:
|
|
25
|
+
|
|
26
|
+
- **base** — the plugin runtime, transport (gRPC + subprocess-exec), and the
|
|
27
|
+
action-lifecycle *protocol*. Stable.
|
|
28
|
+
- **compat** (`src/compat/`) — temporary shims for current APIs: token exchange,
|
|
29
|
+
action `PATCH` shapes. Each is deletable in isolation once the real API lands.
|
|
30
|
+
- **goal layers** — `defineScope` today; `defineService`, `defineHook` next.
|
|
31
|
+
|
|
19
32
|
## Usage
|
|
20
33
|
|
|
21
34
|
### Scope plugin
|
|
@@ -36,18 +49,51 @@ defineScope({
|
|
|
36
49
|
},
|
|
37
50
|
},
|
|
38
51
|
|
|
52
|
+
// Optional — how the platform routes actions to your worker. Defaults to
|
|
53
|
+
// selector { package: <name> } and entrypoint /app/packages/<name>/entrypoint.
|
|
54
|
+
agent: {
|
|
55
|
+
selector: { package: "kubernetes-k3d" },
|
|
56
|
+
},
|
|
57
|
+
|
|
39
58
|
actions: {
|
|
40
59
|
"create-scope": {
|
|
41
60
|
input: { type: "object" },
|
|
42
61
|
handler: async (notification, emit) => {
|
|
62
|
+
emit({ stdout: "Provisioning…" }); // live message on the action + worker logs
|
|
43
63
|
// provision infrastructure
|
|
44
|
-
return { domain: "app.k3d.local" };
|
|
64
|
+
return { domain: "app.k3d.local" }; // becomes the action results
|
|
45
65
|
},
|
|
46
66
|
},
|
|
47
67
|
},
|
|
48
68
|
});
|
|
49
69
|
```
|
|
50
70
|
|
|
71
|
+
#### Actions
|
|
72
|
+
|
|
73
|
+
`handler` is a plain `async (notification, emit) => result` (or a workflow
|
|
74
|
+
chain). The SDK reports the action lifecycle automatically: `in_progress` on
|
|
75
|
+
start, `success` with your returned object, or `failed` on throw. `emit({ stdout
|
|
76
|
+
})` streams a live message onto the action in the UI.
|
|
77
|
+
|
|
78
|
+
Well-known action slugs get their metadata (name, type, retryable, lifecycle)
|
|
79
|
+
for free — `create-scope`, `delete-scope`, `update-scope`, `start-initial`,
|
|
80
|
+
`start-blue-green`, `switch-traffic`, `finalize-blue-green`,
|
|
81
|
+
`rollback-deployment`, `delete-deployment`, `diagnose-scope`,
|
|
82
|
+
`diagnose-deployment`, `kill-instances`, `restart-pods`, `pause-autoscaling`,
|
|
83
|
+
`resume-autoscaling`, `set-desired-instance-count`. For a **custom** action, use
|
|
84
|
+
any slug and declare `name`/`type` inline:
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
"say-hello": { name: "Say Hello", type: "custom", input, handler },
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
#### Transport
|
|
91
|
+
|
|
92
|
+
Agents run the compiled plugin directly (subprocess-exec): the action arrives in
|
|
93
|
+
`NP_ACTION_CONTEXT`, the plugin runs it, reports status, prints the result, and
|
|
94
|
+
exits. The gRPC go-plugin server is also supported for hosts that use it. You
|
|
95
|
+
write handlers; the SDK picks the transport.
|
|
96
|
+
|
|
51
97
|
### Simple plugin (custom command)
|
|
52
98
|
|
|
53
99
|
```typescript
|
|
@@ -74,12 +120,17 @@ createPlugin({
|
|
|
74
120
|
|
|
75
121
|
## CLI integration
|
|
76
122
|
|
|
77
|
-
|
|
123
|
+
Packages are scaffolded and driven with the `np package` commands, which are
|
|
124
|
+
thin wrappers over the template's own tasks:
|
|
78
125
|
|
|
79
126
|
```bash
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
127
|
+
np package init # scaffold from a git template
|
|
128
|
+
np package dev # local dev UI + hot reload (→ mise/bun run dev)
|
|
129
|
+
np package test # run tests (→ mise/bun run test)
|
|
130
|
+
np package build --image # build the worker image
|
|
131
|
+
np package run # run the worker as a real local agent (docker)
|
|
132
|
+
np package publish # register on the platform (specs + channel + artifact)
|
|
85
133
|
```
|
|
134
|
+
|
|
135
|
+
`publish` reads the manifest from `--describe`, so the SDK owns the manifest
|
|
136
|
+
shape and the CLI stays decoupled from it. See the CLI's `docs/packages.md`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nullplatform/plugin",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.4-alpha.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
@@ -9,11 +9,14 @@
|
|
|
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",
|
|
16
18
|
"@grpc/proto-loader": "^0.7.0",
|
|
19
|
+
"config": "^4.4.2",
|
|
17
20
|
"yaml": "^2.7.0"
|
|
18
21
|
},
|
|
19
22
|
"files": [
|
|
@@ -46,6 +49,7 @@
|
|
|
46
49
|
},
|
|
47
50
|
"devDependencies": {
|
|
48
51
|
"@types/bun": "^1.2.0",
|
|
52
|
+
"@types/config": "^4.4.0",
|
|
49
53
|
"json-schema-to-ts": "^3.1.1"
|
|
50
54
|
}
|
|
51
55
|
}
|
package/src/api.ts
CHANGED
|
@@ -14,13 +14,15 @@
|
|
|
14
14
|
|
|
15
15
|
// ─── Configuration ───────────────────────────────────────────────────
|
|
16
16
|
|
|
17
|
+
import { cfg } from "./config";
|
|
18
|
+
|
|
17
19
|
const TIMEOUT_MS = 30_000;
|
|
18
20
|
|
|
19
21
|
function getConfig() {
|
|
20
|
-
const apiUrl =
|
|
21
|
-
const apiKey =
|
|
22
|
+
const apiUrl = cfg.apiUrl();
|
|
23
|
+
const apiKey = cfg.apiKey();
|
|
22
24
|
if (!apiKey) {
|
|
23
|
-
throw new Error("
|
|
25
|
+
throw new Error("NP_API_KEY is not set");
|
|
24
26
|
}
|
|
25
27
|
return { apiUrl, apiKey };
|
|
26
28
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* COMPAT LAYER — temporary shims for today's platform APIs.
|
|
3
|
+
*
|
|
4
|
+
* Anything in here exists only because a platform API is missing or awkward
|
|
5
|
+
* right now. Each shim should be deletable in isolation once the real API
|
|
6
|
+
* lands. The stable base + goal layers import from here; nothing here should
|
|
7
|
+
* depend on them.
|
|
8
|
+
*/
|
|
9
|
+
export {
|
|
10
|
+
patchActionStatus,
|
|
11
|
+
patchActionMessages,
|
|
12
|
+
type ActionStatus,
|
|
13
|
+
type ActionMessage,
|
|
14
|
+
} from "./lifecycle";
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* COMPAT LAYER — temporary. Delete as the platform APIs improve.
|
|
3
|
+
*
|
|
4
|
+
* Action lifecycle management, working around today's API shapes:
|
|
5
|
+
* - PATCH /service/{serviceId}/action/{actionId} { status, results } for
|
|
6
|
+
* in_progress / success / failed (no dedicated action-status endpoint yet).
|
|
7
|
+
* - The API expects a Bearer *token*, so NP_API_KEY is exchanged for
|
|
8
|
+
* an access token (POST /token) and cached (no direct api-key auth yet).
|
|
9
|
+
*
|
|
10
|
+
* Everything here is a shim for a rough edge. When the platform exposes a
|
|
11
|
+
* first-class action-lifecycle API, this whole file goes away and the base
|
|
12
|
+
* layer talks to it directly. Keep base-layer code OUT of here.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { cfg } from "../config";
|
|
16
|
+
|
|
17
|
+
const API_URL = () => cfg.apiUrl();
|
|
18
|
+
const API_KEY = () => cfg.apiKey();
|
|
19
|
+
|
|
20
|
+
let cachedToken: string | undefined;
|
|
21
|
+
|
|
22
|
+
async function getToken(): Promise<string> {
|
|
23
|
+
if (cachedToken) return cachedToken;
|
|
24
|
+
const res = await fetch(`${API_URL()}/token`, {
|
|
25
|
+
method: "POST",
|
|
26
|
+
headers: { "Content-Type": "application/json" },
|
|
27
|
+
body: JSON.stringify({ apikey: API_KEY() }),
|
|
28
|
+
});
|
|
29
|
+
if (!res.ok) throw new Error(`Token exchange failed: ${res.status} ${res.statusText}`);
|
|
30
|
+
const data = (await res.json()) as { access_token?: string };
|
|
31
|
+
if (!data.access_token) throw new Error("Token exchange returned no access_token");
|
|
32
|
+
cachedToken = data.access_token;
|
|
33
|
+
return cachedToken;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type ActionStatus = "in_progress" | "success" | "failed";
|
|
37
|
+
export interface ActionMessage {
|
|
38
|
+
level: "info" | "warning" | "error";
|
|
39
|
+
message: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// patchAction PATCHes the action with any of status / results / messages. It's
|
|
43
|
+
// the single entry point for reporting; status/messages helpers wrap it.
|
|
44
|
+
async function patchAction(serviceId: string, actionId: string, body: Record<string, unknown>): Promise<void> {
|
|
45
|
+
if (!API_KEY()) return; // skip lifecycle if no API key (local dev / testing)
|
|
46
|
+
if (!serviceId || !actionId) return;
|
|
47
|
+
|
|
48
|
+
const token = await getToken();
|
|
49
|
+
const res = await fetch(`${API_URL()}/service/${serviceId}/action/${actionId}`, {
|
|
50
|
+
method: "PATCH",
|
|
51
|
+
headers: {
|
|
52
|
+
Authorization: `Bearer ${token}`,
|
|
53
|
+
"Content-Type": "application/json",
|
|
54
|
+
},
|
|
55
|
+
body: JSON.stringify(body),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
if (!res.ok) {
|
|
59
|
+
const text = await res.text().catch(() => "");
|
|
60
|
+
throw new Error(`Action PATCH failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ""}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function patchActionStatus(
|
|
65
|
+
serviceId: string,
|
|
66
|
+
actionId: string,
|
|
67
|
+
status: ActionStatus,
|
|
68
|
+
results?: Record<string, unknown>,
|
|
69
|
+
): Promise<void> {
|
|
70
|
+
const body: Record<string, unknown> = { status };
|
|
71
|
+
if (results) body.results = results;
|
|
72
|
+
return patchAction(serviceId, actionId, body);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// patchActionMessages appends log lines to the action (what shows in the UI as
|
|
76
|
+
// the action runs) — the SDK equivalent of `np service-action exec --live-report`.
|
|
77
|
+
export async function patchActionMessages(
|
|
78
|
+
serviceId: string,
|
|
79
|
+
actionId: string,
|
|
80
|
+
messages: ActionMessage[],
|
|
81
|
+
): Promise<void> {
|
|
82
|
+
if (messages.length === 0) return;
|
|
83
|
+
return patchAction(serviceId, actionId, { messages });
|
|
84
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Central configuration — the ONE place the SDK reads environment.
|
|
3
|
+
*
|
|
4
|
+
* Values come from node-config (a package's `config/` dir: `default.json` for
|
|
5
|
+
* defaults + `custom-environment-variables.json` mapping env vars → keys), which
|
|
6
|
+
* is the single declarative place every env var is documented. If a package
|
|
7
|
+
* ships no config/ (or a key isn't mapped), we fall back to the raw env var and
|
|
8
|
+
* then a built-in default — so the SDK works with or without node-config.
|
|
9
|
+
*
|
|
10
|
+
* Nothing else in the SDK touches `process.env`. Import { cfg } from here.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import config from "config";
|
|
14
|
+
|
|
15
|
+
// read resolves a value in priority order: node-config → raw env var → default.
|
|
16
|
+
// This module is the only place `process.env` is read.
|
|
17
|
+
function read(key: string, envVar: string, fallback = ""): string {
|
|
18
|
+
try {
|
|
19
|
+
if (config.has(key)) {
|
|
20
|
+
const v = config.get(key);
|
|
21
|
+
if (v != null && v !== "") return String(v);
|
|
22
|
+
}
|
|
23
|
+
} catch {
|
|
24
|
+
// node-config not configured for this process (no config/ dir) — fall through.
|
|
25
|
+
}
|
|
26
|
+
return process.env[envVar] ?? fallback;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const cfg = {
|
|
30
|
+
/** Platform API base URL. env: NP_API_URL */
|
|
31
|
+
apiUrl: () => read("api.url", "NP_API_URL", "https://api.nullplatform.com"),
|
|
32
|
+
|
|
33
|
+
/** Platform API key. env: NP_API_KEY */
|
|
34
|
+
apiKey: () => read("api.key", "NP_API_KEY"),
|
|
35
|
+
|
|
36
|
+
/** Executions/streaming API URL. env: NP_EXECUTIONS_API_URL */
|
|
37
|
+
executionsApiUrl: () => read("api.executionsUrl", "NP_EXECUTIONS_API_URL"),
|
|
38
|
+
|
|
39
|
+
/** Agent transport mode marker. env: NP_AGENT_PLUGIN */
|
|
40
|
+
agentMode: () => read("agent.mode", "NP_AGENT_PLUGIN"),
|
|
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
|
+
|
|
50
|
+
/** The action payload injected in subprocess-exec mode. env: NP_ACTION_CONTEXT */
|
|
51
|
+
actionContext: () => read("agent.actionContext", "NP_ACTION_CONTEXT"),
|
|
52
|
+
|
|
53
|
+
/** Path to a plugin manifest override. env: NP_PLUGIN_MANIFEST */
|
|
54
|
+
pluginManifest: () => read("plugin.manifest", "NP_PLUGIN_MANIFEST"),
|
|
55
|
+
} as const;
|
|
@@ -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
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { readFileSync, existsSync } from "fs";
|
|
9
9
|
import { resolve } from "path";
|
|
10
10
|
import YAML from "yaml";
|
|
11
|
+
import { cfg } from "../config";
|
|
11
12
|
import type { PluginManifest } from "../types";
|
|
12
13
|
|
|
13
14
|
let registeredManifest: PluginManifest | null = null;
|
|
@@ -18,14 +19,19 @@ export function registerManifest(manifest: PluginManifest): void {
|
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
export function loadManifest(): PluginManifest {
|
|
21
|
-
//
|
|
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.
|
|
22
27
|
if (registeredManifest) {
|
|
23
28
|
return registeredManifest;
|
|
24
29
|
}
|
|
25
30
|
|
|
31
|
+
const manifestOverride = cfg.pluginManifest();
|
|
26
32
|
const manifestPath = resolve(
|
|
27
|
-
|
|
28
|
-
|
|
33
|
+
manifestOverride || process.cwd(),
|
|
34
|
+
manifestOverride ? "" : "plugin.yaml",
|
|
29
35
|
);
|
|
30
36
|
|
|
31
37
|
if (!existsSync(manifestPath)) {
|
|
@@ -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/describe.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { getActionDefaults } from "./actions";
|
|
9
|
-
import type
|
|
9
|
+
import { resolveAgentConfig, type ScopeDefinition } from "./index";
|
|
10
10
|
|
|
11
11
|
export function handleDescribe(definition: ScopeDefinition): void {
|
|
12
12
|
const actionSpecs: Record<string, unknown> = {};
|
|
@@ -33,6 +33,9 @@ export function handleDescribe(definition: ScopeDefinition): void {
|
|
|
33
33
|
schema: definition.schema,
|
|
34
34
|
actions: actionSpecs,
|
|
35
35
|
uiSchema: definition.uiSchema,
|
|
36
|
+
// Routing config with defaults resolved from the name, so the CLI applies
|
|
37
|
+
// exactly what the scope declares instead of re-deriving conventions.
|
|
38
|
+
agent: resolveAgentConfig(definition),
|
|
36
39
|
};
|
|
37
40
|
|
|
38
41
|
const cleaned = JSON.parse(JSON.stringify(output));
|
package/src/scope/index.ts
CHANGED
|
@@ -30,7 +30,8 @@ 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
|
+
import { cfg } from "../config";
|
|
34
35
|
import type { NpJSONSchema } from "../schema";
|
|
35
36
|
|
|
36
37
|
// ─── Types ───────────────────────────────────────────────────────────
|
|
@@ -84,10 +85,35 @@ export interface ScopeDefinition {
|
|
|
84
85
|
/** JSONForms UISchema for rendering the capabilities form. */
|
|
85
86
|
uiSchema?: object;
|
|
86
87
|
|
|
88
|
+
/**
|
|
89
|
+
* How the platform routes actions to the worker. Optional — sensible defaults
|
|
90
|
+
* are derived from `name`, so most scopes never set this. Override only for a
|
|
91
|
+
* custom selector or a non-standard worker entrypoint.
|
|
92
|
+
*/
|
|
93
|
+
agent?: ScopeAgentConfig;
|
|
94
|
+
|
|
87
95
|
/** Actions: schema (input/output) + handler, keyed by action slug. */
|
|
88
96
|
actions: Record<string, ActionDefinition>;
|
|
89
97
|
}
|
|
90
98
|
|
|
99
|
+
/** Agent routing config for a scope. See {@link ScopeDefinition.agent}. */
|
|
100
|
+
export interface ScopeAgentConfig {
|
|
101
|
+
/** Selector tags an agent must carry to handle this scope. Default: `{ package: <name> }`. */
|
|
102
|
+
selector?: Record<string, string>;
|
|
103
|
+
/** Path the agent execs in the worker image. Default: `/app/packages/<name>/entrypoint`. */
|
|
104
|
+
entrypoint?: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Fill agent config defaults from the scope name (the package slug). */
|
|
108
|
+
export function resolveAgentConfig(definition: ScopeDefinition): Required<ScopeAgentConfig> {
|
|
109
|
+
const slug = definition.name;
|
|
110
|
+
const selector = definition.agent?.selector;
|
|
111
|
+
return {
|
|
112
|
+
selector: selector && Object.keys(selector).length > 0 ? selector : { package: slug },
|
|
113
|
+
entrypoint: definition.agent?.entrypoint || `/app/packages/${slug}/entrypoint`,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
91
117
|
// ─── Exec payload parsing ────────────────────────────────────────────
|
|
92
118
|
// The platform only supports "exec" channel type today. np-agent reroutes
|
|
93
119
|
// exec commands to this plugin when the channel config includes NP_PLUGIN=scope
|
|
@@ -178,6 +204,66 @@ export function defineScope(definition: ScopeDefinition): void {
|
|
|
178
204
|
command_types: ["scope"],
|
|
179
205
|
});
|
|
180
206
|
|
|
207
|
+
// Subprocess exec mode: agents that run this binary directly (no gRPC host)
|
|
208
|
+
// pass the action in the NP_ACTION_CONTEXT env var. Run the one action, report
|
|
209
|
+
// its lifecycle status, print the result, and exit.
|
|
210
|
+
const execCtx = cfg.actionContext();
|
|
211
|
+
if (execCtx && cfg.agentMode() !== "np-agent-v1") {
|
|
212
|
+
void (async () => {
|
|
213
|
+
const platform = resolvePlatformClient();
|
|
214
|
+
let out: ExecuteResult;
|
|
215
|
+
let serviceId = "";
|
|
216
|
+
let actionId = "";
|
|
217
|
+
let lifecycle = false;
|
|
218
|
+
try {
|
|
219
|
+
const raw = JSON.parse(execCtx);
|
|
220
|
+
const notification: Notification = raw.notification ?? raw;
|
|
221
|
+
serviceId = notification.service?.id ?? "";
|
|
222
|
+
actionId = notification.id ?? "";
|
|
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.
|
|
226
|
+
const emit = (o: { stdout?: string; stderr?: string }) => {
|
|
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(() => {});
|
|
229
|
+
};
|
|
230
|
+
const actionSlug = resolveActionSlug(notification, notification.type ?? "");
|
|
231
|
+
const actionDef = definition.actions[actionSlug];
|
|
232
|
+
if (!actionDef) throw new Error(`Unknown action: ${actionSlug}`);
|
|
233
|
+
lifecycle = getActionDefaults(actionSlug).lifecycle;
|
|
234
|
+
if (lifecycle && actionId) {
|
|
235
|
+
await platform.transitionAction(serviceId, actionId, "in_progress").catch(() => {});
|
|
236
|
+
}
|
|
237
|
+
let data: unknown;
|
|
238
|
+
if (isWorkflowChain(actionDef.handler)) {
|
|
239
|
+
const observer = new StreamingExecutionObserver(emit, { apiUrl: cfg.executionsApiUrl() });
|
|
240
|
+
observer.emitPlan(actionDef.handler.toGraph());
|
|
241
|
+
const engine = new InMemoryEngine({ observer });
|
|
242
|
+
const wf = await actionDef.handler.run(notification, { engine, timeoutMs: 10 * 60 * 1000 });
|
|
243
|
+
if (wf.status !== "completed") throw new Error(wf.error?.message ?? `Workflow ${wf.status}`);
|
|
244
|
+
data = wf.output;
|
|
245
|
+
} else {
|
|
246
|
+
data = await actionDef.handler(notification, emit);
|
|
247
|
+
}
|
|
248
|
+
if (lifecycle && actionId) {
|
|
249
|
+
const results = data && typeof data === "object" ? (data as Record<string, unknown>) : undefined;
|
|
250
|
+
await platform.transitionAction(serviceId, actionId, "success", results).catch(() => {});
|
|
251
|
+
}
|
|
252
|
+
out = { success: true, data };
|
|
253
|
+
} catch (error) {
|
|
254
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
255
|
+
if (lifecycle && actionId) {
|
|
256
|
+
await platform.transitionAction(serviceId, actionId, "failed", { error: message }).catch(() => {});
|
|
257
|
+
}
|
|
258
|
+
out = { success: false, error: message, errorCode: "EXECUTION_ERROR" };
|
|
259
|
+
}
|
|
260
|
+
process.stdout.write(JSON.stringify(out));
|
|
261
|
+
process.exit(out.success ? 0 : 1);
|
|
262
|
+
})();
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const platform = resolvePlatformClient();
|
|
181
267
|
const plugin = createPlugin({
|
|
182
268
|
async execute(req: ExecuteRequest): Promise<ExecuteResult> {
|
|
183
269
|
// Handle both native "scope" and legacy "exec" command types
|
|
@@ -205,9 +291,10 @@ export function defineScope(definition: ScopeDefinition): void {
|
|
|
205
291
|
|
|
206
292
|
const defaults = getActionDefaults(actionSlug);
|
|
207
293
|
const actionId = notification.id;
|
|
294
|
+
const serviceId = notification.service?.id ?? "";
|
|
208
295
|
|
|
209
296
|
if (defaults.lifecycle && actionId) {
|
|
210
|
-
await
|
|
297
|
+
await platform.transitionAction(serviceId, actionId, "in_progress").catch((err) => {
|
|
211
298
|
console.error(`[defineScope] Failed to patch action status: ${err.message}`);
|
|
212
299
|
});
|
|
213
300
|
}
|
|
@@ -218,7 +305,7 @@ export function defineScope(definition: ScopeDefinition): void {
|
|
|
218
305
|
if (isWorkflowChain(actionDef.handler)) {
|
|
219
306
|
// Workflow chain: emit plan, run with engine
|
|
220
307
|
const observer = new StreamingExecutionObserver(req.emit, {
|
|
221
|
-
apiUrl:
|
|
308
|
+
apiUrl: cfg.executionsApiUrl(),
|
|
222
309
|
});
|
|
223
310
|
observer.emitPlan(actionDef.handler.toGraph());
|
|
224
311
|
|
|
@@ -235,7 +322,8 @@ export function defineScope(definition: ScopeDefinition): void {
|
|
|
235
322
|
}
|
|
236
323
|
|
|
237
324
|
if (defaults.lifecycle && actionId) {
|
|
238
|
-
|
|
325
|
+
const results = result && typeof result === "object" ? (result as Record<string, unknown>) : undefined;
|
|
326
|
+
await platform.transitionAction(serviceId, actionId, "success", results).catch((err) => {
|
|
239
327
|
console.error(`[defineScope] Failed to patch action status: ${err.message}`);
|
|
240
328
|
});
|
|
241
329
|
}
|
|
@@ -245,7 +333,7 @@ export function defineScope(definition: ScopeDefinition): void {
|
|
|
245
333
|
const message = error instanceof Error ? error.message : String(error);
|
|
246
334
|
|
|
247
335
|
if (defaults.lifecycle && actionId) {
|
|
248
|
-
await
|
|
336
|
+
await platform.transitionAction(serviceId, actionId, "failed", { error: message }).catch((err) => {
|
|
249
337
|
console.error(`[defineScope] Failed to patch action status: ${err.message}`);
|
|
250
338
|
});
|
|
251
339
|
}
|
package/src/scope/publish.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import { $ } from "bun";
|
|
17
17
|
import { getActionDefaults } from "./actions";
|
|
18
|
+
import { cfg } from "../config";
|
|
18
19
|
import type { ScopeDefinition } from "./index";
|
|
19
20
|
|
|
20
21
|
export async function handlePublish(definition: ScopeDefinition): Promise<void> {
|
|
@@ -161,7 +162,7 @@ export async function handlePublish(definition: ScopeDefinition): Promise<void>
|
|
|
161
162
|
console.log(` ${existingScopeType ? "↻" : "✓"} Scope type: ${scopeTypeId}`);
|
|
162
163
|
|
|
163
164
|
// 4. Dev notification channel (upsert by description tag)
|
|
164
|
-
const apiKey =
|
|
165
|
+
const apiKey = cfg.apiKey();
|
|
165
166
|
if (!apiKey) {
|
|
166
167
|
console.error("\n⚠ Skipping notification channel — NP_API_KEY not set");
|
|
167
168
|
console.error(" Set it and re-run, or create the channel manually via the UI");
|
|
@@ -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",
|
package/src/scope/lifecycle.ts
DELETED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Action lifecycle management.
|
|
3
|
-
*
|
|
4
|
-
* PATCHes service action status via the platform API.
|
|
5
|
-
* Mirrors what `np service-action exec` does:
|
|
6
|
-
* - PATCH status: "in_progress" on start
|
|
7
|
-
* - PATCH status: "completed" or "failed" on end
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
const API_URL = () => process.env.NP_API_URL ?? "https://api.nullplatform.com";
|
|
11
|
-
const API_KEY = () => process.env.NULLPLATFORM_APIKEY ?? "";
|
|
12
|
-
|
|
13
|
-
export async function patchActionStatus(
|
|
14
|
-
actionId: string,
|
|
15
|
-
status: "in_progress" | "completed" | "failed",
|
|
16
|
-
error?: string,
|
|
17
|
-
): Promise<void> {
|
|
18
|
-
const apiKey = API_KEY();
|
|
19
|
-
if (!apiKey) return; // skip lifecycle if no API key (local dev / testing)
|
|
20
|
-
|
|
21
|
-
const body: Record<string, unknown> = { status };
|
|
22
|
-
if (error) {
|
|
23
|
-
body.results = { error };
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const res = await fetch(`${API_URL()}/service/action/${actionId}`, {
|
|
27
|
-
method: "PATCH",
|
|
28
|
-
headers: {
|
|
29
|
-
Authorization: `Bearer ${apiKey}`,
|
|
30
|
-
"Content-Type": "application/json",
|
|
31
|
-
},
|
|
32
|
-
body: JSON.stringify(body),
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
if (!res.ok) {
|
|
36
|
-
const text = await res.text().catch(() => "");
|
|
37
|
-
throw new Error(`Lifecycle PATCH ${status} failed: ${res.status} ${res.statusText}${text ? ` — ${text}` : ""}`);
|
|
38
|
-
}
|
|
39
|
-
}
|