runwork 0.27.0 → 0.27.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.
@@ -229,14 +229,17 @@ export declare const integrationApiSchema: z.ZodObject<{
229
229
  method: z.ZodDefault<z.ZodEnum<["GET", "POST", "PUT", "PATCH", "DELETE"]>>;
230
230
  endpoint: z.ZodString;
231
231
  data: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
232
+ headers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
232
233
  }, "strip", z.ZodTypeAny, {
233
234
  endpoint: string;
234
235
  method: "POST" | "GET" | "PUT" | "PATCH" | "DELETE";
235
236
  data?: Record<string, unknown> | undefined;
237
+ headers?: Record<string, string> | undefined;
236
238
  }, {
237
239
  endpoint: string;
238
240
  data?: Record<string, unknown> | undefined;
239
241
  method?: "POST" | "GET" | "PUT" | "PATCH" | "DELETE" | undefined;
242
+ headers?: Record<string, string> | undefined;
240
243
  }>;
241
244
  /**
242
245
  * Get tool definitions for integration access
@@ -6,6 +6,16 @@
6
6
  * - Creates WorkflowInstance DOs
7
7
  * - Maintains an index for querying workflows
8
8
  * - Routes signals to correct instances
9
+ *
10
+ * Index storage: one storage key per instance entry (`index:<instanceId>`),
11
+ * read back with a prefix list. A single-value index hits the 2 MB per-value
12
+ * limit of SQLite-backed DO storage at roughly 11,000 entries, after which every
13
+ * write fails with SQLITE_TOOBIG. Apps deployed before the per-key layout still
14
+ * hold the legacy `workflow_index` value; it is migrated on first load.
15
+ *
16
+ * Retention: terminal entries older than the retention window are pruned from
17
+ * a self-armed alarm, and the index is trimmed inline when a write pushes it
18
+ * past the entry cap, so the index stays bounded without an external caller.
9
19
  */
10
20
  import { DurableObject } from 'cloudflare:workers';
11
21
  import type { NativeWorkflowStatus, NativeWorkflowConfig, ListWorkflowsOptions, WorkflowInstanceInfo, WorkflowInstanceStub } from './core-workflow-types';
@@ -19,8 +29,14 @@ import { type Env } from './core-utils';
19
29
  */
20
30
  export declare class WorkflowCoordinator extends DurableObject<Env> {
21
31
  private index;
32
+ /** Entry cap applied inline on writes and by the retention alarm. */
33
+ readonly retentionMaxEntries = 10000;
22
34
  /**
23
- * Create a new workflow instance
35
+ * Create a new workflow instance.
36
+ *
37
+ * The index entry and stored params are written before the instance starts,
38
+ * so a completion notification from a fast workflow can never race ahead of
39
+ * its own pending entry, and an index failure surfaces before any work runs.
24
40
  */
25
41
  create(workflowName: string, params: Record<string, unknown>, config?: Partial<NativeWorkflowConfig>, metadata?: Record<string, unknown>): Promise<string>;
26
42
  /**
@@ -65,14 +81,36 @@ export declare class WorkflowCoordinator extends DurableObject<Env> {
65
81
  byWorkflow: Record<string, number>;
66
82
  }>;
67
83
  /**
68
- * Clean up completed/old workflow entries
69
- * Should be called periodically to prevent index bloat
84
+ * Clean up completed/old workflow entries.
85
+ *
86
+ * Removes terminal entries older than `olderThanMs`, every completed entry
87
+ * unless `keepCompleted` is set, and then the oldest terminal entries until
88
+ * the index fits within `maxEntries`. Active entries (pending, running,
89
+ * sleeping, waiting, paused) are never removed. Runs from the retention alarm
90
+ * and inline on writes; explicit calls remain supported.
70
91
  */
71
92
  cleanup(options?: {
72
93
  olderThanMs?: number;
73
94
  keepCompleted?: boolean;
74
95
  maxEntries?: number;
75
96
  }): Promise<number>;
97
+ /**
98
+ * Retention alarm: prune old terminal entries and re-arm while the index
99
+ * still holds anything.
100
+ */
101
+ alarm(): Promise<void>;
76
102
  private ensureIndexLoaded;
77
- private persistIndex;
103
+ /**
104
+ * Move a legacy single-value index into per-entry keys. Per-entry values
105
+ * already present win, since they were written after the legacy value.
106
+ * An unreadable legacy value is dropped: the workflow instances themselves
107
+ * still hold their state, only the listing loses history.
108
+ */
109
+ private migrateLegacyIndex;
110
+ private deleteLegacyIndexKey;
111
+ /**
112
+ * Remove index entries and their stored params, in storage batches.
113
+ */
114
+ private removeEntries;
115
+ private ensureRetentionAlarm;
78
116
  }
@@ -28,7 +28,12 @@ export interface NangoProxyOptions {
28
28
  method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
29
29
  endpoint: string;
30
30
  providerConfigKey: string;
31
- data?: Record<string, unknown>;
31
+ /**
32
+ * Request body. Encoded per the Content-Type in `headers`: form-encoded with
33
+ * bracket notation for application/x-www-form-urlencoded, JSON otherwise.
34
+ * A string is sent verbatim.
35
+ */
36
+ data?: Record<string, unknown> | string;
32
37
  headers?: Record<string, string>;
33
38
  params?: Record<string, string>;
34
39
  retries?: number;
package/dist/index.js CHANGED
@@ -427,6 +427,124 @@ function safeRm(path) {
427
427
  var MAX_REDIRECTS = 10, transportOverride = "auto";
428
428
  var init_http = () => {};
429
429
 
430
+ // ../../shared/utils/integration-body.ts
431
+ function findHeader(headers, name) {
432
+ if (!headers)
433
+ return;
434
+ const wanted = name.toLowerCase();
435
+ for (const [key, value] of Object.entries(headers)) {
436
+ if (key.toLowerCase() === wanted)
437
+ return value;
438
+ }
439
+ return;
440
+ }
441
+ function setHeader(headers, name, value) {
442
+ const wanted = name.toLowerCase();
443
+ const next = {};
444
+ for (const [key, existing] of Object.entries(headers)) {
445
+ if (key.toLowerCase() !== wanted)
446
+ next[key] = existing;
447
+ }
448
+ next[name] = value;
449
+ return next;
450
+ }
451
+ function mediaType(contentType) {
452
+ return (contentType ?? "").split(";")[0].trim().toLowerCase();
453
+ }
454
+ function isJsonContentType(contentType) {
455
+ const type = mediaType(contentType);
456
+ return type === JSON_CONTENT_TYPE || type.endsWith("+json");
457
+ }
458
+ function isFormContentType(contentType) {
459
+ return mediaType(contentType) === FORM_CONTENT_TYPE;
460
+ }
461
+ function isMultipartContentType(contentType) {
462
+ return mediaType(contentType) === "multipart/form-data";
463
+ }
464
+ function appendFormValue(out, key, value) {
465
+ if (value === undefined)
466
+ return;
467
+ if (value === null) {
468
+ out.append(key, "");
469
+ return;
470
+ }
471
+ if (Array.isArray(value)) {
472
+ value.forEach((item, index) => appendFormValue(out, `${key}[${index}]`, item));
473
+ return;
474
+ }
475
+ if (typeof value === "object") {
476
+ for (const [childKey, childValue] of Object.entries(value)) {
477
+ appendFormValue(out, `${key}[${childKey}]`, childValue);
478
+ }
479
+ return;
480
+ }
481
+ out.append(key, String(value));
482
+ }
483
+ function encodeFormBody(value) {
484
+ if (typeof value === "string")
485
+ return value;
486
+ if (value === undefined || value === null)
487
+ return "";
488
+ const out = new URLSearchParams;
489
+ if (Array.isArray(value) || typeof value === "object") {
490
+ for (const [key, child] of Object.entries(value)) {
491
+ appendFormValue(out, key, child);
492
+ }
493
+ } else {
494
+ out.append("value", String(value));
495
+ }
496
+ return out.toString();
497
+ }
498
+ function looksLikeJson(text) {
499
+ const trimmed = text.trim();
500
+ if (!(trimmed.startsWith("{") || trimmed.startsWith("[")))
501
+ return false;
502
+ try {
503
+ JSON.parse(trimmed);
504
+ return true;
505
+ } catch {
506
+ return false;
507
+ }
508
+ }
509
+ function encodeIntegrationBody(body, contentType) {
510
+ if (body === undefined || body === null) {
511
+ return { body: undefined, contentType };
512
+ }
513
+ if (isMultipartContentType(contentType)) {
514
+ throw new Error("multipart/form-data bodies are not supported by the integrations proxy. Send application/x-www-form-urlencoded or JSON instead.");
515
+ }
516
+ if (typeof body === "string") {
517
+ if (contentType)
518
+ return { body, contentType };
519
+ return { body, contentType: looksLikeJson(body) ? JSON_CONTENT_TYPE : TEXT_CONTENT_TYPE };
520
+ }
521
+ if (isFormContentType(contentType)) {
522
+ return { body: encodeFormBody(body), contentType };
523
+ }
524
+ return { body: JSON.stringify(body), contentType: contentType ?? JSON_CONTENT_TYPE };
525
+ }
526
+ function encodeIntegrationRequest(body, headers = {}) {
527
+ const encoded = encodeIntegrationBody(body, findHeader(headers, "Content-Type"));
528
+ if (encoded.body === undefined) {
529
+ return { body: undefined, headers };
530
+ }
531
+ return {
532
+ body: encoded.body,
533
+ headers: setHeader(headers, "Content-Type", encoded.contentType ?? JSON_CONTENT_TYPE)
534
+ };
535
+ }
536
+ function parseBodyArgument(text, contentType) {
537
+ try {
538
+ return JSON.parse(text);
539
+ } catch {
540
+ if (contentType && !isJsonContentType(contentType)) {
541
+ return text;
542
+ }
543
+ throw new Error('Body is not valid JSON. To send a raw or form-encoded body, set a matching Content-Type header (for example --header "Content-Type: application/x-www-form-urlencoded").');
544
+ }
545
+ }
546
+ var JSON_CONTENT_TYPE = "application/json", FORM_CONTENT_TYPE = "application/x-www-form-urlencoded", TEXT_CONTENT_TYPE = "text/plain; charset=utf-8";
547
+
430
548
  // src/api/client.ts
431
549
  class ApiClient {
432
550
  baseUrl;
@@ -868,22 +986,22 @@ class ApiClient {
868
986
  return res.data;
869
987
  }
870
988
  async callIntegrationProxy(integrationDbId, method, path, opts) {
871
- const targetPath = opts?.query ? `${path}?${opts.query}` : path;
989
+ const targetPath = opts?.query ? `${path}${path.includes("?") ? "&" : "?"}${opts.query}` : path;
872
990
  const url = `${this.baseUrl}/api/proxy/integrations${targetPath}`;
873
- const headers = {
991
+ const upperMethod = method.toUpperCase();
992
+ const hasBody = opts?.body !== undefined && opts?.body !== null && upperMethod !== "GET" && upperMethod !== "HEAD";
993
+ const encoded = encodeIntegrationRequest(hasBody ? opts?.body : undefined, {
874
994
  "X-Workspace-Integration-Id": integrationDbId,
875
- ...Object.fromEntries(Object.entries(opts?.headers || {}))
876
- };
995
+ ...opts?.headers || {}
996
+ });
997
+ const headers = encoded.headers;
877
998
  if (this.apiKey) {
878
999
  headers["Authorization"] = `Bearer ${this.apiKey}`;
879
1000
  }
880
- if (opts?.body) {
881
- headers["Content-Type"] = "application/json";
882
- }
883
1001
  const response = await httpFetch(url, {
884
- method,
1002
+ method: upperMethod,
885
1003
  headers,
886
- body: opts?.body ? JSON.stringify(opts.body) : undefined
1004
+ body: encoded.body
887
1005
  });
888
1006
  if (!response.ok) {
889
1007
  const body = await response.text();
@@ -6212,6 +6330,16 @@ export declare class WorkflowInstanceDO extends DurableObject<Env> {
6212
6330
  * - Creates WorkflowInstance DOs
6213
6331
  * - Maintains an index for querying workflows
6214
6332
  * - Routes signals to correct instances
6333
+ *
6334
+ * Index storage: one storage key per instance entry (\`index:<instanceId>\`),
6335
+ * read back with a prefix list. A single-value index hits the 2 MB per-value
6336
+ * limit of SQLite-backed DO storage at roughly 11,000 entries, after which every
6337
+ * write fails with SQLITE_TOOBIG. Apps deployed before the per-key layout still
6338
+ * hold the legacy \`workflow_index\` value; it is migrated on first load.
6339
+ *
6340
+ * Retention: terminal entries older than the retention window are pruned from
6341
+ * a self-armed alarm, and the index is trimmed inline when a write pushes it
6342
+ * past the entry cap, so the index stays bounded without an external caller.
6215
6343
  */
6216
6344
  import { DurableObject } from 'cloudflare:workers';
6217
6345
  import type { NativeWorkflowStatus, NativeWorkflowConfig, ListWorkflowsOptions, WorkflowInstanceInfo, WorkflowInstanceStub } from './core-workflow-types';
@@ -6225,8 +6353,14 @@ import { type Env } from './core-utils';
6225
6353
  */
6226
6354
  export declare class WorkflowCoordinator extends DurableObject<Env> {
6227
6355
  private index;
6356
+ /** Entry cap applied inline on writes and by the retention alarm. */
6357
+ readonly retentionMaxEntries = 10000;
6228
6358
  /**
6229
- * Create a new workflow instance
6359
+ * Create a new workflow instance.
6360
+ *
6361
+ * The index entry and stored params are written before the instance starts,
6362
+ * so a completion notification from a fast workflow can never race ahead of
6363
+ * its own pending entry, and an index failure surfaces before any work runs.
6230
6364
  */
6231
6365
  create(workflowName: string, params: Record<string, unknown>, config?: Partial<NativeWorkflowConfig>, metadata?: Record<string, unknown>): Promise<string>;
6232
6366
  /**
@@ -6271,16 +6405,38 @@ export declare class WorkflowCoordinator extends DurableObject<Env> {
6271
6405
  byWorkflow: Record<string, number>;
6272
6406
  }>;
6273
6407
  /**
6274
- * Clean up completed/old workflow entries
6275
- * Should be called periodically to prevent index bloat
6408
+ * Clean up completed/old workflow entries.
6409
+ *
6410
+ * Removes terminal entries older than \`olderThanMs\`, every completed entry
6411
+ * unless \`keepCompleted\` is set, and then the oldest terminal entries until
6412
+ * the index fits within \`maxEntries\`. Active entries (pending, running,
6413
+ * sleeping, waiting, paused) are never removed. Runs from the retention alarm
6414
+ * and inline on writes; explicit calls remain supported.
6276
6415
  */
6277
6416
  cleanup(options?: {
6278
6417
  olderThanMs?: number;
6279
6418
  keepCompleted?: boolean;
6280
6419
  maxEntries?: number;
6281
6420
  }): Promise<number>;
6421
+ /**
6422
+ * Retention alarm: prune old terminal entries and re-arm while the index
6423
+ * still holds anything.
6424
+ */
6425
+ alarm(): Promise<void>;
6282
6426
  private ensureIndexLoaded;
6283
- private persistIndex;
6427
+ /**
6428
+ * Move a legacy single-value index into per-entry keys. Per-entry values
6429
+ * already present win, since they were written after the legacy value.
6430
+ * An unreadable legacy value is dropped: the workflow instances themselves
6431
+ * still hold their state, only the listing loses history.
6432
+ */
6433
+ private migrateLegacyIndex;
6434
+ private deleteLegacyIndexKey;
6435
+ /**
6436
+ * Remove index entries and their stored params, in storage batches.
6437
+ */
6438
+ private removeEntries;
6439
+ private ensureRetentionAlarm;
6284
6440
  }
6285
6441
  `,
6286
6442
  "core-integration-entities.d.ts": `/**
@@ -7962,14 +8118,17 @@ export declare const integrationApiSchema: z.ZodObject<{
7962
8118
  method: z.ZodDefault<z.ZodEnum<["GET", "POST", "PUT", "PATCH", "DELETE"]>>;
7963
8119
  endpoint: z.ZodString;
7964
8120
  data: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
8121
+ headers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
7965
8122
  }, "strip", z.ZodTypeAny, {
7966
8123
  endpoint: string;
7967
8124
  method: "POST" | "GET" | "PUT" | "PATCH" | "DELETE";
7968
8125
  data?: Record<string, unknown> | undefined;
8126
+ headers?: Record<string, string> | undefined;
7969
8127
  }, {
7970
8128
  endpoint: string;
7971
8129
  data?: Record<string, unknown> | undefined;
7972
8130
  method?: "POST" | "GET" | "PUT" | "PATCH" | "DELETE" | undefined;
8131
+ headers?: Record<string, string> | undefined;
7973
8132
  }>;
7974
8133
  /**
7975
8134
  * Get tool definitions for integration access
@@ -8205,7 +8364,7 @@ function createKeyboardListener() {
8205
8364
  }
8206
8365
 
8207
8366
  // src/generated/version.ts
8208
- var VERSION = "0.27.0";
8367
+ var VERSION = "0.27.1";
8209
8368
 
8210
8369
  // src/commands/dev.ts
8211
8370
  var exports_dev = {};
@@ -19885,17 +20044,10 @@ async function parseCurlToRequest(curlStr) {
19885
20044
  try {
19886
20045
  body = JSON.parse(dataStr);
19887
20046
  } catch {
19888
- if (dataStr.includes("=") && !dataStr.includes("{")) {
19889
- const formData = {};
19890
- for (const pair of dataStr.split("&")) {
19891
- const eqIdx = pair.indexOf("=");
19892
- if (eqIdx > 0) {
19893
- formData[decodeURIComponent(pair.slice(0, eqIdx))] = decodeURIComponent(pair.slice(eqIdx + 1));
19894
- }
19895
- }
19896
- body = formData;
19897
- } else {
19898
- body = dataStr;
20047
+ body = dataStr;
20048
+ const hasContentType = Object.keys(filteredHeaders).some((k) => k.toLowerCase() === "content-type");
20049
+ if (!hasContentType) {
20050
+ filteredHeaders["Content-Type"] = "application/x-www-form-urlencoded";
19899
20051
  }
19900
20052
  }
19901
20053
  }
@@ -20021,16 +20173,19 @@ Used by your team (${teamOnly.length} more):
20021
20173
  process.exit(1);
20022
20174
  }
20023
20175
  });
20024
- var callCommand = new Command10("call").description("Make a proxy call to a connected integration").argument("<integration>", "Integration name (e.g., hubspot, slack)").argument("[method]", "HTTP method (GET, POST, PUT, DELETE)").argument("[path]", "API path (e.g., /crm/v3/contacts)").option("--workspace <name-or-id>", "Workspace name or ID").option("--body <json>", "Request body JSON").option("--header <header>", "Request header (repeatable)", (val, prev) => [...prev, val], []).option("--query <query>", "Query string (e.g., limit=10&offset=0)").option("--curl <command>", 'Parse a curl command (paste from Chrome DevTools "Copy as cURL")').option("--curl-file <file>", "Read curl command from a file").action(async (integration, method, path2, opts, command) => {
20176
+ var callCommand = new Command10("call").description("Make a proxy call to a connected integration").argument("<integration>", "Integration name (e.g., hubspot, slack)").argument("[method]", "HTTP method (GET, POST, PUT, DELETE)").argument("[path]", "API path (e.g., /crm/v3/contacts)").option("--workspace <name-or-id>", "Workspace name or ID").option("--body <body>", "Request body: JSON, or a raw string when a non-JSON Content-Type header is set").option("--form", "Send the body as application/x-www-form-urlencoded (Stripe, Twilio, OAuth token endpoints). Accepts a JSON object (form-encoded, nested keys in bracket notation) or an already-encoded a=b&c=d string").option("--header <header>", "Request header (repeatable). Content-Type decides how --body is encoded", (val, prev) => [...prev, val], []).option("--query <query>", "Query string (e.g., limit=10&offset=0)").option("--curl <command>", 'Parse a curl command (paste from Chrome DevTools "Copy as cURL")').option("--curl-file <file>", "Read curl command from a file").action(async (integration, method, path2, opts, command) => {
20025
20177
  const useJson = shouldOutputJson(command.optsWithGlobals().json);
20026
20178
  const credentials = requireAuth();
20027
20179
  const client = new ApiClient(credentials);
20028
20180
  const { workspaceId } = await resolveWorkspace2(client, opts);
20029
20181
  let finalMethod;
20030
20182
  let finalPath;
20031
- const headers = {};
20183
+ let headers = {};
20032
20184
  let body;
20033
20185
  let query = opts.query;
20186
+ if (opts.form) {
20187
+ headers = setHeader(headers, "Content-Type", FORM_CONTENT_TYPE);
20188
+ }
20034
20189
  if (opts.curl || opts.curlFile) {
20035
20190
  let curlStr = opts.curl;
20036
20191
  if (opts.curlFile) {
@@ -20047,7 +20202,7 @@ var callCommand = new Command10("call").description("Make a proxy call to a conn
20047
20202
  finalPath = result.path;
20048
20203
  if (result.query)
20049
20204
  query = query || result.query;
20050
- Object.assign(headers, result.headers);
20205
+ headers = opts.form ? setHeader({ ...result.headers }, "Content-Type", FORM_CONTENT_TYPE) : { ...result.headers };
20051
20206
  body = result.body;
20052
20207
  } catch (err) {
20053
20208
  console.error("Failed to parse curl command:", err instanceof Error ? err.message : err);
@@ -20058,13 +20213,13 @@ var callCommand = new Command10("call").description("Make a proxy call to a conn
20058
20213
  finalPath = path2;
20059
20214
  for (const h of opts.header || []) {
20060
20215
  const [key, ...rest] = h.split(":");
20061
- headers[key.trim()] = rest.join(":").trim();
20216
+ headers = setHeader(headers, key.trim(), rest.join(":").trim());
20062
20217
  }
20063
20218
  if (opts.body) {
20064
20219
  try {
20065
- body = JSON.parse(opts.body);
20066
- } catch {
20067
- console.error("Invalid JSON in --body");
20220
+ body = parseBodyArgument(opts.body, findHeader(headers, "Content-Type"));
20221
+ } catch (err) {
20222
+ console.error(err instanceof Error ? err.message : "Invalid --body");
20068
20223
  process.exit(1);
20069
20224
  }
20070
20225
  }
@@ -21819,7 +21974,7 @@ function generateIntroSkill(ctx) {
21819
21974
  lines.push("| `runwork entities list` | Browse data across apps |");
21820
21975
  lines.push("| `runwork entities records <name>` | Query entity records |");
21821
21976
  lines.push("| `runwork workflows trigger <name>` | Trigger a workflow |");
21822
- lines.push("| `runwork integrations call <id> <method> <path>` | Call an integration API |");
21977
+ lines.push("| `runwork integrations call <id> <method> <path>` | Call an integration API (`--body` JSON; `--form` for form-encoded APIs such as Stripe) |");
21823
21978
  lines.push("| `runwork integrations search <query>` | Search 3,200+ available integrations |");
21824
21979
  lines.push("| `runwork logs` | View app logs |");
21825
21980
  lines.push("| `runwork doctor` | Check system health (`--fix` auto-remediates git auth/remote; `--check <names>` scopes output) |");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runwork",
3
- "version": "0.27.0",
3
+ "version": "0.27.1",
4
4
  "description": "CLI for Runwork: develop, preview, and deploy Runwork apps from your local machine.",
5
5
  "license": "UNLICENSED",
6
6
  "author": "Runwork, Inc. <info@runwork.ai> (https://www.runwork.ai)",