@nullplatform/plugin 0.0.3 → 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 CHANGED
@@ -8,7 +8,9 @@
8
8
  <br>
9
9
  </h2>
10
10
 
11
- TypeScript SDK for building nullplatform plugins. Handles gRPC transport (HashiCorp go-plugin protocol), action routing, `--describe`, and `--publish`.
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
- Plugins are scaffolded with `np plugin init` and managed via mise tasks:
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
- mise run dev # local dev with hot reload
81
- mise run dev:agent # dev with np-agent attached
82
- mise run test # run tests
83
- mise run build # compile to standalone binary
84
- mise run publish # publish to platform
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",
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 = process.env.NP_API_URL ?? "https://api.nullplatform.com";
21
- const apiKey = process.env.NULLPLATFORM_APIKEY ?? "";
22
+ const apiUrl = cfg.apiUrl();
23
+ const apiKey = cfg.apiKey();
22
24
  if (!apiKey) {
23
- throw new Error("NULLPLATFORM_APIKEY environment variable is not set");
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;
@@ -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
- process.env.NP_PLUGIN_MANIFEST || process.cwd(),
28
- process.env.NP_PLUGIN_MANIFEST ? "" : "plugin.yaml",
29
+ manifestOverride || process.cwd(),
30
+ manifestOverride ? "" : "plugin.yaml",
29
31
  );
30
32
 
31
33
  if (!existsSync(manifestPath)) {
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { getActionDefaults } from "./actions";
9
- import type { ScopeDefinition } from "./index";
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));
@@ -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 "./lifecycle";
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: process.env.NP_EXECUTIONS_API_URL,
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
- await patchActionStatus(actionId, "completed").catch((err) => {
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
  }
@@ -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 = process.env.NP_API_KEY;
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");
@@ -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
- }