@nullplatform/plugin 0.0.2 → 0.0.4-alpha.1
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 +3 -1
- package/src/api.ts +5 -3
- package/src/compat/index.ts +14 -0
- package/src/compat/lifecycle.ts +84 -0
- package/src/config.ts +47 -0
- package/src/internal/manifest.ts +4 -2
- package/src/scope/describe.ts +4 -1
- package/src/scope/index.ts +96 -5
- package/src/scope/publish.ts +2 -1
- 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.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"dependencies": {
|
|
15
15
|
"@grpc/grpc-js": "^1.13.0",
|
|
16
16
|
"@grpc/proto-loader": "^0.7.0",
|
|
17
|
+
"config": "^4.4.2",
|
|
17
18
|
"yaml": "^2.7.0"
|
|
18
19
|
},
|
|
19
20
|
"files": [
|
|
@@ -46,6 +47,7 @@
|
|
|
46
47
|
},
|
|
47
48
|
"devDependencies": {
|
|
48
49
|
"@types/bun": "^1.2.0",
|
|
50
|
+
"@types/config": "^4.4.0",
|
|
49
51
|
"json-schema-to-ts": "^3.1.1"
|
|
50
52
|
}
|
|
51
53
|
}
|
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,47 @@
|
|
|
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
|
+
/** The action payload injected in subprocess-exec mode. env: NP_ACTION_CONTEXT */
|
|
43
|
+
actionContext: () => read("agent.actionContext", "NP_ACTION_CONTEXT"),
|
|
44
|
+
|
|
45
|
+
/** Path to a plugin manifest override. env: NP_PLUGIN_MANIFEST */
|
|
46
|
+
pluginManifest: () => read("plugin.manifest", "NP_PLUGIN_MANIFEST"),
|
|
47
|
+
} as const;
|
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;
|
|
@@ -23,9 +24,10 @@ export function loadManifest(): PluginManifest {
|
|
|
23
24
|
return registeredManifest;
|
|
24
25
|
}
|
|
25
26
|
|
|
27
|
+
const manifestOverride = cfg.pluginManifest();
|
|
26
28
|
const manifestPath = resolve(
|
|
27
|
-
|
|
28
|
-
|
|
29
|
+
manifestOverride || process.cwd(),
|
|
30
|
+
manifestOverride ? "" : "plugin.yaml",
|
|
29
31
|
);
|
|
30
32
|
|
|
31
33
|
if (!existsSync(manifestPath)) {
|
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 { patchActionStatus } from "
|
|
33
|
+
import { patchActionStatus, patchActionMessages } from "../compat";
|
|
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,69 @@ 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
|
+
let out: ExecuteResult;
|
|
214
|
+
let serviceId = "";
|
|
215
|
+
let actionId = "";
|
|
216
|
+
let lifecycle = false;
|
|
217
|
+
try {
|
|
218
|
+
const raw = JSON.parse(execCtx);
|
|
219
|
+
const notification: Notification = raw.notification ?? raw;
|
|
220
|
+
serviceId = notification.service?.id ?? "";
|
|
221
|
+
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.
|
|
224
|
+
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
|
+
}
|
|
233
|
+
};
|
|
234
|
+
const actionSlug = resolveActionSlug(notification, notification.type ?? "");
|
|
235
|
+
const actionDef = definition.actions[actionSlug];
|
|
236
|
+
if (!actionDef) throw new Error(`Unknown action: ${actionSlug}`);
|
|
237
|
+
lifecycle = getActionDefaults(actionSlug).lifecycle;
|
|
238
|
+
if (lifecycle && actionId) {
|
|
239
|
+
await patchActionStatus(serviceId, actionId, "in_progress").catch(() => {});
|
|
240
|
+
}
|
|
241
|
+
let data: unknown;
|
|
242
|
+
if (isWorkflowChain(actionDef.handler)) {
|
|
243
|
+
const observer = new StreamingExecutionObserver(emit, { apiUrl: cfg.executionsApiUrl() });
|
|
244
|
+
observer.emitPlan(actionDef.handler.toGraph());
|
|
245
|
+
const engine = new InMemoryEngine({ observer });
|
|
246
|
+
const wf = await actionDef.handler.run(notification, { engine, timeoutMs: 10 * 60 * 1000 });
|
|
247
|
+
if (wf.status !== "completed") throw new Error(wf.error?.message ?? `Workflow ${wf.status}`);
|
|
248
|
+
data = wf.output;
|
|
249
|
+
} else {
|
|
250
|
+
data = await actionDef.handler(notification, emit);
|
|
251
|
+
}
|
|
252
|
+
if (lifecycle && actionId) {
|
|
253
|
+
const results = data && typeof data === "object" ? (data as Record<string, unknown>) : undefined;
|
|
254
|
+
await patchActionStatus(serviceId, actionId, "success", results).catch(() => {});
|
|
255
|
+
}
|
|
256
|
+
out = { success: true, data };
|
|
257
|
+
} catch (error) {
|
|
258
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
259
|
+
if (lifecycle && actionId) {
|
|
260
|
+
await patchActionStatus(serviceId, actionId, "failed", { error: message }).catch(() => {});
|
|
261
|
+
}
|
|
262
|
+
out = { success: false, error: message, errorCode: "EXECUTION_ERROR" };
|
|
263
|
+
}
|
|
264
|
+
process.stdout.write(JSON.stringify(out));
|
|
265
|
+
process.exit(out.success ? 0 : 1);
|
|
266
|
+
})();
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
181
270
|
const plugin = createPlugin({
|
|
182
271
|
async execute(req: ExecuteRequest): Promise<ExecuteResult> {
|
|
183
272
|
// Handle both native "scope" and legacy "exec" command types
|
|
@@ -205,9 +294,10 @@ export function defineScope(definition: ScopeDefinition): void {
|
|
|
205
294
|
|
|
206
295
|
const defaults = getActionDefaults(actionSlug);
|
|
207
296
|
const actionId = notification.id;
|
|
297
|
+
const serviceId = notification.service?.id ?? "";
|
|
208
298
|
|
|
209
299
|
if (defaults.lifecycle && actionId) {
|
|
210
|
-
await patchActionStatus(actionId, "in_progress").catch((err) => {
|
|
300
|
+
await patchActionStatus(serviceId, actionId, "in_progress").catch((err) => {
|
|
211
301
|
console.error(`[defineScope] Failed to patch action status: ${err.message}`);
|
|
212
302
|
});
|
|
213
303
|
}
|
|
@@ -218,7 +308,7 @@ export function defineScope(definition: ScopeDefinition): void {
|
|
|
218
308
|
if (isWorkflowChain(actionDef.handler)) {
|
|
219
309
|
// Workflow chain: emit plan, run with engine
|
|
220
310
|
const observer = new StreamingExecutionObserver(req.emit, {
|
|
221
|
-
apiUrl:
|
|
311
|
+
apiUrl: cfg.executionsApiUrl(),
|
|
222
312
|
});
|
|
223
313
|
observer.emitPlan(actionDef.handler.toGraph());
|
|
224
314
|
|
|
@@ -235,7 +325,8 @@ export function defineScope(definition: ScopeDefinition): void {
|
|
|
235
325
|
}
|
|
236
326
|
|
|
237
327
|
if (defaults.lifecycle && actionId) {
|
|
238
|
-
|
|
328
|
+
const results = result && typeof result === "object" ? (result as Record<string, unknown>) : undefined;
|
|
329
|
+
await patchActionStatus(serviceId, actionId, "success", results).catch((err) => {
|
|
239
330
|
console.error(`[defineScope] Failed to patch action status: ${err.message}`);
|
|
240
331
|
});
|
|
241
332
|
}
|
|
@@ -245,7 +336,7 @@ export function defineScope(definition: ScopeDefinition): void {
|
|
|
245
336
|
const message = error instanceof Error ? error.message : String(error);
|
|
246
337
|
|
|
247
338
|
if (defaults.lifecycle && actionId) {
|
|
248
|
-
await patchActionStatus(actionId, "failed", message).catch((err) => {
|
|
339
|
+
await patchActionStatus(serviceId, actionId, "failed", { error: message }).catch((err) => {
|
|
249
340
|
console.error(`[defineScope] Failed to patch action status: ${err.message}`);
|
|
250
341
|
});
|
|
251
342
|
}
|
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");
|
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
|
-
}
|