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.
- package/agentic-endpoint.mjs +325 -0
- package/agentic.mjs +6 -0
- package/c8ctl-plugin.js +168 -0
- package/package.json +10 -8
- package/supervisor-engine.mjs +193 -0
- package/supervisor.dist.js +18 -18
|
@@ -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
|
+
}
|