c8ctl-plugin-nano 1.48.0 → 1.50.0

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.
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Concrete C8 v2 REST engine client — the {@link RawEngineClient} the
3
+ * single-owner supervisor runtime consumes as its {@link EngineClient} port
4
+ * (issue #156).
5
+ *
6
+ * #154's activation loop deliberately does NOT use the `@camunda8` SDK job
7
+ * worker: the SDK models one poller per type with `maxJobsToActivate =
8
+ * maxParallel − active` and structurally cannot express "global capacity S
9
+ * shared across K types with per-type gating". The supervisor rolls its own race
10
+ * over a NARROW two-call engine surface instead:
11
+ *
12
+ * - `activate` → `POST <base>/v2/jobs/activation` for exactly ONE type, one
13
+ * long-poll, resolving 0..`maxJobsToActivate` jobs (0 == the long-poll
14
+ * expired empty). `timeout` is the SHORT initial lock (the crash-safety net);
15
+ * `requestTimeout` is how long the call blocks server-side.
16
+ * - `extendLock` → `PATCH <base>/v2/jobs/{jobKey}/timeout` with `{ timeout }`.
17
+ * The C8 contract SETs the lock to `ms` from now (a duration-from-now), which
18
+ * is exactly the supervisor's "extend the winner to the recovery window, then
19
+ * heartbeat" model — set, not accumulate.
20
+ *
21
+ * This module is the raw-JS analogue of `agentic-endpoint.mjs`: it is Effect-free
22
+ * (the supervisor's `makeEngineClient` lift wraps each method into the Effect
23
+ * port + `SupervisorError` channel) and quarantines undici/`fetch` behind an
24
+ * injectable `fetchImpl`, so it is wire-testable with a fake `fetch` and never
25
+ * needs a live engine to cover its request shape / response mapping. A rejected
26
+ * promise here (network error or non-2xx) surfaces through the port as a
27
+ * `SupervisorError`, which the activation race treats as that poll losing and the
28
+ * dispatch treats as a likely reclaim — never a crash.
29
+ *
30
+ * @typedef {import('./supervisor.dist.js').ActivateRequest} ActivateRequest
31
+ * @typedef {import('./supervisor.dist.js').ActivatedJob} ActivatedJob
32
+ */
33
+
34
+ /**
35
+ * Normalize a C8 REST base to `<origin>/v2`. The SDK's `restAddress` may or may
36
+ * not already carry the `/v2` API prefix (CAMUNDA_REST_ADDRESS accepts either),
37
+ * so strip a trailing `/v2` (and any trailing slashes) before re-adding exactly
38
+ * one — mirroring the monolith's `normalizeRestBase` + `<base>/v2` convention so
39
+ * the activation calls hit the SAME endpoint the reconcile reader reads from.
40
+ */
41
+ export function v2Base(baseUrl) {
42
+ const trimmed = String(baseUrl || "").replace(/\/+$/, "").replace(/\/v2$/i, "");
43
+ return `${trimmed}/v2`;
44
+ }
45
+
46
+ /**
47
+ * Build the request headers for an engine call. A ready-made `authHeaders` map
48
+ * (from the activating SDK client's `getAuthHeaders()`, covering OAuth/basic/
49
+ * none) wins over a bare bearer `token`; an empty map means unauthenticated —
50
+ * deliberately no `Authorization` header (a local nano cluster is unauthed).
51
+ * Matches `fetchLinkedResourceContent`'s auth precedence exactly.
52
+ */
53
+ function buildHeaders({ token, authHeaders }) {
54
+ const headers = { "Content-Type": "application/json", Accept: "application/json" };
55
+ if (authHeaders && typeof authHeaders === "object") Object.assign(headers, authHeaders);
56
+ else if (token) headers.Authorization = `Bearer ${token}`;
57
+ return headers;
58
+ }
59
+
60
+ /**
61
+ * Map one raw v2 activated-job record to the port's {@link ActivatedJob}. Keys are
62
+ * strings in v2. A record missing `jobKey`/`type` violates the `ActivatedJob`
63
+ * contract (an empty key would produce `PATCH .../jobs//timeout`; an empty type
64
+ * would dispatch a typeless job), so fail the whole activation rather than coerce
65
+ * to `""` — a malformed engine response surfaces as a rejected activate() call.
66
+ */
67
+ function mapJob(raw) {
68
+ const rawKey = raw?.jobKey ?? raw?.key;
69
+ const jobKey = rawKey === undefined || rawKey === null ? "" : String(rawKey);
70
+ const rawType = raw?.type;
71
+ const type = rawType === undefined || rawType === null ? "" : String(rawType);
72
+ if (!jobKey || !type) {
73
+ const missing = !jobKey && !type ? "jobKey and type" : !jobKey ? "jobKey" : "type";
74
+ // Summarise the record (keys/type/pdk) — never JSON.stringify the whole raw
75
+ // job, which can carry `variables` (potentially sensitive payload) into
76
+ // logs/error messages.
77
+ const summary =
78
+ raw && typeof raw === "object"
79
+ ? `keys=[${Object.keys(raw).join(",")}] type=${JSON.stringify(rawType)} pdk=${JSON.stringify(
80
+ raw.processDefinitionKey ?? raw.processDefinitionId,
81
+ )}`
82
+ : `type=${typeof raw}`;
83
+ throw new Error(`activate: malformed activated job from engine (missing ${missing}): ${summary}`);
84
+ }
85
+ const job = { jobKey, type };
86
+ const pdk = raw.processDefinitionKey ?? raw.processDefinitionId;
87
+ if (pdk !== undefined && pdk !== null) job.processDefinitionKey = String(pdk);
88
+ if (raw.variables !== undefined) job.variables = raw.variables;
89
+ return job;
90
+ }
91
+
92
+ async function readErrorBody(res) {
93
+ try {
94
+ const text = await res.text();
95
+ return text ? ` — ${text.slice(0, 500)}` : "";
96
+ } catch {
97
+ return "";
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Create the raw C8 v2 engine client.
103
+ *
104
+ * @param {object} opts
105
+ * @param {string} opts.baseUrl Engine REST base (with or without `/v2`).
106
+ * @param {string} [opts.worker] Worker id stamped on every activation (required by v2; defaults to `"c8ctl-supervisor"`).
107
+ * @param {string} [opts.token] Optional bearer token (unauthed when absent and no `authHeaders`).
108
+ * @param {Record<string,string>|(() => (Record<string,string>|Promise<Record<string,string>>))} [opts.authHeaders] Ready-made auth header map, OR a resolver invoked per request (so rotating SDK auth — e.g. an OAuth bearer that refreshes — is re-derived each call rather than frozen). Wins over `token`.
109
+ * @param {typeof fetch} [opts.fetchImpl] Injected `fetch` (defaults to the global; overridden in tests).
110
+ * @param {number} [opts.requestTimeoutSlackMs] Extra ms added to a call's abort budget over its server long-poll (default 5000).
111
+ * @returns {{ activate(req: ActivateRequest): Promise<ReadonlyArray<ActivatedJob>>, extendLock(jobKey: string, ms: number): Promise<void> }}
112
+ */
113
+ export function createRawEngineClient(opts = {}) {
114
+ const {
115
+ baseUrl,
116
+ worker = "c8ctl-supervisor",
117
+ token,
118
+ authHeaders,
119
+ fetchImpl = fetch,
120
+ requestTimeoutSlackMs = 5_000,
121
+ } = opts;
122
+ if (typeof fetchImpl !== "function") {
123
+ throw new TypeError("createRawEngineClient: `fetchImpl` must be a function (global fetch or an injected fake)");
124
+ }
125
+ if (typeof baseUrl !== "string" || baseUrl.trim() === "") {
126
+ throw new TypeError("createRawEngineClient: `baseUrl` is required (the engine REST base, with or without `/v2`)");
127
+ }
128
+ // Trim before deriving the base: `v2Base` strips trailing `/` and `/v2` but
129
+ // preserves interior/edge whitespace, so a base URL with leading/trailing
130
+ // spaces (e.g. from env/config) would pass the non-empty check yet yield an
131
+ // invalid request URL like `http://engine:8080 /v2/...`.
132
+ const base = v2Base(baseUrl.trim());
133
+ // Auth headers may be a static map OR a resolver function. SDK auth (e.g. an
134
+ // OAuth bearer) rotates on token refresh, so a long-lived client must re-derive
135
+ // headers PER CALL rather than freeze a startup snapshot that silently expires.
136
+ // A static map (or bare token) is resolved once and reused.
137
+ const resolveHeaders =
138
+ typeof authHeaders === "function"
139
+ ? async () => buildHeaders({ token, authHeaders: await authHeaders() })
140
+ : (() => {
141
+ const staticHeaders = buildHeaders({ token, authHeaders });
142
+ return async () => staticHeaders;
143
+ })();
144
+
145
+ /**
146
+ * Issue one call with an abort budget. `abortAfterMs <= 0` means no timer
147
+ * (the caller relies purely on the server long-poll / connection).
148
+ */
149
+ async function call(url, init, abortAfterMs) {
150
+ const controller = new AbortController();
151
+ const timer = abortAfterMs > 0 ? setTimeout(() => controller.abort(), abortAfterMs) : null;
152
+ try {
153
+ const headers = await resolveHeaders();
154
+ return await fetchImpl(url, { ...init, headers, signal: controller.signal });
155
+ } finally {
156
+ if (timer) clearTimeout(timer);
157
+ }
158
+ }
159
+
160
+ return {
161
+ async activate(req) {
162
+ const body = JSON.stringify({
163
+ type: req.type,
164
+ worker,
165
+ maxJobsToActivate: req.maxJobsToActivate,
166
+ // `timeout` is the SHORT initial lock applied to any returned job.
167
+ timeout: req.lockMs,
168
+ // `requestTimeout` is the server-side long-poll window.
169
+ requestTimeout: req.requestTimeoutMs,
170
+ });
171
+ // Give the abort budget slack over the server long-poll so we don't cancel
172
+ // a still-valid long-poll a hair before the server would answer it.
173
+ const abortAfterMs = req.requestTimeoutMs > 0 ? req.requestTimeoutMs + requestTimeoutSlackMs : 0;
174
+ const res = await call(`${base}/jobs/activation`, { method: "POST", body }, abortAfterMs);
175
+ if (!res || !res.ok) {
176
+ const status = res ? res.status : "?";
177
+ throw new Error(`activate ${req.type}: HTTP ${status} from ${base}/jobs/activation${res ? await readErrorBody(res) : ""}`);
178
+ }
179
+ const json = await res.json();
180
+ const jobs = Array.isArray(json?.jobs) ? json.jobs : Array.isArray(json) ? json : [];
181
+ return jobs.map(mapJob);
182
+ },
183
+
184
+ async extendLock(jobKey, ms) {
185
+ const url = `${base}/jobs/${encodeURIComponent(jobKey)}/timeout`;
186
+ const res = await call(url, { method: "PATCH", body: JSON.stringify({ timeout: ms }) }, 15_000);
187
+ if (!res || !res.ok) {
188
+ const status = res ? res.status : "?";
189
+ throw new Error(`extendLock ${jobKey}: HTTP ${status} from ${url}${res ? await readErrorBody(res) : ""}`);
190
+ }
191
+ },
192
+ };
193
+ }