@desplega.ai/agent-swarm 1.80.1 → 1.80.3

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.
@@ -2,7 +2,14 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
2
  import crypto from "node:crypto";
3
3
  import { unlink } from "node:fs/promises";
4
4
  import { z } from "zod";
5
- import { closeDb, createWorkflow, getWorkflowRun, initDb, updateWorkflow } from "../be/db";
5
+ import {
6
+ closeDb,
7
+ createWorkflow,
8
+ getWorkflowRun,
9
+ initDb,
10
+ updateWorkflow,
11
+ upsertSwarmConfig,
12
+ } from "../be/db";
6
13
  import type { Workflow } from "../types";
7
14
  import { startWorkflowExecution } from "../workflows/engine";
8
15
  import { BaseExecutor, type ExecutorResult } from "../workflows/executors/base";
@@ -118,7 +125,12 @@ describe("handleWebhookTrigger", () => {
118
125
  hmac.update(body);
119
126
  const sig = `sha256=${hmac.digest("hex")}`;
120
127
 
121
- const result = await handleWebhookTrigger(workflow.id, body, sig, sig, registry);
128
+ const result = await handleWebhookTrigger(
129
+ workflow.id,
130
+ body,
131
+ { "x-hub-signature-256": sig },
132
+ registry,
133
+ );
122
134
 
123
135
  expect(result.runId).toBeDefined();
124
136
  expect(typeof result.runId).toBe("string");
@@ -138,8 +150,7 @@ describe("handleWebhookTrigger", () => {
138
150
  await handleWebhookTrigger(
139
151
  workflow.id,
140
152
  '{"test":true}',
141
- "sha256=invalid",
142
- "sha256=invalid",
153
+ { "x-hub-signature-256": "sha256=invalid" },
143
154
  registry,
144
155
  );
145
156
  expect(true).toBe(false); // Should not reach here
@@ -155,7 +166,7 @@ describe("handleWebhookTrigger", () => {
155
166
  });
156
167
 
157
168
  try {
158
- await handleWebhookTrigger(workflow.id, '{"test":true}', undefined, undefined, registry);
169
+ await handleWebhookTrigger(workflow.id, '{"test":true}', {}, registry);
159
170
  expect(true).toBe(false);
160
171
  } catch (err) {
161
172
  expect(err).toBeInstanceOf(WebhookError);
@@ -168,13 +179,7 @@ describe("handleWebhookTrigger", () => {
168
179
  triggers: [{ type: "webhook" }],
169
180
  });
170
181
 
171
- const result = await handleWebhookTrigger(
172
- workflow.id,
173
- '{"data":"hello"}',
174
- undefined,
175
- undefined,
176
- registry,
177
- );
182
+ const result = await handleWebhookTrigger(workflow.id, '{"data":"hello"}', {}, registry);
178
183
 
179
184
  expect(result.runId).toBeDefined();
180
185
  const run = getWorkflowRun(result.runId);
@@ -183,13 +188,7 @@ describe("handleWebhookTrigger", () => {
183
188
 
184
189
  test("workflow not found returns 404", async () => {
185
190
  try {
186
- await handleWebhookTrigger(
187
- "00000000-0000-0000-0000-000000000000",
188
- "{}",
189
- undefined,
190
- undefined,
191
- registry,
192
- );
191
+ await handleWebhookTrigger("00000000-0000-0000-0000-000000000000", "{}", {}, registry);
193
192
  expect(true).toBe(false);
194
193
  } catch (err) {
195
194
  expect(err).toBeInstanceOf(WebhookError);
@@ -205,7 +204,7 @@ describe("handleWebhookTrigger", () => {
205
204
  updateWorkflow(workflow.id, { enabled: false });
206
205
 
207
206
  try {
208
- await handleWebhookTrigger(workflow.id, "{}", undefined, undefined, registry);
207
+ await handleWebhookTrigger(workflow.id, "{}", {}, registry);
209
208
  expect(true).toBe(false);
210
209
  } catch (err) {
211
210
  expect(err).toBeInstanceOf(WebhookError);
@@ -214,6 +213,248 @@ describe("handleWebhookTrigger", () => {
214
213
  });
215
214
  });
216
215
 
216
+ // ─── Custom HMAC header + secret refs ───────────────────────
217
+
218
+ describe("handleWebhookTrigger — custom hmacHeader", () => {
219
+ function signRaw(secret: string, body: string): string {
220
+ return crypto.createHmac("sha256", secret).update(body).digest("hex");
221
+ }
222
+
223
+ test("custom hmacHeader (X-Webhook-Signature) is picked up and verified", async () => {
224
+ const secret = "kapso-secret";
225
+ const workflow = makeWorkflow({
226
+ triggers: [{ type: "webhook", hmacSecret: secret, hmacHeader: "X-Webhook-Signature" }],
227
+ });
228
+
229
+ const body = '{"event":"message"}';
230
+ // Kapso-style: raw hex, no `sha256=` prefix.
231
+ const result = await handleWebhookTrigger(
232
+ workflow.id,
233
+ body,
234
+ { "x-webhook-signature": signRaw(secret, body) },
235
+ registry,
236
+ );
237
+
238
+ expect(result.runId).toBeDefined();
239
+ expect(getWorkflowRun(result.runId)).not.toBeNull();
240
+ });
241
+
242
+ test("custom hmacHeader lookup is case-insensitive", async () => {
243
+ const secret = "kapso-secret-ci";
244
+ const workflow = makeWorkflow({
245
+ triggers: [{ type: "webhook", hmacSecret: secret, hmacHeader: "X-Webhook-Signature" }],
246
+ });
247
+
248
+ const body = '{"event":"ci"}';
249
+ const result = await handleWebhookTrigger(
250
+ workflow.id,
251
+ body,
252
+ { "X-Webhook-Signature": signRaw(secret, body) },
253
+ registry,
254
+ );
255
+
256
+ expect(result.runId).toBeDefined();
257
+ });
258
+
259
+ test("signature on a non-configured header is rejected as missing", async () => {
260
+ const secret = "kapso-secret-2";
261
+ const workflow = makeWorkflow({
262
+ triggers: [{ type: "webhook", hmacSecret: secret, hmacHeader: "X-Webhook-Signature" }],
263
+ });
264
+
265
+ const body = '{"event":"x"}';
266
+ // Use a header that is neither the configured one nor a known fallback.
267
+ try {
268
+ await handleWebhookTrigger(
269
+ workflow.id,
270
+ body,
271
+ { "x-some-other-header": signRaw(secret, body) },
272
+ registry,
273
+ );
274
+ expect(true).toBe(false);
275
+ } catch (err) {
276
+ expect(err).toBeInstanceOf(WebhookError);
277
+ expect((err as WebhookError).statusCode).toBe(401);
278
+ }
279
+ });
280
+
281
+ test("fallback header (x-signature) still works without explicit hmacHeader", async () => {
282
+ const secret = "fallback-secret";
283
+ const workflow = makeWorkflow({
284
+ triggers: [{ type: "webhook", hmacSecret: secret }],
285
+ });
286
+
287
+ const body = '{"event":"fallback"}';
288
+ const result = await handleWebhookTrigger(
289
+ workflow.id,
290
+ body,
291
+ { "x-signature": signRaw(secret, body) },
292
+ registry,
293
+ );
294
+
295
+ expect(result.runId).toBeDefined();
296
+ });
297
+
298
+ test("default X-Hub-Signature-256 path still works (no regression)", async () => {
299
+ const secret = "default-header-secret";
300
+ const workflow = makeWorkflow({
301
+ triggers: [{ type: "webhook", hmacSecret: secret }],
302
+ });
303
+
304
+ const body = '{"event":"default"}';
305
+ const sig = `sha256=${signRaw(secret, body)}`;
306
+ const result = await handleWebhookTrigger(
307
+ workflow.id,
308
+ body,
309
+ { "x-hub-signature-256": sig },
310
+ registry,
311
+ );
312
+
313
+ expect(result.runId).toBeDefined();
314
+ });
315
+ });
316
+
317
+ describe("handleWebhookTrigger — hmacSecret references", () => {
318
+ function signRaw(secret: string, body: string): string {
319
+ return crypto.createHmac("sha256", secret).update(body).digest("hex");
320
+ }
321
+
322
+ test("hmacSecret as secret.NAME ref resolves and verifies", async () => {
323
+ const SECRET_VALUE = "resolved-kapso-hmac-value";
324
+ upsertSwarmConfig({
325
+ scope: "global",
326
+ key: "TEST_KAPSO_WEBHOOK_HMAC_SECRET",
327
+ value: SECRET_VALUE,
328
+ isSecret: true,
329
+ });
330
+
331
+ const workflow = makeWorkflow({
332
+ triggers: [
333
+ {
334
+ type: "webhook",
335
+ hmacSecret: "secret.TEST_KAPSO_WEBHOOK_HMAC_SECRET",
336
+ hmacHeader: "X-Webhook-Signature",
337
+ },
338
+ ],
339
+ });
340
+
341
+ const body = '{"event":"secret-ref"}';
342
+ const result = await handleWebhookTrigger(
343
+ workflow.id,
344
+ body,
345
+ { "x-webhook-signature": signRaw(SECRET_VALUE, body) },
346
+ registry,
347
+ );
348
+
349
+ expect(result.runId).toBeDefined();
350
+ expect(getWorkflowRun(result.runId)).not.toBeNull();
351
+ });
352
+
353
+ test("unresolvable secret.NAME ref fails cleanly with a WebhookError", async () => {
354
+ const workflow = makeWorkflow({
355
+ triggers: [{ type: "webhook", hmacSecret: "secret.NONEXISTENT_HMAC_SECRET_12345" }],
356
+ });
357
+
358
+ const body = '{"event":"missing-secret"}';
359
+ try {
360
+ await handleWebhookTrigger(
361
+ workflow.id,
362
+ body,
363
+ { "x-hub-signature-256": "deadbeef" },
364
+ registry,
365
+ );
366
+ expect(true).toBe(false);
367
+ } catch (err) {
368
+ expect(err).toBeInstanceOf(WebhookError);
369
+ expect((err as WebhookError).statusCode).toBe(500);
370
+ }
371
+ });
372
+
373
+ test("a literal hmacSecret is not treated as a reference", async () => {
374
+ const secret = "plain.literal-not-a-ref";
375
+ const workflow = makeWorkflow({
376
+ triggers: [{ type: "webhook", hmacSecret: secret }],
377
+ });
378
+
379
+ const body = '{"event":"literal"}';
380
+ const result = await handleWebhookTrigger(
381
+ workflow.id,
382
+ body,
383
+ { "x-hub-signature-256": signRaw(secret, body) },
384
+ registry,
385
+ );
386
+
387
+ expect(result.runId).toBeDefined();
388
+ });
389
+ });
390
+
391
+ // ─── Trigger payload JSON parsing ───────────────────────────
392
+
393
+ describe("handleWebhookTrigger — triggerData JSON parsing", () => {
394
+ function signRaw(secret: string, body: string): string {
395
+ return crypto.createHmac("sha256", secret).update(body).digest("hex");
396
+ }
397
+
398
+ test("JSON body is parsed and run.triggerData is a deep-equal object", async () => {
399
+ const workflow = makeWorkflow({ triggers: [{ type: "webhook" }] });
400
+ const payload = {
401
+ message: { from: "+34000111222", text: "hi" },
402
+ conversation: { id: "conv-abc-123" },
403
+ };
404
+ const body = JSON.stringify(payload);
405
+
406
+ const result = await handleWebhookTrigger(workflow.id, body, {}, registry);
407
+
408
+ const run = getWorkflowRun(result.runId);
409
+ expect(run).not.toBeNull();
410
+ expect(run!.triggerData).toEqual(payload);
411
+ // Deep paths must be reachable (this is what `{{trigger.message.from}}` needs).
412
+ expect((run!.triggerData as { message: { from: string } }).message.from).toBe("+34000111222");
413
+ });
414
+
415
+ test("signed JSON body: HMAC verified against raw bytes, triggerData parsed to object", async () => {
416
+ const secret = "kapso-deep-secret";
417
+ const workflow = makeWorkflow({
418
+ triggers: [{ type: "webhook", hmacSecret: secret, hmacHeader: "X-Webhook-Signature" }],
419
+ });
420
+ // Use whitespace + unsorted keys so any re-serialization would change the bytes.
421
+ const body = '{ "message": {"from":"+1","text":"hi"}, "id":"x" }';
422
+ const sig = signRaw(secret, body);
423
+
424
+ const result = await handleWebhookTrigger(
425
+ workflow.id,
426
+ body,
427
+ { "x-webhook-signature": sig },
428
+ registry,
429
+ );
430
+
431
+ const run = getWorkflowRun(result.runId);
432
+ expect(run).not.toBeNull();
433
+ expect(run!.triggerData).toEqual({ message: { from: "+1", text: "hi" }, id: "x" });
434
+ });
435
+
436
+ test("non-JSON body falls back to the raw string and does not throw", async () => {
437
+ const workflow = makeWorkflow({ triggers: [{ type: "webhook" }] });
438
+ const body = "this is not json at all";
439
+
440
+ const result = await handleWebhookTrigger(workflow.id, body, {}, registry);
441
+
442
+ const run = getWorkflowRun(result.runId);
443
+ expect(run).not.toBeNull();
444
+ expect(run!.triggerData).toBe(body);
445
+ });
446
+
447
+ test("empty body produces a run without throwing", async () => {
448
+ const workflow = makeWorkflow({ triggers: [{ type: "webhook" }] });
449
+
450
+ const result = await handleWebhookTrigger(workflow.id, "", {}, registry);
451
+
452
+ expect(result.runId).toBeDefined();
453
+ const run = getWorkflowRun(result.runId);
454
+ expect(run).not.toBeNull();
455
+ });
456
+ });
457
+
217
458
  // ─── Manual Trigger ─────────────────────────────────────────
218
459
 
219
460
  describe("manual trigger (startWorkflowExecution)", () => {
package/src/types.ts CHANGED
@@ -1320,6 +1320,7 @@ export const ScriptRecordSchema = z.object({
1320
1320
  description: z.string(),
1321
1321
  intent: z.string(),
1322
1322
  signatureJson: z.string(),
1323
+ argsJsonSchema: z.string().nullable(),
1323
1324
  contentHash: z.string(),
1324
1325
  version: z.number(),
1325
1326
  isScratch: z.boolean(),
@@ -10,8 +10,21 @@ export interface ErrorSignal {
10
10
  timestamp: string;
11
11
  }
12
12
 
13
+ /**
14
+ * Clamps a candidate reset timestamp (ms) to [now+60s, now+6h].
15
+ * Protects against past timestamps (clock skew) and absurdly far future values (malformed).
16
+ */
17
+ function clampRateLimitResetMs(candidateMs: number): number {
18
+ const nowMs = Date.now();
19
+ const minMs = nowMs + 60_000;
20
+ const maxMs = nowMs + 6 * 60 * 60 * 1000;
21
+ return Math.min(Math.max(candidateMs, minMs), maxMs);
22
+ }
23
+
13
24
  export class SessionErrorTracker {
14
25
  private errors: ErrorSignal[] = [];
26
+ /** Stashed reset time (ms) from the last rejected rate_limit_event in this session. */
27
+ private rateLimitResetAtMs: number | undefined;
15
28
 
16
29
  /** Record an error from an assistant message with message.error field */
17
30
  addApiError(errorCategory: string, message: string): void {
@@ -53,6 +66,45 @@ export class SessionErrorTracker {
53
66
  });
54
67
  }
55
68
 
69
+ /**
70
+ * Process a parsed rate_limit_event JSON object from the Claude CLI stream.
71
+ * Only stashes the reset time when status === "rejected"; ignores all others.
72
+ * Last call wins — if the CLI emits multiple events, the final rejected one is used.
73
+ *
74
+ * `resetsAt` is **seconds** since epoch (empirically verified; Linear description is wrong).
75
+ * Conversion to ms happens here at this single well-named boundary.
76
+ */
77
+ processRateLimitEvent(json: Record<string, unknown>): void {
78
+ try {
79
+ const info = json.rate_limit_info as Record<string, unknown> | undefined;
80
+ if (!info) return;
81
+
82
+ if (info.status !== "rejected") return;
83
+
84
+ const resetsAtSec = info.resetsAt;
85
+ if (typeof resetsAtSec !== "number" || !Number.isFinite(resetsAtSec) || resetsAtSec <= 0) {
86
+ console.warn(
87
+ `[rate_limit_event] Malformed resetsAt value: ${JSON.stringify(resetsAtSec)} — ignoring`,
88
+ );
89
+ return;
90
+ }
91
+
92
+ const resetsAtMs = resetsAtSec * 1000;
93
+ this.rateLimitResetAtMs = clampRateLimitResetMs(resetsAtMs);
94
+ } catch (err) {
95
+ console.warn(`[rate_limit_event] Failed to process event: ${err}`);
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Returns the stashed rate limit reset time as an ISO string, or undefined
101
+ * if no rejected rate_limit_event was seen in this session.
102
+ */
103
+ getRateLimitResetAt(): string | undefined {
104
+ if (this.rateLimitResetAtMs === undefined) return undefined;
105
+ return new Date(this.rateLimitResetAtMs).toISOString();
106
+ }
107
+
56
108
  hasErrors(): boolean {
57
109
  return this.errors.length > 0;
58
110
  }
@@ -137,6 +189,12 @@ export function trackErrorFromJson(
137
189
  json: Record<string, unknown>,
138
190
  tracker: SessionErrorTracker,
139
191
  ): void {
192
+ // 0. Structured rate limit event — stash resetsAt for the three-tier resolver in runner.ts
193
+ if (json.type === "rate_limit_event") {
194
+ tracker.processRateLimitEvent(json);
195
+ return;
196
+ }
197
+
140
198
  // 1. Assistant messages with API errors (rate_limit, auth, billing, etc.)
141
199
  if (json.type === "assistant") {
142
200
  const message = json.message as Record<string, unknown> | undefined;
@@ -13,13 +13,18 @@ export function resolveInputs(input: Record<string, string>): Record<string, str
13
13
  const resolved: Record<string, string> = {};
14
14
 
15
15
  for (const [key, value] of Object.entries(input)) {
16
- resolved[key] = resolveValue(value);
16
+ resolved[key] = resolveInputValue(value);
17
17
  }
18
18
 
19
19
  return resolved;
20
20
  }
21
21
 
22
- function resolveValue(value: string): string {
22
+ /**
23
+ * Resolve a single input value, supporting `${ENV_VAR}` and `secret.NAME`
24
+ * references. A plain string is returned unchanged. Throws if a referenced
25
+ * env var or swarm secret cannot be found.
26
+ */
27
+ export function resolveInputValue(value: string): string {
23
28
  // Env var reference: ${MY_VAR}
24
29
  const envMatch = /^\$\{(.+)\}$/.exec(value);
25
30
  if (envMatch?.[1]) {
@@ -3,19 +3,75 @@ import { getWorkflow, getWorkflowsByScheduleId } from "../be/db";
3
3
  import type { ScheduledTask, TriggerConfig } from "../types";
4
4
  import { startWorkflowExecution } from "./engine";
5
5
  import type { ExecutorRegistry } from "./executors/registry";
6
+ import { resolveInputValue } from "./input";
7
+
8
+ /** Header name used to look up an HMAC signature when the trigger configures none. */
9
+ const DEFAULT_HMAC_HEADER = "X-Hub-Signature-256";
10
+
11
+ /** Fallback header names checked (case-insensitive) after the trigger's configured header. */
12
+ const FALLBACK_HMAC_HEADERS = ["x-hub-signature-256", "x-signature", "x-webhook-signature"];
13
+
14
+ /** A bag of HTTP headers — values may be a string, a string array, or absent. */
15
+ export type HeaderBag = Record<string, string | string[] | undefined>;
16
+
17
+ /** Case-insensitive header lookup; returns the first value when an array is given. */
18
+ function getHeader(headers: HeaderBag, name: string): string | undefined {
19
+ const target = name.toLowerCase();
20
+ for (const [key, value] of Object.entries(headers)) {
21
+ if (key.toLowerCase() === target) {
22
+ return Array.isArray(value) ? value[0] : value;
23
+ }
24
+ }
25
+ return undefined;
26
+ }
27
+
28
+ /**
29
+ * Resolve the HMAC signature from request headers. Checks the trigger's
30
+ * configured `hmacHeader` first, then well-known fallback header names.
31
+ */
32
+ function resolveSignature(headers: HeaderBag, hmacHeader: string): string | undefined {
33
+ for (const name of [hmacHeader, ...FALLBACK_HMAC_HEADERS]) {
34
+ const value = getHeader(headers, name);
35
+ if (value) return value;
36
+ }
37
+ return undefined;
38
+ }
39
+
40
+ /**
41
+ * Resolve the configured `hmacSecret`. Supports `secret.NAME` swarm-secret refs
42
+ * and `${ENV_VAR}` env refs (reusing the workflow input resolver); a plain
43
+ * string is treated as a literal. Resolved per request, never at create time.
44
+ */
45
+ function resolveHmacSecret(raw: string): string {
46
+ if (/^secret\..+$/.test(raw) || /^\$\{.+\}$/.test(raw)) {
47
+ try {
48
+ return resolveInputValue(raw);
49
+ } catch (err) {
50
+ throw new WebhookError(
51
+ `Failed to resolve webhook HMAC secret: ${err instanceof Error ? err.message : String(err)}`,
52
+ 500,
53
+ );
54
+ }
55
+ }
56
+ return raw;
57
+ }
6
58
 
7
59
  /**
8
60
  * Handle an incoming webhook trigger for a workflow.
9
61
  *
10
62
  * 1. Loads the workflow and finds a webhook trigger in `triggers[]`
11
- * 2. If `hmacSecret` is set, verifies HMAC-SHA256 signature
12
- * 3. Starts the workflow execution with the webhook payload
63
+ * 2. If `hmacSecret` is set, resolves the signature header + secret and
64
+ * verifies the HMAC-SHA256 signature against the raw body bytes
65
+ * 3. Parses the raw body as JSON (falling back to the raw string when the
66
+ * body is non-JSON) so downstream `{{trigger.deep.path}}` interpolation
67
+ * can traverse the object — matches the shape produced by the
68
+ * `trigger-workflow` MCP tool.
69
+ * 4. Starts the workflow execution with the parsed payload
13
70
  */
14
71
  export async function handleWebhookTrigger(
15
72
  workflowId: string,
16
73
  payload: unknown,
17
- signature: string | undefined,
18
- signatureHeader: string | undefined,
74
+ headers: HeaderBag,
19
75
  registry: ExecutorRegistry,
20
76
  ): Promise<{ runId: string }> {
21
77
  const workflow = getWorkflow(workflowId);
@@ -31,16 +87,20 @@ export async function handleWebhookTrigger(
31
87
  const webhookTrigger = workflow.triggers.find((t: TriggerConfig) => t.type === "webhook");
32
88
 
33
89
  // If the workflow has a webhook trigger with an hmacSecret, verify the signature
90
+ // against the RAW body bytes — re-serializing would change whitespace / key order
91
+ // and break the HMAC.
34
92
  if (webhookTrigger && webhookTrigger.type === "webhook" && webhookTrigger.hmacSecret) {
35
- if (!signature && !signatureHeader) {
93
+ const hmacHeader = webhookTrigger.hmacHeader || DEFAULT_HMAC_HEADER;
94
+ const signature = resolveSignature(headers, hmacHeader);
95
+ if (!signature) {
36
96
  throw new WebhookError("Missing signature", 401);
37
97
  }
38
98
 
39
- const rawSignature = signatureHeader || signature || "";
99
+ const secret = resolveHmacSecret(webhookTrigger.hmacSecret);
40
100
  const isValid = verifyHmacSignature(
41
- webhookTrigger.hmacSecret,
101
+ secret,
42
102
  typeof payload === "string" ? payload : JSON.stringify(payload),
43
- rawSignature,
103
+ signature,
44
104
  );
45
105
 
46
106
  if (!isValid) {
@@ -48,10 +108,30 @@ export async function handleWebhookTrigger(
48
108
  }
49
109
  }
50
110
 
51
- const runId = await startWorkflowExecution(workflow, payload, registry);
111
+ // Parse the raw body so downstream nodes can interpolate deep paths
112
+ // (e.g. `{{trigger.message.from}}`). A non-JSON body falls back to the raw
113
+ // string so non-JSON webhooks don't break.
114
+ const triggerData = parseTriggerPayload(payload);
115
+
116
+ const runId = await startWorkflowExecution(workflow, triggerData, registry);
52
117
  return { runId };
53
118
  }
54
119
 
120
+ /**
121
+ * If `payload` is a JSON string, parse and return the resulting value;
122
+ * otherwise return it as-is. Empty / non-JSON strings fall back to the raw
123
+ * value so non-JSON webhooks (text/plain, form-encoded, etc.) still produce
124
+ * a usable workflow run.
125
+ */
126
+ function parseTriggerPayload(payload: unknown): unknown {
127
+ if (typeof payload !== "string" || payload.length === 0) return payload;
128
+ try {
129
+ return JSON.parse(payload);
130
+ } catch {
131
+ return payload;
132
+ }
133
+ }
134
+
55
135
  /**
56
136
  * Handle a schedule trigger: find workflows linked to this schedule and execute them.
57
137
  * Returns an array of workflow run IDs. Empty array means no workflows matched